diff --git a/AGENTS.md b/AGENTS.md index 93203cd8c..407efa4d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,8 @@ Full-stack framework for Cloudflare Workers, powered by Vite. The `void` package on npm has these previous versions: '0.0.1', '0.1.0', '0.1.2', '0.1.3', '0.1.5', '0.1.6', '1.0.0', '1.0.1', '1.0.2', '3.0.0', '3.0.1'. We have unpublished these versions but they cannot be published again. In package release workflows, make sure to avoid / skip these versions for `packages/void`. +Deploy hosted production before publishing the npm packages: the CLI checks the platform version. See [operations](./platform/meta/operations.md#github-actions). + ## Best Practices That Must Be Followed - **Do not use Python for repository scripts or automation**, including inline CI scripts and ad-hoc maintenance commands. Use Node.js with TypeScript/JavaScript, or shell/PowerShell where appropriate. diff --git a/docs/guide/platform/installation/maintenance.md b/docs/guide/platform/installation/maintenance.md index 217c3854f..e06279c2b 100644 --- a/docs/guide/platform/installation/maintenance.md +++ b/docs/guide/platform/installation/maintenance.md @@ -68,7 +68,7 @@ Repair recreates missing infrastructure that the installer owns. It does not res ### Upgrade the platform -Keep the platform runtime current when updating the Void CLI. If a developer's CLI reports that project lookup requires an upgrade, upgrade the platform before retrying. +Keep the platform runtime current when updating the Void CLI. The CLI requires platform version 0.23.0 or newer. If a developer's CLI reports that the platform is older than that, upgrade the platform before retrying. Use the installed CLI's packaged runtime to upgrade: diff --git a/docs/guide/sandboxes.md b/docs/guide/sandboxes.md index f4200fa69..d099a9c96 100644 --- a/docs/guide/sandboxes.md +++ b/docs/guide/sandboxes.md @@ -113,7 +113,7 @@ Files, running processes, and listening servers last only as long as the contain Sandboxes require [Workers Paid](https://dash.cloudflare.com/?to=/:account/workers/plans) and Containers access. A managed platform's runtime token needs Account / Containers: Edit and Account / Cloudchamber: Edit. -`void deploy --platform cloudflare` builds native images and deploys the Sandbox alongside the application. `void deploy` on a connected Void Platform applies the platform's Sandbox concurrency and runtime limits. Upgrade the platform before deploying an application built with this Sandbox API. +`void deploy --platform cloudflare` builds native images and deploys the Sandbox alongside the application. `void deploy` on a connected Void Platform applies the platform's Sandbox concurrency and runtime limits. The platform must run version 0.23.0 or newer. ## Moving from Sandbox SDK 0.x diff --git a/docs/guide/self-hosted-platform.md b/docs/guide/self-hosted-platform.md index 333ddf77f..2774e760f 100644 --- a/docs/guide/self-hosted-platform.md +++ b/docs/guide/self-hosted-platform.md @@ -8,6 +8,8 @@ A Void platform lets your team deploy apps into a shared Cloudflare account. As If you're deploying an app for yourself, [deploy directly to Cloudflare](/integrations/cloudflare#deploy-to-your-own-cloudflare-account). You don't need to install a platform first. +The Void CLI works with platforms on version 0.23.0 or newer. On an older platform, commands stop with an error. Ask the platform administrator to run [`void platform upgrade`](/guide/platform/installation/maintenance#upgrade-the-platform). Signing in still works. + The core platform supports GitHub, Google, generic OIDC, and Cloudflare Access login, CLI deploys, D1, KV, R2, Queues, cron jobs, Workers AI, WebSockets, SSE, ISR, routing, logs, and rollback. The core installation includes managed Sandboxes and an email gateway; email is enabled when you configure a shared mail domain and zone. The user dashboard, GitHub builds and webhooks, build Containers, and custom project domains aren't part of the core installation. Source-built platforms that add the optional GitHub services should follow the [isolated webhook ingress setup](/guide/platform/development/runtime#optional-github-webhook-ingress-for-access-protected-apis) when Access protects the API. diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index 5008d691c..25864d2bf 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -75,6 +75,7 @@ generated entries must preserve the module-boundary rules below. - **Cloudflare session switching**: Explicit `void cloudflare login` forces a fresh browser sign-in, while automatic authentication reuses valid sessions. Adopted OAuth sessions persist the keyring preference so later compatible CLI logins update the same encrypted store. Environment API credentials block explicit browser login rather than masking it. Status reports identity/auth method and the pinned account separately; login never rewrites the project target. Keep credentials out of all diagnostics. +- **Minimum platform version**: `MIN_PLATFORM_VERSION` in `src/cli/client.ts` is the oldest Void platform release the CLI supports. `PlatformClient` checks public discovery once per process and base URL and keeps every outcome. A release version below the minimum, or a 404 from discovery (a platform that predates it), stops the command. `"hosted"` (a hosted deploy without `PLATFORM_VERSION`) and other non-release strings (custom or development builds) pass, as do unusable answers (network errors, a disabled platform, Access login pages, invalid documents), which leave the real request to report its own error. Hosted deploys set `PLATFORM_VERSION` from `packages/platform/package.json` (`platform/scripts/platform-version.ts`). Only login opts out. Operator, installer, and management commands do not use `PlatformClient`, so they keep working on older runtimes. Do not add CLI fallbacks for platforms older than the minimum. When a server change ships in a release, raise the minimum to that release and delete the fallbacks marked for it in the same change. - **Authentication commands**: `void auth login|status|logout` follows the saved `.void/project.json` deployment platform, or prompts when unset; headless calls need `--platform` or an explicit command. `void account` always targets a Void platform account, and `void cloudflare` always targets Cloudflare. Keep `void auth whoami|link|token` as compatibility shortcuts; `link` and `token` belong only to Void accounts. Auth selection never changes the deployment preference. - **Repeatable framework wrappers**: `generateWrapper` recognizes current and legacy wrapper entries and preserves the original application across `--skip-build` runs. Compile handlers before replacing the entry, stage the wrapper for an atomic rename, and copy a new original only after an actual new build. A missing or recursively wrapped original requires a rebuild; readiness alone cannot validate the application entry. diff --git a/packages/void/src/cli/auth-link.ts b/packages/void/src/cli/auth-link.ts index d94815262..b2c1baf0f 100644 --- a/packages/void/src/cli/auth-link.ts +++ b/packages/void/src/cli/auth-link.ts @@ -17,9 +17,6 @@ export async function runAuthLink(root: string, connectionId?: string): Promise< } const target = await selectPlatformLoginMethod(platform, connectionId); const existing = await new PlatformClient(token, apiUrl).whoami(); - if (!existing.id) { - throw new Error('auth: This platform does not support account linking.'); - } log.info(`Sign in again to ${existing.login} using an already linked login method.`); const source = await selectPlatformLoginMethod(platform); const freshToken = await runPlatformLogin(apiUrl, source.id, (url) => diff --git a/packages/void/src/cli/build-cmd.ts b/packages/void/src/cli/build-cmd.ts index aadb3e95b..fc633164c 100644 --- a/packages/void/src/cli/build-cmd.ts +++ b/packages/void/src/cli/build-cmd.ts @@ -14,7 +14,8 @@ function isBuildLogComplete(page: BuildLogsResponse): boolean { if (!page.terminal) { return false; } - if (page.logsComplete === null || page.logsComplete === undefined) { + // `null` means the server cannot prove completion. + if (page.logsComplete === null) { throw new Error( 'This platform cannot confirm that the final build logs were captured. Ask its administrator to upgrade the platform runtime, or read the retained logs without --follow.', ); diff --git a/packages/void/src/cli/client.ts b/packages/void/src/cli/client.ts index 146f9cc85..1f1d82a89 100644 --- a/packages/void/src/cli/client.ts +++ b/packages/void/src/cli/client.ts @@ -9,6 +9,7 @@ import { isCloudflareAccessChallenge, } from './cf-access.ts'; import type { CliLogger } from './log-file.ts'; +import { readPlatformDiscoveryResponse } from './platform-discovery.ts'; import { formatPlatformApiError, parsePlatformErrorBody, @@ -43,6 +44,77 @@ export function readCliVersion(): string | null { const VOID_CLI_VERSION = readCliVersion(); +/** The oldest Void platform release this CLI supports. */ +export const MIN_PLATFORM_VERSION = '0.23.0'; + +const RELEASE_VERSION = /^(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/; + +/** + * Version checks keyed by platform base URL. Every outcome (pass, inconclusive, + * or too old) is kept for the rest of the process, so polling commands probe + * discovery once. + */ +const platformVersionChecks = new Map>(); + +/** + * What public discovery says about the platform version: + * - `version`: the reported `platformVersion` + * - `missing`: discovery answered 404, so the platform predates discovery + * - `unknown`: no usable answer (network error, timeout, disabled platform, + * Access login page, or an invalid document) + */ +type PlatformVersionProbe = + | { kind: 'version'; version: string } + | { kind: 'missing' } + | { kind: 'unknown' }; + +/** + * Returns whether `version` is older than MIN_PLATFORM_VERSION, or null when + * it is not a release version. A prerelease sorts before its release. + */ +function isOlderThanMinimumPlatform(version: string): boolean | null { + const actual = RELEASE_VERSION.exec(version); + const minimum = RELEASE_VERSION.exec(MIN_PLATFORM_VERSION)!; + if (!actual) { + return null; + } + for (const part of [1, 2, 3]) { + const difference = Number(actual[part]) - Number(minimum[part]); + if (difference !== 0) { + return difference < 0; + } + } + return actual[4] !== undefined && minimum[4] === undefined; +} + +const UPGRADE_PLATFORM_ACTION = + 'Ask its administrator to upgrade the platform runtime with `void platform upgrade`.'; + +function assertSupportedPlatform(apiUrl: string, probe: PlatformVersionProbe): void { + if (probe.kind === 'unknown') { + // Let the real request run and report its own error. + return; + } + if (probe.kind === 'missing') { + // Every Void platform release serves discovery, so a 404 there means a + // platform that predates it, and therefore predates the minimum. + throw new Error( + `The Void platform at ${apiUrl} does not report its version, but this CLI requires ${MIN_PLATFORM_VERSION} or later. ${UPGRADE_PLATFORM_ACTION}`, + ); + } + const { version } = probe; + // Installed and hosted platforms report their release version. "hosted" + // comes from a hosted deployment that does not set PLATFORM_VERSION yet. + // Any other value that is not a release version comes from a custom or + // development build and cannot be ordered. Both pass. + if (version === 'hosted' || isOlderThanMinimumPlatform(version) !== true) { + return; + } + throw new Error( + `The Void platform at ${apiUrl} runs version ${version}, but this CLI requires ${MIN_PLATFORM_VERSION} or later. ${UPGRADE_PLATFORM_ACTION}`, + ); +} + export { parsePlatformErrorBody } from './platform-errors.ts'; export type { PlatformErrorPayload } from './platform-errors.ts'; @@ -114,7 +186,7 @@ export type ProjectResponse = { type: string; created_at: string; custom_domain: string | null; - role?: ProjectRole; + role: ProjectRole; owner?: { id: string; login?: string; @@ -300,7 +372,7 @@ export type BuildLogsResponse = { lastSeq: number; status: string; terminal: boolean; - logsComplete?: boolean | null; + logsComplete: boolean | null; external: string | null; }; @@ -446,6 +518,7 @@ export type DomainResponse = { }; export type DomainStatusResponse = DomainResponse & { + cname_target: string; ssl_status: string; verification_errors?: Array; cf_status?: string; @@ -470,7 +543,6 @@ export type GithubConnection = { project_id: string; installation_id: string; repo_full_name: string; - repository_id: string | null; branch: string; build_executor: 'container' | 'github_actions'; workflow_path: string; @@ -523,6 +595,10 @@ const DEPLOY_OBSERVATION_DEADLINE_MS = 30 * 60_000; const DEPLOY_OBSERVATION_BACKOFF_MS = [1000, 2000, 5000, 10_000, 30_000]; const DEPLOY_EVENT_MAX_BYTES = 1024 * 1024; +// Heartbeats carry no `seq`. Heartbeat `status` and the observation page +// `heartbeat` come from commit 21d4ec46, which 0.23.0 does not ship; keep them +// optional until MIN_PLATFORM_VERSION includes it. `recoveryAt` is optional: +// it is sent only while an execution lease exists. type SequencedDeployEvent = DeployEvent & { seq?: number }; class DeploymentProtocolError extends Error {} class DeploymentObservationTimeoutError extends Error { @@ -579,17 +655,26 @@ export class PlatformClient { private readonly authHeaders: Record; private readonly accessHeaders: Record; private readonly cliLog?: CliLogger; + private readonly checkPlatformVersion: boolean; readonly apiUrl: string; + /** + * `checkPlatformVersion: false` skips the minimum platform version check. + * Only login uses it, so users can still sign in to an older platform. + */ constructor( token: string, - options?: string | { apiUrl?: string; root?: string; cliLog?: CliLogger }, + options?: + | string + | { apiUrl?: string; root?: string; cliLog?: CliLogger; checkPlatformVersion?: boolean }, ) { if (typeof options === 'string') { this.baseUrl = normalizePlatformApiUrl(options); + this.checkPlatformVersion = true; } else { this.baseUrl = normalizePlatformApiUrl(options?.apiUrl ?? getApiUrl(options?.root)); this.cliLog = options?.cliLog; + this.checkPlatformVersion = options?.checkPlatformVersion !== false; } this.apiUrl = this.baseUrl; ensureCloudflaredToken(this.baseUrl); @@ -608,6 +693,55 @@ export class PlatformClient { if (new URL(url).origin !== this.baseUrl) { throw new Error('Platform credentials cannot be sent to another origin.'); } + if (this.checkPlatformVersion) { + await this.ensureSupportedPlatform(); + } + return this.send(url, options); + } + + /** + * Checks the platform version once per process and base URL. Concurrent + * first requests share one check, and its outcome is kept for later requests. + */ + private ensureSupportedPlatform(): Promise { + const apiUrl = this.baseUrl; + const cached = platformVersionChecks.get(apiUrl); + if (cached) { + return cached; + } + const check = this.probePlatformVersion().then((probe) => + assertSupportedPlatform(apiUrl, probe), + ); + platformVersionChecks.set(apiUrl, check); + return check; + } + + /** Reads the platform version from public discovery, without credentials. */ + private async probePlatformVersion(): Promise { + let response: Response; + try { + response = await this.send(`${this.baseUrl}/.well-known/void-platform`, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(15_000), + }); + } catch { + // A network error or timeout says nothing about the version. The real + // request reports its own error if the platform is unreachable. + return { kind: 'unknown' }; + } + if (response.status === 404) { + await response.body?.cancel(); + return { kind: 'missing' }; + } + try { + const discovery = await readPlatformDiscoveryResponse(this.baseUrl, response); + return { kind: 'version', version: discovery.platformVersion }; + } catch { + return { kind: 'unknown' }; + } + } + + private async send(url: string | URL, options: RequestInit): Promise { // A service-token pair is long-lived and was captured before managed deploy // scrubs the parent environment. User tokens can be renewed in the browser, // so keep checking for a newer environment value when no service pair was @@ -711,11 +845,6 @@ export class PlatformClient { const res = await this.request(`${this.baseUrl}/projects/choices`, { headers: { ...this.authHeaders }, }); - if (res.status === 404) { - throw new Error( - 'This platform does not support project lookup. Ask its administrator to upgrade the platform runtime.', - ); - } if (!res.ok) { await throwPlatformApiError('Failed to list project choices', res); } @@ -741,11 +870,6 @@ export class PlatformClient { const url = new URL(`${this.baseUrl}/projects/resolve`); url.searchParams.set('slug', slug); const res = await this.request(url.toString(), { headers: { ...this.authHeaders } }); - if (res.status === 404) { - throw new Error( - 'This platform does not support project lookup. Ask its administrator to upgrade the platform runtime.', - ); - } if (!res.ok) { await throwPlatformApiError('Failed to resolve project', res); } @@ -1229,22 +1353,22 @@ export class PlatformClient { const receipt = res.headers.get('x-void-deployment-id'); const observationDeadline = Date.now() + DEPLOY_OBSERVATION_DEADLINE_MS; - if (receipt !== null && !/^[A-Za-z0-9_-]{1,128}$/.test(receipt)) { + if (receipt === null || !/^[A-Za-z0-9_-]{1,128}$/.test(receipt)) { void res.body?.cancel().catch(() => {}); - throw new Error('Invalid deployment receipt from platform.'); + throw new Error( + receipt === null + ? 'Platform returned no deployment receipt.' + : 'Invalid deployment receipt from platform.', + ); } let cursor = 0; let terminal = false; - // Receipt-aware platforms own execution independently of this observer. - // Capturing the ID before the first byte also permits recovery when start - // was lost. Older platforms retain their original stream-only behavior. + // The platform owns execution independently of this observer. Capturing + // the ID before the first byte also permits recovery when start was lost. if (!res.body) { - if (receipt) { - yield { event: 'start', deploymentId: receipt }; - yield* this.followDeploymentEvents(projectId, receipt, cursor, observationDeadline); - return; - } - throw new Error('Platform returned no deployment event stream.'); + yield { event: 'start', deploymentId: receipt }; + yield* this.followDeploymentEvents(projectId, receipt, cursor, observationDeadline); + return; } const reader = res.body.getReader(); const decoder = new TextDecoder(); @@ -1257,9 +1381,6 @@ export class PlatformClient { let streamFinished = false; let readTimer: ReturnType | undefined; const read = async () => { - if (!receipt) { - return reader.read(); - } const remaining = observationDeadline - Date.now(); if (remaining <= 0) { throw new DeploymentObservationTimeoutError(receipt); @@ -1301,7 +1422,7 @@ export class PlatformClient { lastEventName = event.event; cliLog?.info('deploy_event', { eventCount, event: lastEventName, raw: line.slice(0, 200) }); // The receipt already supplied start, but still advance its sequence. - if (receipt && event.event === 'start') { + if (event.event === 'start') { return null; } terminal = event.event === 'done' || event.event === 'error'; @@ -1309,12 +1430,10 @@ export class PlatformClient { }; try { - if (receipt) { - yield { event: 'start', deploymentId: receipt }; - } + yield { event: 'start', deploymentId: receipt }; while (true) { const { done, value } = await read(); - if (receipt && Date.now() >= observationDeadline) { + if (Date.now() >= observationDeadline) { throw new DeploymentObservationTimeoutError(receipt); } if (done) { @@ -1365,7 +1484,6 @@ export class PlatformClient { // Parse/protocol errors are not transient transport failures. Never hide // them behind polling, and never replay a POST that yielded a receipt. if ( - !receipt || err instanceof DeploymentObservationTimeoutError || err instanceof DeploymentProtocolError || err instanceof SyntaxError || @@ -1382,7 +1500,7 @@ export class PlatformClient { } reader.releaseLock(); } - if (receipt && !terminal) { + if (!terminal) { yield* this.followDeploymentEvents(projectId, receipt, cursor, observationDeadline); } } diff --git a/packages/void/src/cli/connect.ts b/packages/void/src/cli/connect.ts index f6cb34491..c88a10b9d 100644 --- a/packages/void/src/cli/connect.ts +++ b/packages/void/src/cli/connect.ts @@ -53,7 +53,11 @@ export async function authenticateConnection( const token = matchingEnv ? envToken : readPlatformToken(discovery.apiUrl); if (token) { try { - await new PlatformClient(token, { apiUrl: discovery.apiUrl }).whoami(); + // Login must work on any platform version. + await new PlatformClient(token, { + apiUrl: discovery.apiUrl, + checkPlatformVersion: false, + }).whoami(); log.success(`Authenticated with ${discovery.displayName}.`); return; } catch (error) { diff --git a/packages/void/src/cli/deploy.ts b/packages/void/src/cli/deploy.ts index cc55857f2..516f87433 100644 --- a/packages/void/src/cli/deploy.ts +++ b/packages/void/src/cli/deploy.ts @@ -7336,7 +7336,7 @@ async function runManagedDeploy( } function assertProjectDeployAccess(project: ProjectResponse | undefined): void { - // Older platforms omit roles; the API remains authoritative for every deploy. + // 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. ` + diff --git a/packages/void/src/cli/domain.ts b/packages/void/src/cli/domain.ts index d27ab39de..ac5333596 100644 --- a/packages/void/src/cli/domain.ts +++ b/packages/void/src/cli/domain.ts @@ -229,14 +229,9 @@ export function computeRollup(status: DomainStatusResponse): RolledUpStatus { ) { return { state: 'issuing_cert', - // Only cite the CNAME "below" when it will actually render. The CNAME block - // shows iff `status.cname_target` is set (ssl_status !== 'active' always - // holds in this branch), so a status response without a target (e.g. an - // older API during the deploy window) no longer points at a record we - // don't display. - diagnostic: status.cname_target - ? 'Cloudflare is issuing the SSL certificate automatically (typically <5 minutes). If this persists, confirm the traffic CNAME below is published.' - : 'Cloudflare is issuing the SSL certificate automatically (typically <5 minutes).', + // ssl_status !== 'active' always holds here, so the CNAME block renders below. + diagnostic: + 'Cloudflare is issuing the SSL certificate automatically (typically <5 minutes). If this persists, confirm the traffic CNAME below is published.', }; } @@ -307,7 +302,7 @@ async function domainStatus( let cnameSection: string | undefined; let ownershipSection: string | undefined; - if (status.cname_target && status.ssl_status !== 'active') { + if (status.ssl_status !== 'active') { cnameSection = `CNAME (traffic)\n ${status.hostname} → ${status.cname_target}`; } if (status.ownership_verification) { diff --git a/packages/void/src/cli/github-cmd.ts b/packages/void/src/cli/github-cmd.ts index c5f00c10c..999f5e574 100644 --- a/packages/void/src/cli/github-cmd.ts +++ b/packages/void/src/cli/github-cmd.ts @@ -732,9 +732,7 @@ export async function runConnect( // // Only carry a prompted value forward when it DIFFERS from the default, so // accepting the default leaves `workflowPath` undefined → the field is not - // sent → the server applies its own (identical) default. This keeps the - // fail-closed echo check below from firing against an older platform that - // silently drops the field and returns its default. + // sent → the server applies its own (identical) default. // ------------------------------------------------------------------------- let workflowPath = args.workflowPath; if (executor === 'github_actions' && args.workflowPath === undefined && isTTY) { @@ -773,9 +771,8 @@ export async function runConnect( ...extra, }); - let connection: GithubConnection; try { - connection = await attemptConnect(); + await attemptConnect(); } catch (err) { if (!(err instanceof PlatformApiError)) { throw err; @@ -800,7 +797,7 @@ export async function runConnect( repoFullName, ); try { - connection = await attemptConnect({ + await attemptConnect({ connectGrant: grant.connectGrant, codeVerifier: grant.codeVerifier, }); @@ -830,19 +827,6 @@ export async function runConnect( } } - // Fail closed when a custom workflow path (from --workflow or the prompt) was - // not applied. An older platform that predates workflow_path silently drops - // the field and returns success while staying pinned to the default, so the - // user would wrongly believe their custom workflow is authorized to deploy. - // Verify the server echoed it back. (Accepting the default leaves workflowPath - // undefined, so this check is skipped — see step 6.) - if (workflowPath !== undefined && connection.workflow_path !== workflowPath) { - log.error( - `connect: server did not apply workflow path ${workflowPath} (got ${connection.workflow_path ?? 'none'}). The platform may be too old to support a custom workflow path.`, - ); - process.exit(1); - } - log.success(`Connected ${repoFullName} (${branch}) → project. Push to ${branch} to deploy.`); } @@ -1060,17 +1044,6 @@ export async function runGithubUpdate( throw err; } - // Fail closed when a custom workflow path (from --workflow or the prompt) was - // not applied — same old-platform compatibility hazard as connect: the server - // could return success while ignoring the unknown field, leaving the workflow - // pin unchanged. - if (patch.workflowPath !== undefined && updated.workflow_path !== patch.workflowPath) { - log.error( - `update: server did not apply workflow path ${patch.workflowPath} (got ${updated.workflow_path ?? 'none'}). The platform may be too old to support a custom workflow path.`, - ); - process.exit(1); - } - log.success( `Updated ${updated.repo_full_name} → branch ${updated.branch}, executor ${updated.build_executor}, workflow ${updated.workflow_path}.`, ); diff --git a/packages/void/src/cli/login.ts b/packages/void/src/cli/login.ts index b5e31cb5d..ba9ea32ea 100644 --- a/packages/void/src/cli/login.ts +++ b/packages/void/src/cli/login.ts @@ -105,7 +105,8 @@ export async function runLoginCommand(root: string = process.cwd()): Promise { diff --git a/packages/void/src/cli/platform-discovery.ts b/packages/void/src/cli/platform-discovery.ts new file mode 100644 index 000000000..026e2c504 --- /dev/null +++ b/packages/void/src/cli/platform-discovery.ts @@ -0,0 +1,34 @@ +import { parsePlatformDiscoveryDocument, type PlatformDiscoveryDocument } from '@void/platform'; +import { normalizePlatformApiUrl } from './platform-registry.ts'; + +/** Validates a `GET /.well-known/void-platform` response from `apiUrl`. */ +export async function readPlatformDiscoveryResponse( + apiUrl: string, + response: Response, +): Promise { + if (!response.ok) { + await response.body?.cancel(); + throw new Error(`platform: Discovery failed at ${apiUrl} (HTTP ${response.status}).`); + } + let value: unknown; + try { + value = await response.json(); + } catch { + throw new Error(`platform: ${apiUrl} returned invalid discovery metadata.`); + } + let discovery: PlatformDiscoveryDocument; + try { + discovery = parsePlatformDiscoveryDocument(value); + } catch (error) { + throw new Error( + `platform: ${apiUrl} returned invalid discovery metadata: ${error instanceof Error ? error.message : String(error)}`, + ); + } + const finalOrigin = normalizePlatformApiUrl(response.url ? new URL(response.url).origin : apiUrl); + if (discovery.apiUrl !== finalOrigin) { + throw new Error( + `platform: Discovery origin mismatch. The response came from ${finalOrigin}, but identifies ${discovery.apiUrl}.`, + ); + } + return discovery; +} diff --git a/packages/void/src/cli/platform-domain.ts b/packages/void/src/cli/platform-domain.ts index 3b228bc23..f113f220f 100644 --- a/packages/void/src/cli/platform-domain.ts +++ b/packages/void/src/cli/platform-domain.ts @@ -231,7 +231,9 @@ export async function activatePlatformDomain( }, body: JSON.stringify({ application }), }); - // An older runtime has no activation handoff and answers with a non-JSON 404. + // A runtime before 0.22.0 has no activation handoff and answers with a + // non-JSON 404. Platform management commands do not run the minimum + // platform version check, so keep this. const body = (await response.json().catch(() => null)) as { ok?: boolean; installationId?: string; diff --git a/packages/void/src/cli/projects.ts b/packages/void/src/cli/projects.ts index d7ed3b902..5ed4682a8 100644 --- a/packages/void/src/cli/projects.ts +++ b/packages/void/src/cli/projects.ts @@ -23,7 +23,7 @@ export async function runProjectsCommand(root: string): Promise { const rows = projects.map((p) => ({ slug: p.slug, - role: p.role ?? 'owner', + role: p.role, appType: p.type ?? 'worker', url: p.custom_domain ? `https://${p.custom_domain}` diff --git a/packages/void/src/cli/prompt.ts b/packages/void/src/cli/prompt.ts index 7cd3e4fc9..ac8d94789 100644 --- a/packages/void/src/cli/prompt.ts +++ b/packages/void/src/cli/prompt.ts @@ -56,7 +56,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 } : {}), + hint: p.role === 'reader' ? 'reader (read-only)' : p.role, })), ]; diff --git a/packages/void/test/helpers/platform-discovery.ts b/packages/void/test/helpers/platform-discovery.ts new file mode 100644 index 000000000..a2d5ceb05 --- /dev/null +++ b/packages/void/test/helpers/platform-discovery.ts @@ -0,0 +1,54 @@ +import { MIN_PLATFORM_VERSION } from '../../src/cli/client.ts'; + +const DISCOVERY_PATH = '/.well-known/void-platform'; + +/** A valid discovery response for `apiUrl` that advertises `platformVersion`. */ +export function platformDiscoveryResponse( + apiUrl: string, + platformVersion: string = MIN_PLATFORM_VERSION, +): Response { + const origin = new URL(apiUrl).origin; + return Response.json({ + apiUrl: origin, + applicationDomain: 'void.app', + authenticationProviders: ['github'], + capabilities: { + ai: false, + customDomains: false, + email: false, + githubActions: false, + githubOAuth: true, + googleOAuth: false, + managedBuilds: false, + sandbox: false, + }, + displayName: 'Test platform', + installationId: null, + platformVersion, + proxyUrl: origin, + schemaVersion: 1, + }); +} + +export function isPlatformDiscoveryRequest(input: unknown): boolean { + const url = input instanceof Request ? input.url : String(input); + return new URL(url).pathname === DISCOVERY_PATH; +} + +/** + * Wraps a fetch mock so the CLI's platform version check is answered here and + * never reaches the mock. Call counts and queued responses stay unchanged. + */ +export function withPlatformDiscovery( + fetchMock: unknown, + platformVersion: string = MIN_PLATFORM_VERSION, +): typeof fetch { + const next = fetchMock as (input: RequestInfo | URL, init?: RequestInit) => Promise; + return async (input, init) => + isPlatformDiscoveryRequest(input) + ? platformDiscoveryResponse( + input instanceof Request ? input.url : String(input), + platformVersion, + ) + : next(input, init); +} diff --git a/packages/void/test/integration/full-deploy-manifest.test.ts b/packages/void/test/integration/full-deploy-manifest.test.ts index 17da574e7..2031b5822 100644 --- a/packages/void/test/integration/full-deploy-manifest.test.ts +++ b/packages/void/test/integration/full-deploy-manifest.test.ts @@ -18,6 +18,7 @@ import { import type { ProjectRole } from '../../src/cli/client.ts'; import { savePlatformConnection } from '../../src/cli/platform-registry.ts'; import type { DetectedFramework } from '../../src/plugin-inference.ts'; +import { withPlatformDiscovery } from '../helpers/platform-discovery.ts'; // @voidzero-dev/vite-plus-prompts + the output-spinner wrapper are TTY-bound; noop them (same as // direct-r2-deploy.test.ts) so runDeploy's interactive UI doesn't render here. @@ -185,7 +186,7 @@ function ndjson(events: Array): Response { }); return new Response(stream, { status: 200, - headers: { 'content-type': 'application/x-ndjson' }, + headers: { 'content-type': 'application/x-ndjson', 'x-void-deployment-id': 'dep_test' }, }); } @@ -245,7 +246,7 @@ describe('runFullDeploy emits manifest.pages (Void pages app, end-to-end)', () = mockDetectFramework.mockReset().mockReturnValue(undefined); originalFetch = globalThis.fetch; - globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; + globalThis.fetch = withPlatformDiscovery(fetchMock); fetchMock.mockReset(); mockCollectPrerenderPaths.mockClear(); diff --git a/packages/void/test/unit/auth-legacy.test.ts b/packages/void/test/unit/auth-legacy.test.ts index 0ab203ca8..5f3806c0c 100644 --- a/packages/void/test/unit/auth-legacy.test.ts +++ b/packages/void/test/unit/auth-legacy.test.ts @@ -2,6 +2,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'nod import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import { withPlatformDiscovery } from '../helpers/platform-discovery.ts'; const state = vi.hoisted(() => ({ home: '', @@ -327,7 +328,9 @@ describe('environment token destinations', () => { const { PlatformClient } = await import('../../src/cli/client.ts'); const fetcher = vi .spyOn(globalThis, 'fetch') - .mockResolvedValue(Response.json({ login: 'operator', plan: 'free' })); + .mockImplementation( + withPlatformDiscovery(async () => Response.json({ login: 'operator', plan: 'free' })), + ); try { await new PlatformClient(getToken(root)!, { root }).whoami(); expect(getApiUrl(root)).toBe(origin); diff --git a/packages/void/test/unit/build-client.test.ts b/packages/void/test/unit/build-client.test.ts index 1dba3f793..88cb0e8c1 100644 --- a/packages/void/test/unit/build-client.test.ts +++ b/packages/void/test/unit/build-client.test.ts @@ -1,12 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; import { PlatformClient } from '../../src/cli/client.ts'; +import { withPlatformDiscovery } from '../helpers/platform-discovery.ts'; const fetchMock = vi.fn(); let originalFetch: typeof globalThis.fetch | undefined; beforeEach(() => { originalFetch = globalThis.fetch; - globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; + globalThis.fetch = withPlatformDiscovery(fetchMock); fetchMock.mockReset(); }); afterEach(() => { diff --git a/packages/void/test/unit/build-logs-follow.test.ts b/packages/void/test/unit/build-logs-follow.test.ts index 49adcf1e7..634a06077 100644 --- a/packages/void/test/unit/build-logs-follow.test.ts +++ b/packages/void/test/unit/build-logs-follow.test.ts @@ -17,7 +17,15 @@ afterEach(() => { }); function page(partial: Partial): BuildLogsResponse { - return { lines: [], lastSeq: 0, status: 'building', terminal: false, external: null, ...partial }; + return { + lines: [], + lastSeq: 0, + status: 'building', + terminal: false, + logsComplete: null, + external: null, + ...partial, + }; } function apiError(status: number, body = '{}'): PlatformApiError { @@ -211,31 +219,28 @@ describe('followBuildLogs', () => { expect(written).toEqual(['final tail']); }); - it.each([undefined, null])( - 'prints retained logs before rejecting %s completion proof', - async (logsComplete) => { - const written: Array = []; - await expect( - followBuildLogs({ - fetchPage: () => - Promise.resolve( - page({ - status: 'complete', - terminal: true, - logsComplete, - lines: [{ seq: 3, line: 'retained tail', stream: 'stdout', ts: 3 }], - lastSeq: 3, - }), - ), - write: (line) => written.push(line), - sleep: () => Promise.resolve(), - since: 2, - status: 'complete', - }), - ).rejects.toThrow('upgrade the platform runtime'); - expect(written).toEqual(['retained tail']); - }, - ); + it('prints retained logs before rejecting null completion proof', async () => { + const written: Array = []; + await expect( + followBuildLogs({ + fetchPage: () => + Promise.resolve( + page({ + status: 'complete', + terminal: true, + logsComplete: null, + lines: [{ seq: 3, line: 'retained tail', stream: 'stdout', ts: 3 }], + lastSeq: 3, + }), + ), + write: (line) => written.push(line), + sleep: () => Promise.resolve(), + since: 2, + status: 'complete', + }), + ).rejects.toThrow('upgrade the platform runtime'); + expect(written).toEqual(['retained tail']); + }); it('bounds terminal drain waiting at two minutes', async () => { let now = 0; diff --git a/packages/void/test/unit/client-platform-version.test.ts b/packages/void/test/unit/client-platform-version.test.ts new file mode 100644 index 000000000..d11b5d871 --- /dev/null +++ b/packages/void/test/unit/client-platform-version.test.ts @@ -0,0 +1,165 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import { MIN_PLATFORM_VERSION, PlatformClient } from '../../src/cli/client.ts'; +import { + isPlatformDiscoveryRequest, + platformDiscoveryResponse, +} from '../helpers/platform-discovery.ts'; + +// Never read the keychain or run cloudflared. Without Access credentials, an +// Access challenge fails the way it does for a CLI that has none. +vi.mock('../../src/cli/cf-access.ts', async (importOriginal) => ({ + ...(await importOriginal()), + ensureCloudflaredToken: vi.fn(), + ensurePlatformAccessToken: vi.fn(() => { + throw new Error('platform: Cloudflare Access authentication is required.'); + }), +})); + +// Version checks are cached per process and base URL, so each test uses its own origin. +let origin = ''; +let sequence = 0; +const fetchMock = vi.fn(); +let originalFetch: typeof globalThis.fetch; + +beforeEach(() => { + origin = `https://platform-${++sequence}.example.test`; + originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; + fetchMock.mockReset(); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +function serve(platformVersion: string | (() => Response | Promise)): void { + fetchMock.mockImplementation(async (input: string) => { + if (isPlatformDiscoveryRequest(input)) { + return typeof platformVersion === 'string' + ? platformDiscoveryResponse(input, platformVersion) + : platformVersion(); + } + return Response.json([]); + }); +} + +function requestedPaths(): Array { + return fetchMock.mock.calls.map(([url]) => new URL(String(url)).pathname); +} + +function client(options: { checkPlatformVersion?: boolean } = {}): PlatformClient { + return new PlatformClient('token', { apiUrl: origin, ...options }); +} + +describe('minimum platform version', () => { + it('declares 0.23.0 as the minimum', () => { + expect(MIN_PLATFORM_VERSION).toBe('0.23.0'); + }); + + it.each(['0.23.0', '0.24.0', '1.0.0', 'hosted'])( + 'accepts a platform reporting %s', + async (version) => { + serve(version); + await expect(client().listProjectChoices()).resolves.toEqual([]); + expect(requestedPaths()).toEqual(['/.well-known/void-platform', '/projects/choices']); + }, + ); + + it('sends discovery without the account token', async () => { + serve('0.23.0'); + await client().listProjectChoices(); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(init.headers).not.toHaveProperty('Authorization'); + }); + + it.each(['0.22.0', '0.23.0-1'])('refuses a platform reporting %s', async (version) => { + serve(version); + await expect(client().listProjectChoices()).rejects.toThrow( + `The Void platform at ${origin} runs version ${version}, but this CLI requires 0.23.0 or later. Ask its administrator to upgrade the platform runtime with \`void platform upgrade\`.`, + ); + expect(requestedPaths()).toEqual(['/.well-known/void-platform']); + }); + + it('checks once per process and base URL, sharing a concurrent first check', async () => { + serve('0.23.0'); + await Promise.all([client().listProjectChoices(), client().listProjects()]); + await client().listProjectChoices(); + expect(requestedPaths().filter((path) => path === '/.well-known/void-platform')).toHaveLength( + 1, + ); + expect(requestedPaths()).toHaveLength(4); + + const other = `https://other-${sequence}.example.test`; + await new PlatformClient('token', { apiUrl: other }).listProjects(); + expect(fetchMock.mock.calls.at(-2)?.[0]).toBe(`${other}/.well-known/void-platform`); + }); + + it('does not check when login opts out', async () => { + serve('0.22.0'); + await expect(client({ checkPlatformVersion: false }).listProjectChoices()).resolves.toEqual([]); + expect(requestedPaths()).toEqual(['/projects/choices']); + }); + + it('refuses a too-old platform once and keeps the answer for later requests', async () => { + serve('0.22.0'); + await expect(client().listProjectChoices()).rejects.toThrow('runs version 0.22.0'); + await expect(client().listProjects()).rejects.toThrow('runs version 0.22.0'); + expect(requestedPaths()).toEqual(['/.well-known/void-platform']); + }); + + it.each([ + ['an HTML 404', () => new Response('not found', { status: 404 })], + ['a JSON 404', () => Response.json({ error: 'not found' }, { status: 404 })], + ])('refuses a platform that answers discovery with %s', async (_label, answer) => { + serve(answer); + const message = `The Void platform at ${origin} does not report its version, but this CLI requires 0.23.0 or later. Ask its administrator to upgrade the platform runtime with \`void platform upgrade\`.`; + await expect(client().listProjectChoices()).rejects.toThrow(message); + await expect(client().listProjects()).rejects.toThrow(message); + expect(requestedPaths()).toEqual(['/.well-known/void-platform']); + }); + + it.each([ + [ + 'a network error', + () => { + throw new TypeError('fetch failed'); + }, + ], + [ + 'a timeout', + () => { + throw new DOMException('The operation was aborted due to timeout', 'TimeoutError'); + }, + ], + [ + 'a disabled platform (503)', + () => Response.json({ error: 'platform disabled' }, { status: 503 }), + ], + [ + 'an Access login redirect', + () => + new Response(null, { + status: 302, + headers: { location: 'https://team.cloudflareaccess.com/cdn-cgi/access/login' }, + }), + ], + ['invalid JSON', () => new Response('not json', { status: 200 })], + ])( + 'lets the request run once discovery gives %s, and does not probe again', + async (_label, answer) => { + serve(answer); + await expect(client().listProjectChoices()).resolves.toEqual([]); + await expect(client().listProjects()).resolves.toEqual([]); + expect(requestedPaths()).toEqual([ + '/.well-known/void-platform', + '/projects/choices', + '/projects', + ]); + }, + ); + + it('accepts a version it cannot order', async () => { + serve('development'); + await expect(client().listProjectChoices()).resolves.toEqual([]); + }); +}); diff --git a/packages/void/test/unit/connect.test.ts b/packages/void/test/unit/connect.test.ts index 78870b8e4..48cd60a5a 100644 --- a/packages/void/test/unit/connect.test.ts +++ b/packages/void/test/unit/connect.test.ts @@ -142,7 +142,10 @@ describe('connect workflow', () => { it('verifies an existing origin-scoped session without opening a browser', async () => { mocks.token.mockReturnValue('saved-token'); await runConnectCommand(root, parseConnectArgs([discovery.apiUrl]), true); - expect(mocks.client).toHaveBeenCalledWith('saved-token', { apiUrl: discovery.apiUrl }); + expect(mocks.client).toHaveBeenCalledWith('saved-token', { + apiUrl: discovery.apiUrl, + checkPlatformVersion: false, + }); expect(mocks.whoami).toHaveBeenCalledOnce(); expect(mocks.login).not.toHaveBeenCalled(); }); @@ -166,7 +169,10 @@ describe('connect workflow', () => { vi.stubEnv('VOID_TOKEN', 'ci-token'); vi.stubEnv('VOID_API_URL', discovery.apiUrl); await runConnectCommand(root, parseConnectArgs([discovery.apiUrl]), false); - expect(mocks.client).toHaveBeenCalledWith('ci-token', { apiUrl: discovery.apiUrl }); + expect(mocks.client).toHaveBeenCalledWith('ci-token', { + apiUrl: discovery.apiUrl, + checkPlatformVersion: false, + }); expect(mocks.login).not.toHaveBeenCalled(); }); it.each(['', 'https://different.example.com'])( diff --git a/packages/void/test/unit/deploy-platform-target.test.ts b/packages/void/test/unit/deploy-platform-target.test.ts index 21745f59c..f48c95f47 100644 --- a/packages/void/test/unit/deploy-platform-target.test.ts +++ b/packages/void/test/unit/deploy-platform-target.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, expect, it, vi } from 'vite-plus/test'; import { getApiUrl } from '../../src/cli/auth.ts'; import { runDeploy } from '../../src/cli/deploy.ts'; import { savePlatformConnection } from '../../src/cli/platform-registry.ts'; +import { withPlatformDiscovery } from '../helpers/platform-discovery.ts'; const { confirm, select, text, login, oidcAvailable, exchangeOidc } = vi.hoisted(() => ({ confirm: vi.fn(async () => true), @@ -91,54 +92,61 @@ beforeEach(() => { Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); vi.stubGlobal( 'fetch', - vi.fn(async (input: string | URL | Request, init?: RequestInit) => { - const url = new URL(input instanceof Request ? input.url : input); - const method = init?.method ?? 'GET'; - const authorization = new Headers(init?.headers).get('Authorization'); - requests.push({ origin: url.origin, path: url.pathname, method, authorization }); - expect([originalOrigin, alternateOrigin]).toContain(url.origin); - const id = url.origin === originalOrigin ? 'project-original' : 'project-alternate'; - const project = { id, slug: 'my-app', role: 'owner' }; - if (url.pathname === '/projects/choices') { - return Response.json([]); - } - if (url.pathname === '/projects/resolve') { - expect(url.searchParams.get('slug')).toBe('my-app'); - return Response.json({ project: missing ? null : project }); - } - if (method === 'POST' && url.pathname === '/projects') { - missing = false; - return Response.json(project); - } - if (method === 'GET' && url.pathname === `/projects/${id}`) { - return Response.json(project); - } - if (url.pathname === `/projects/${id}/deploy/preflight`) { - if (expirePreflight) { - expirePreflight = false; - return Response.json({ error: 'invalid or expired token' }, { status: 401 }); + withPlatformDiscovery( + vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : input); + const method = init?.method ?? 'GET'; + const authorization = new Headers(init?.headers).get('Authorization'); + requests.push({ origin: url.origin, path: url.pathname, method, authorization }); + expect([originalOrigin, alternateOrigin]).toContain(url.origin); + const id = url.origin === originalOrigin ? 'project-original' : 'project-alternate'; + const project = { id, slug: 'my-app', role: 'owner' }; + if (url.pathname === '/projects/choices') { + return Response.json([]); } - return Response.json({ needed: [], total: 1, skipped: 1 }); - } - if (url.pathname === `/projects/${id}/deploy`) { - return new Response( - JSON.stringify( - failDeploy - ? { event: 'error', message: 'deployment refused', step: 'upload' } - : { - event: 'done', - deployment: 'deploy-test', - url: 'https://app.example.test', - urls: ['https://app.example.test'], - assets: 1, - workers: 0, - }, - ) + '\n', - { headers: { 'Content-Type': 'application/x-ndjson' } }, - ); - } - throw new Error(`Unexpected request: ${method} ${url}`); - }), + if (url.pathname === '/projects/resolve') { + expect(url.searchParams.get('slug')).toBe('my-app'); + return Response.json({ project: missing ? null : project }); + } + if (method === 'POST' && url.pathname === '/projects') { + missing = false; + return Response.json(project); + } + if (method === 'GET' && url.pathname === `/projects/${id}`) { + return Response.json(project); + } + if (url.pathname === `/projects/${id}/deploy/preflight`) { + if (expirePreflight) { + expirePreflight = false; + return Response.json({ error: 'invalid or expired token' }, { status: 401 }); + } + return Response.json({ needed: [], total: 1, skipped: 1 }); + } + if (url.pathname === `/projects/${id}/deploy`) { + return new Response( + JSON.stringify( + failDeploy + ? { event: 'error', message: 'deployment refused', step: 'upload' } + : { + event: 'done', + deployment: 'deploy-test', + url: 'https://app.example.test', + urls: ['https://app.example.test'], + assets: 1, + workers: 0, + }, + ) + '\n', + { + headers: { + 'Content-Type': 'application/x-ndjson', + 'x-void-deployment-id': 'deploy-test', + }, + }, + ); + } + throw new Error(`Unexpected request: ${method} ${url}`); + }), + ), ); }); afterEach(() => { diff --git a/packages/void/test/unit/deploy-project.test.ts b/packages/void/test/unit/deploy-project.test.ts index 012f41ef2..0d0de7857 100644 --- a/packages/void/test/unit/deploy-project.test.ts +++ b/packages/void/test/unit/deploy-project.test.ts @@ -60,6 +60,7 @@ describe('resolveProjectBySlug', () => { type: 'worker', created_at: '2026-02-15T00:00:00.000Z', custom_domain: null, + role: 'owner', }, ], 'my-app', diff --git a/packages/void/test/unit/direct-r2-client.test.ts b/packages/void/test/unit/direct-r2-client.test.ts index d93ed96a2..ad4619e8b 100644 --- a/packages/void/test/unit/direct-r2-client.test.ts +++ b/packages/void/test/unit/direct-r2-client.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; import { PlatformClient, type ManifestOnlyDeployPayload } from '../../src/cli/client.ts'; import type { DeployManifest } from '../../src/cli/types.ts'; +import { withPlatformDiscovery } from '../helpers/platform-discovery.ts'; const spawnSyncMock = vi.hoisted(() => vi.fn()); const accessKeyring = vi.hoisted(() => ({ @@ -21,7 +22,7 @@ let originalFetch: typeof globalThis.fetch | undefined; beforeEach(() => { originalFetch = globalThis.fetch; - globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; + globalThis.fetch = withPlatformDiscovery(fetchMock); fetchMock.mockReset(); }); @@ -47,6 +48,17 @@ function jsonResponse(body: unknown, status = 200): Response { }); } +/** The platform names the accepted deployment in a receipt header before streaming. */ +function receiptFor(events: Array): string { + for (const event of events as Array<{ deploymentId?: unknown; deployment?: unknown }>) { + const id = event.deploymentId ?? event.deployment; + if (typeof id === 'string') { + return id; + } + } + return 'dpl_durable'; +} + function ndjsonStreamResponse(events: Array, status = 200): Response { const encoder = new TextEncoder(); const stream = new ReadableStream({ @@ -57,7 +69,13 @@ function ndjsonStreamResponse(events: Array, status = 200): Response { controller.close(); }, }); - return new Response(stream, { status, headers: { 'content-type': 'application/x-ndjson' } }); + return new Response(stream, { + status, + headers: { + 'content-type': 'application/x-ndjson', + 'x-void-deployment-id': receiptFor(events), + }, + }); } describe('PlatformClient.requestUploadUrls', () => { @@ -227,7 +245,10 @@ describe('PlatformClient.finalizeDeploy', () => { }, }); fetchMock.mockResolvedValueOnce( - new Response(stream, { status: 200, headers: { 'content-type': 'application/x-ndjson' } }), + new Response(stream, { + status: 200, + headers: { 'content-type': 'application/x-ndjson', 'x-void-deployment-id': 'd1' }, + }), ); const client = makeClient(); @@ -301,6 +322,7 @@ describe('PlatformClient.finalizeDeploy', () => { // No client-side dedupe GET — the server's precheck is authoritative. expect(deploymentListCalls()).toHaveLength(0); expect(events).toEqual([ + { event: 'start', deploymentId: 'd1' }, { event: 'done', deployment: 'd1', url: 'https://x', urls: [], assets: 0, workers: 0 }, ]); expect(cliLog.warn).toHaveBeenCalledWith( @@ -662,11 +684,13 @@ describe('PlatformClient.finalizeDeploy', () => { } }); - it('keeps EOF behavior on an older platform without a durable receipt', async () => { - fetchMock.mockResolvedValueOnce( - ndjsonStreamResponse([{ event: 'start', deploymentId: 'dpl_legacy' }]), - ); - expect(await collectDeploy()).toEqual([{ event: 'start', deploymentId: 'dpl_legacy' }]); + it('rejects a deployment stream without a receipt and does not observe or retry it', async () => { + const response = ndjsonStreamResponse([{ event: 'start', deploymentId: 'dpl_unknown' }]); + response.headers.delete('x-void-deployment-id'); + const cancel = vi.spyOn(response.body!, 'cancel'); + fetchMock.mockResolvedValueOnce(response); + await expect(collectDeploy()).rejects.toThrow('Platform returned no deployment receipt.'); + expect(cancel).toHaveBeenCalledOnce(); expect(fetchMock).toHaveBeenCalledTimes(1); }); diff --git a/packages/void/test/unit/direct-r2-deploy.test.ts b/packages/void/test/unit/direct-r2-deploy.test.ts index 69dbd634f..3645ade32 100644 --- a/packages/void/test/unit/direct-r2-deploy.test.ts +++ b/packages/void/test/unit/direct-r2-deploy.test.ts @@ -2,6 +2,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'nod import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import { withPlatformDiscovery } from '../helpers/platform-discovery.ts'; // Capture how many times each asset path is read via `readFileSync`. Mocked // before any importer of `node:fs` so the spy is in place when `package.ts` @@ -84,7 +85,7 @@ beforeEach(() => { } originalFetch = globalThis.fetch; - globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; + globalThis.fetch = withPlatformDiscovery(fetchMock); fetchMock.mockReset(); readFileSyncMock.mockClear(); }); @@ -200,7 +201,7 @@ function ndjsonResponse(events: Array): Response { }); return new Response(stream, { status: 200, - headers: { 'content-type': 'application/x-ndjson' }, + headers: { 'content-type': 'application/x-ndjson', 'x-void-deployment-id': 'dep_test' }, }); } diff --git a/packages/void/test/unit/domain-command.test.ts b/packages/void/test/unit/domain-command.test.ts index b3741f3c9..41f024c60 100644 --- a/packages/void/test/unit/domain-command.test.ts +++ b/packages/void/test/unit/domain-command.test.ts @@ -420,31 +420,6 @@ describe('domainStatus rollup', () => { expect(allNotes).toContain('foo.example.com → cname.void.app'); }); - it('issuing_cert omits the CNAME citation when cname_target is absent', async () => { - setupDomain(); - mockGetDomainStatus.mockResolvedValue({ - id: 'dom_1', - project_id: 'proj_default', - hostname: 'foo.example.com', - cf_hostname_id: 'cf_1', - status: 'pending', - created_at: 'now', - cf_status: 'pending_issuance', - ssl_status: 'pending_validation', - // no cname_target (e.g. an older API during the deploy window) - }); - - await runDomainCommand(root, args({ subcommand: 'status', hostname: 'foo.example.com' })); - - const statusNote = mockNote.mock.calls[0][0] as string; - expect(statusNote).toContain('Status: issuing_cert —'); - expect(statusNote).toContain('Cloudflare is issuing the SSL certificate'); - // No dangling reference to a CNAME block that isn't shown. - const allNotes = mockNote.mock.calls.map((c) => c[0] as string).join('\n'); - expect(allNotes).not.toContain('confirm the traffic CNAME below'); - expect(allNotes).not.toContain('CNAME (traffic)'); - }); - it('renders deploying_cert state when cf_status=pending_deployment', async () => { setupDomain(); mockGetDomainStatus.mockResolvedValue({ @@ -543,6 +518,7 @@ describe('computeRollup', () => { created_at: 'now', status: 'pending', ssl_status: 'pending', + cname_target: 'cname.void.app', }; const cases: Array<[name: string, input: Partial, expectedState: string]> = @@ -656,54 +632,6 @@ describe('domainStatus --verbose', () => { }); describe('domainStatus pending records block', () => { - it('shows the pending ownership record while ownership_verification is present', async () => { - mockListDomains.mockResolvedValue([ - { id: 'dom_1', hostname: 'foo.example.com', status: 'pending' }, - ]); - mockGetDomainStatus.mockResolvedValue({ - id: 'dom_1', - project_id: 'proj_default', - hostname: 'foo.example.com', - cf_hostname_id: 'cf_1', - status: 'pending', - created_at: 'now', - ssl_status: 'pending_validation', - cf_status: 'pending', - ownership_verification: { type: 'txt', name: 'own-name', value: 'own-value' }, - }); - - await runDomainCommand(root, args({ subcommand: 'status', hostname: 'foo.example.com' })); - - expect(mockNote).toHaveBeenCalledTimes(2); - const pendingNote = mockNote.mock.calls[1][0] as string; - expect(pendingNote).toContain('TXT (ownership verification)'); - expect(pendingNote).toContain('own-name → own-value'); - // HTTP-DCV: no SSL-validation records ever surface to the user. - expect(pendingNote).not.toContain('TXT (SSL validation)'); - expect(mockNote.mock.calls[1][1]).toBe('Add this DNS record to your provider'); - }); - - it('omits the pending block when ownership_verification is absent (SSL is automatic under HTTP-DCV)', async () => { - mockListDomains.mockResolvedValue([ - { id: 'dom_1', hostname: 'foo.example.com', status: 'pending' }, - ]); - mockGetDomainStatus.mockResolvedValue({ - id: 'dom_1', - project_id: 'proj_default', - hostname: 'foo.example.com', - cf_hostname_id: 'cf_1', - status: 'pending', - created_at: 'now', - ssl_status: 'pending_validation', - cf_status: 'pending_validation', - }); - - await runDomainCommand(root, args({ subcommand: 'status', hostname: 'foo.example.com' })); - - expect(mockNote).toHaveBeenCalledTimes(1); - expect(mockNote.mock.calls[0][1]).toBe('Domain Status'); - }); - it('shows the traffic CNAME while the cert is still pending (ownership already verified)', async () => { mockListDomains.mockResolvedValue([ { id: 'dom_1', hostname: 'foo.example.com', status: 'pending' }, @@ -781,6 +709,8 @@ describe('domainStatus pending records block', () => { expect(pendingNote).toContain('foo.example.com → cname.void.app'); expect(pendingNote).toContain('TXT (ownership verification)'); expect(pendingNote).toContain('own-name → own-value'); + // HTTP-DCV: no SSL-validation records ever surface to the user. + expect(pendingNote).not.toContain('TXT (SSL validation)'); // CNAME ordered before ownership, matching `domain add`. expect(pendingNote.indexOf('CNAME (traffic)')).toBeLessThan( pendingNote.indexOf('TXT (ownership verification)'), diff --git a/packages/void/test/unit/email-cli-domain.test.ts b/packages/void/test/unit/email-cli-domain.test.ts index 94a1050fe..adb8e3878 100644 --- a/packages/void/test/unit/email-cli-domain.test.ts +++ b/packages/void/test/unit/email-cli-domain.test.ts @@ -20,6 +20,7 @@ import { resolveEmailDomainTarget, type EmailDomainAddDeps, } from '../../src/cli/email.ts'; +import { withPlatformDiscovery } from '../helpers/platform-discovery.ts'; /** * `void email domain …` (design doc 0078, "Custom domains on Cloudflare"). @@ -203,7 +204,7 @@ describe('domain input helpers', () => { describe('PlatformClient.getEmailSettings', () => { function clientWith(status: number, body: string) { const fetchSpy = vi.fn(async () => new Response(body, { status })); - vi.stubGlobal('fetch', fetchSpy); + vi.stubGlobal('fetch', withPlatformDiscovery(fetchSpy)); return new PlatformClient('jwt-dummy', 'https://api.example.test'); } @@ -704,7 +705,7 @@ describe('PlatformClient.removeEmailDomain', () => { { status: 200, headers: { 'content-type': 'application/json' } }, ), ); - vi.stubGlobal('fetch', fetchMock); + vi.stubGlobal('fetch', withPlatformDiscovery(fetchMock)); const client = new PlatformClient('tok', { apiUrl: 'https://api.test' }); await client.removeEmailDomain('prj_1', 'acme.com'); const [url, init] = fetchMock.mock.calls[0]!; @@ -778,7 +779,7 @@ describe('PlatformClient.rotateEmailDomainSecret', () => { headers: { 'content-type': 'application/json' }, }), ); - vi.stubGlobal('fetch', fetchMock); + vi.stubGlobal('fetch', withPlatformDiscovery(fetchMock)); const client = new PlatformClient('tok', { apiUrl: 'https://api.test' }); await client.rotateEmailDomainSecret('prj_1', 'acme.com', 'attempt_1'); const [url, init] = fetchMock.mock.calls[0]!; diff --git a/packages/void/test/unit/get-database-url.test.ts b/packages/void/test/unit/get-database-url.test.ts index 4c232223a..8d0d47636 100644 --- a/packages/void/test/unit/get-database-url.test.ts +++ b/packages/void/test/unit/get-database-url.test.ts @@ -1,12 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; import { PlatformClient, PlatformApiError } from '../../src/cli/client.ts'; +import { withPlatformDiscovery } from '../helpers/platform-discovery.ts'; const fetchMock = vi.fn(); let originalFetch: typeof globalThis.fetch | undefined; beforeEach(() => { originalFetch = globalThis.fetch; - globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; + globalThis.fetch = withPlatformDiscovery(fetchMock); fetchMock.mockReset(); }); diff --git a/packages/void/test/unit/github-client.test.ts b/packages/void/test/unit/github-client.test.ts index 44cdbbf09..2f18292e2 100644 --- a/packages/void/test/unit/github-client.test.ts +++ b/packages/void/test/unit/github-client.test.ts @@ -4,13 +4,14 @@ import { type GithubConnection, type GithubInstallation, } from '../../src/cli/client.ts'; +import { withPlatformDiscovery } from '../helpers/platform-discovery.ts'; const fetchMock = vi.fn(); let originalFetch: typeof globalThis.fetch | undefined; beforeEach(() => { originalFetch = globalThis.fetch; - globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; + globalThis.fetch = withPlatformDiscovery(fetchMock); fetchMock.mockReset(); }); @@ -102,7 +103,6 @@ describe('PlatformClient.connectGithub', () => { project_id: projectId, installation_id: '123456', repo_full_name: 'octocat/hello-world', - repository_id: '9876', branch: 'main', build_executor: 'container', workflow_path: '.github/workflows/void-deploy.yml', @@ -142,7 +142,6 @@ describe('PlatformClient.connectGithub', () => { project_id: projectId, installation_id: '123456', repo_full_name: 'octocat/hello-world', - repository_id: null, branch: 'main', build_executor: 'container', workflow_path: '.github/workflows/void-deploy.yml', @@ -171,7 +170,6 @@ describe('PlatformClient.connectGithub', () => { project_id: projectId, installation_id: '123456', repo_full_name: 'octocat/hello-world', - repository_id: '9876', branch: 'main', build_executor: 'container', workflow_path: '.github/workflows/void-deploy.yml', @@ -268,7 +266,6 @@ describe('PlatformClient.getGithubConnection', () => { project_id: projectId, installation_id: '123456', repo_full_name: 'octocat/hello-world', - repository_id: '9876', branch: 'main', build_executor: 'container', workflow_path: '.github/workflows/void-deploy.yml', @@ -308,7 +305,6 @@ describe('PlatformClient.updateGithubConnection', () => { project_id: projectId, installation_id: '123456', repo_full_name: 'octocat/hello-world', - repository_id: '9876', branch: 'release', build_executor: 'github_actions', workflow_path: '.github/workflows/void-deploy.yml', @@ -344,7 +340,6 @@ describe('PlatformClient.updateGithubConnection', () => { project_id: projectId, installation_id: '123456', repo_full_name: 'octocat/hello-world', - repository_id: null, branch: 'main', build_executor: 'github_actions', workflow_path: '.github/workflows/void-deploy.yml', diff --git a/packages/void/test/unit/github-cmd.test.ts b/packages/void/test/unit/github-cmd.test.ts index 5f1e97ff7..9fdb20937 100644 --- a/packages/void/test/unit/github-cmd.test.ts +++ b/packages/void/test/unit/github-cmd.test.ts @@ -111,7 +111,6 @@ function makeConnection(overrides: Partial = {}): GithubConnec project_id: 'proj_1', installation_id: '42', repo_full_name: 'acme/my-app', - repository_id: '99', branch: 'main', build_executor: 'github_actions', workflow_path: '.github/workflows/void-deploy.yml', diff --git a/packages/void/test/unit/github-connect-cmd.test.ts b/packages/void/test/unit/github-connect-cmd.test.ts index 262c0cec0..665fb2761 100644 --- a/packages/void/test/unit/github-connect-cmd.test.ts +++ b/packages/void/test/unit/github-connect-cmd.test.ts @@ -43,6 +43,7 @@ function makeProject(): ProjectResponse { type: 'void', created_at: '2024-01-01T00:00:00Z', custom_domain: null, + role: 'owner', }; } @@ -67,7 +68,6 @@ function makeConnection(overrides: Partial = {}): GithubConnec project_id: projectId, installation_id: installationId, repo_full_name: repoFullName, - repository_id: '9876', branch: 'main', build_executor: 'container', workflow_path: '.github/workflows/void-deploy.yml', diff --git a/packages/void/test/unit/github-disconnect-cmd.test.ts b/packages/void/test/unit/github-disconnect-cmd.test.ts index 4ad886ee7..624b19cba 100644 --- a/packages/void/test/unit/github-disconnect-cmd.test.ts +++ b/packages/void/test/unit/github-disconnect-cmd.test.ts @@ -32,7 +32,6 @@ function makeConnection(overrides: Partial = {}): GithubConnec project_id: projectId, installation_id: '123456', repo_full_name: 'octocat/hello-world', - repository_id: '9876', branch: 'main', build_executor: 'container', workflow_path: '.github/workflows/void-deploy.yml', diff --git a/packages/void/test/unit/github-update-cmd.test.ts b/packages/void/test/unit/github-update-cmd.test.ts index ff2d7e0e3..d431f52f8 100644 --- a/packages/void/test/unit/github-update-cmd.test.ts +++ b/packages/void/test/unit/github-update-cmd.test.ts @@ -19,6 +19,7 @@ function makeProject(): ProjectResponse { type: 'void', created_at: '2024-01-01T00:00:00Z', custom_domain: null, + role: 'owner', }; } @@ -28,7 +29,6 @@ function makeConnection(overrides: Partial = {}): GithubConnec project_id: projectId, installation_id: '123456', repo_full_name: 'octocat/hello-world', - repository_id: '9876', branch: 'main', build_executor: 'container', workflow_path: '.github/workflows/void-deploy.yml', @@ -193,30 +193,6 @@ describe('runGithubUpdate (non-TTY)', () => { expect(exitSpy).not.toHaveBeenCalled(); }); - it('fails closed when the server does not apply --workflow (old platform)', async () => { - const { client, updateGithubConnection } = makeClient({ - getGithubConnection: vi.fn(async () => makeConnection()), - // An old platform that predates workflow_path ignores the unknown field and - // returns the connection still pinned to the default. - updateGithubConnection: vi.fn(async () => makeConnection()), - }); - - await expect( - runGithubUpdate('/project', client, { - subcommand: 'update', - projectName: slug, - workflowPath: '.github/workflows/ci.yml', - }), - ).rejects.toThrow('process.exit(1)'); - - expect(updateGithubConnection).toHaveBeenCalledWith(projectId, { - workflowPath: '.github/workflows/ci.yml', - }); - expect(errorSpy).toHaveBeenCalled(); - expect(exitSpy).toHaveBeenCalledWith(1); - expect(successSpy).not.toHaveBeenCalled(); - }); - it('exits(1) with a connect-first message when the project is not connected', async () => { const notConnected = new PlatformApiError( 'Failed to get GitHub connection', diff --git a/packages/void/test/unit/logs-filter.test.ts b/packages/void/test/unit/logs-filter.test.ts index 7e4edee33..d8a4b047c 100644 --- a/packages/void/test/unit/logs-filter.test.ts +++ b/packages/void/test/unit/logs-filter.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { PlatformClient, type LogEntry } from '../../src/cli/client.ts'; import { filterLogEntries } from '../../src/cli/logs.ts'; +import { withPlatformDiscovery } from '../helpers/platform-discovery.ts'; function entry(partial: Partial): LogEntry { return { @@ -162,7 +163,7 @@ describe('PlatformClient.getLogs URL', () => { function setFetchMock() { const fetchMock: ReturnType = vi.fn(async () => Response.json({ data: [] })); - globalThis.fetch = fetchMock as typeof fetch; + globalThis.fetch = withPlatformDiscovery(fetchMock); return fetchMock; } @@ -222,7 +223,7 @@ describe('PlatformClient.getRequests URL', () => { function setFetchMock() { const fetchMock: ReturnType = vi.fn(async () => Response.json({ data: [] })); - globalThis.fetch = fetchMock as typeof fetch; + globalThis.fetch = withPlatformDiscovery(fetchMock); return fetchMock; } diff --git a/packages/void/test/unit/platform-installer.test.ts b/packages/void/test/unit/platform-installer.test.ts index 6515b3a86..252381b06 100644 --- a/packages/void/test/unit/platform-installer.test.ts +++ b/packages/void/test/unit/platform-installer.test.ts @@ -2906,7 +2906,10 @@ describe('platform installer', () => { const database = readPlatformRuntimeManifest().database; const baseline = database.migrations.filter((migration) => migration.index <= 55); expect(baseline.at(-1)?.tag).toBe('0055_durable_deployment_execution'); - expect(database.migrations.at(-1)?.tag).toBe('0056_email_shared_recipients'); + expect(database.migrations.slice(baseline.length).map((migration) => migration.tag)).toEqual([ + '0056_email_shared_recipients', + '0057_github_authority_constraints', + ]); const rows = baseline.map((migration, index) => ({ id: index + 1, hash: migration.sha256, @@ -2931,7 +2934,7 @@ describe('platform installer', () => { '0.22.0', true, ); - expect(result.appliedMigrations).toBe(1); + expect(result.appliedMigrations).toBe(2); expect( accepted.queries.some((query) => query.includes('CREATE TRIGGER "void_shared_email_recipient_teardown_guard"'), @@ -2940,6 +2943,44 @@ describe('platform installer', () => { expect(accepted.rows).toHaveLength(database.migrations.length); }); + it.each(['0.23.0', '0.24.0'])( + 'upgrades a %s installation through the GitHub authority constraints', + async (sourceVersion) => { + const database = readPlatformRuntimeManifest().database; + const baseline = database.migrations.filter((migration) => migration.index <= 56); + expect(baseline.at(-1)?.tag).toBe('0056_email_shared_recipients'); + const rows = baseline.map((migration, index) => ({ + id: index + 1, + hash: migration.sha256, + created_at: migration.timestampMillis, + })); + const rejected = migrationConnection(rows); + await expect( + preparePlatformDatabaseConnection( + rejected, + getPlatformRuntimeDirectory(), + database, + '0.22.0', + true, + ), + ).rejects.toThrow('cannot safely roll back'); + expect(rejected.queries).not.toContain('BEGIN'); + const accepted = migrationConnection(rows); + const result = await preparePlatformDatabaseConnection( + accepted, + getPlatformRuntimeDirectory(), + database, + sourceVersion, + true, + ); + expect(result.appliedMigrations).toBe(1); + expect( + accepted.queries.some((query) => query.includes('"authorized_by_user_id" SET NOT NULL')), + ).toBe(true); + expect(accepted.rows).toHaveLength(database.migrations.length); + }, + ); + it('rejects the isolated legacy email0054 fork without rewriting its history or applying canonical migrations', async () => { const database = readPlatformRuntimeManifest().database; const commonPrefix = database.migrations.filter((migration) => migration.index <= 53); diff --git a/packages/void/test/unit/platform-permission-errors.test.ts b/packages/void/test/unit/platform-permission-errors.test.ts index 7ebb5897c..027ebaaa0 100644 --- a/packages/void/test/unit/platform-permission-errors.test.ts +++ b/packages/void/test/unit/platform-permission-errors.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { PlatformApiError, PlatformClient, parsePlatformErrorBody } from '../../src/cli/client.ts'; import { formatStreamDeployError } from '../../src/cli/deploy-errors.ts'; +import { withPlatformDiscovery } from '../helpers/platform-discovery.ts'; vi.mock('../../src/cli/cf-access.ts', () => ({ ensureCloudflaredToken: vi.fn(), @@ -75,7 +76,7 @@ describe('PlatformClient permission errors', () => { async (_name, operation, requirement) => { const body = JSON.stringify({ error: 'forbidden' }); const fetch = vi.fn(async () => new Response(body, { status: 403 })); - vi.stubGlobal('fetch', fetch); + vi.stubGlobal('fetch', withPlatformDiscovery(fetch)); const error = await operation(client()).catch((error: unknown) => error); expect(error).toBeInstanceOf(PlatformApiError); expect(error).toMatchObject({ status: 403, code: 'forbidden', body }); @@ -258,7 +259,7 @@ describe('deploy preflight rejections', () => { [426, { error: 'cli_outdated', message: 'Upgrade the CLI.' }], ])('stops after an explicit HTTP %s rejection', async (status, body) => { const fetch = vi.fn(async () => Response.json(body, { status })); - vi.stubGlobal('fetch', fetch); + vi.stubGlobal('fetch', withPlatformDiscovery(fetch)); await expect(client().preflight(project, assets)).rejects.toMatchObject({ status, code: body.error, diff --git a/packages/void/test/unit/project-lookup-client.test.ts b/packages/void/test/unit/project-lookup-client.test.ts index 653a2a05e..5aa927072 100644 --- a/packages/void/test/unit/project-lookup-client.test.ts +++ b/packages/void/test/unit/project-lookup-client.test.ts @@ -1,12 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; import { PlatformClient } from '../../src/cli/client.ts'; +import { withPlatformDiscovery } from '../helpers/platform-discovery.ts'; const fetchMock = vi.fn(); let originalFetch: typeof globalThis.fetch; beforeEach(() => { originalFetch = globalThis.fetch; - globalThis.fetch = fetchMock as typeof globalThis.fetch; + globalThis.fetch = withPlatformDiscovery(fetchMock); fetchMock.mockReset(); }); @@ -39,22 +40,21 @@ describe('targeted project lookup', () => { ); }); - it('distinguishes a missing project from an older platform', async () => { + it('returns null for a missing slug', async () => { fetchMock.mockResolvedValueOnce(Response.json({ project: null })); await expect(client().findProjectBySlug('missing')).resolves.toBeNull(); expect(fetchMock).toHaveBeenCalledOnce(); }); it.each(['listProjectChoices', 'findProjectBySlug'] as const)( - 'requires an upgrade when %s is unsupported', + 'reports a 404 from %s as an API error', async (method) => { fetchMock.mockResolvedValueOnce(Response.json({ error: 'not found' }, { status: 404 })); const api = client(); await expect( method === 'findProjectBySlug' ? api.findProjectBySlug('my-app') : api.listProjectChoices(), - ).rejects.toThrow('Ask its administrator to upgrade the platform runtime.'); + ).rejects.toMatchObject({ name: 'PlatformApiError', status: 404 }); expect(fetchMock).toHaveBeenCalledOnce(); - expect(fetchMock.mock.calls[0][0]).not.toBe('https://api.example.test/projects'); }, ); diff --git a/packages/void/test/unit/project-team-client.test.ts b/packages/void/test/unit/project-team-client.test.ts index 3a7400851..d876e2ebd 100644 --- a/packages/void/test/unit/project-team-client.test.ts +++ b/packages/void/test/unit/project-team-client.test.ts @@ -1,12 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; import { PlatformClient } from '../../src/cli/client.ts'; +import { withPlatformDiscovery } from '../helpers/platform-discovery.ts'; const fetchMock = vi.fn(); let originalFetch: typeof globalThis.fetch | undefined; beforeEach(() => { originalFetch = globalThis.fetch; - globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; + globalThis.fetch = withPlatformDiscovery(fetchMock); fetchMock.mockReset(); }); diff --git a/packages/void/test/unit/project-team-command.test.ts b/packages/void/test/unit/project-team-command.test.ts index 3010a2356..ac8e9001a 100644 --- a/packages/void/test/unit/project-team-command.test.ts +++ b/packages/void/test/unit/project-team-command.test.ts @@ -8,6 +8,7 @@ import { savePlatformConnection, } from '../../src/cli/platform-registry.ts'; import { runProjectCommand } from '../../src/cli/project-cmd.ts'; +import { withPlatformDiscovery } from '../helpers/platform-discovery.ts'; const keyring = vi.hoisted(() => ({ read: vi.fn(), write: vi.fn(), remove: vi.fn() })); vi.mock('../../src/cli/keyring.ts', () => ({ @@ -90,7 +91,7 @@ describe('project team target selection', () => { ? Response.json({ invitations: [] }) : Response.json({}); }); - vi.stubGlobal('fetch', fetchMock); + vi.stubGlobal('fetch', withPlatformDiscovery(fetchMock)); vi.spyOn(console, 'log').mockImplementation(() => undefined); vi.spyOn(process.stdout, 'write').mockImplementation(() => true); @@ -140,7 +141,7 @@ describe('project team target selection', () => { ? Response.json({ invitations: [] }) : Response.json({}); }); - vi.stubGlobal('fetch', fetchMock); + vi.stubGlobal('fetch', withPlatformDiscovery(fetchMock)); vi.spyOn(console, 'log').mockImplementation(() => undefined); vi.spyOn(process.stdout, 'write').mockImplementation(() => true); @@ -194,7 +195,7 @@ describe('project team target selection', () => { : null, ); const fetchMock = vi.fn(async () => Response.json({ invitations: [] })); - vi.stubGlobal('fetch', fetchMock); + vi.stubGlobal('fetch', withPlatformDiscovery(fetchMock)); vi.spyOn(console, 'log').mockImplementation(() => undefined); vi.spyOn(process.stdout, 'write').mockImplementation(() => true); @@ -213,7 +214,7 @@ describe('project team target selection', () => { 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.stubGlobal('fetch', withPlatformDiscovery(fetchMock)); vi.spyOn(console, 'log').mockImplementation(() => undefined); const output: Array = []; vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { @@ -245,7 +246,7 @@ describe('project team target selection', () => { : null, ); const fetchMock = vi.fn(); - vi.stubGlobal('fetch', fetchMock); + vi.stubGlobal('fetch', withPlatformDiscovery(fetchMock)); vi.spyOn(console, 'log').mockImplementation(() => undefined); vi.spyOn(process.stdout, 'write').mockImplementation(() => true); vi.spyOn(process, 'exit').mockImplementation(() => { @@ -279,7 +280,7 @@ describe('project team target selection', () => { members: [{ user: { id: 'user_1', login: 'alex' }, role: 'owner' }], }), ); - vi.stubGlobal('fetch', fetchMock); + vi.stubGlobal('fetch', withPlatformDiscovery(fetchMock)); vi.spyOn(console, 'log').mockImplementation(() => undefined); vi.spyOn(process.stdout, 'write').mockImplementation(() => true); @@ -319,7 +320,7 @@ describe('project team target selection', () => { members: [{ user: { id: 'owner_1', login: 'owner' }, role: 'owner' }], }), ); - vi.stubGlobal('fetch', fetchMock); + vi.stubGlobal('fetch', withPlatformDiscovery(fetchMock)); vi.spyOn(console, 'log').mockImplementation(() => undefined); vi.spyOn(process.stdout, 'write').mockImplementation(() => true); diff --git a/packages/void/test/unit/project-token-command.test.ts b/packages/void/test/unit/project-token-command.test.ts index e05fb9294..767110948 100644 --- a/packages/void/test/unit/project-token-command.test.ts +++ b/packages/void/test/unit/project-token-command.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { savePlatformConnection } from '../../src/cli/platform-registry.ts'; import { runProjectTokenCommand } from '../../src/cli/project-token.ts'; +import { withPlatformDiscovery } from '../helpers/platform-discovery.ts'; const roots: Array = []; @@ -68,7 +69,7 @@ describe('project token command', () => { { status: 201 }, ), ); - vi.stubGlobal('fetch', request); + vi.stubGlobal('fetch', withPlatformDiscovery(request)); const output = vi.spyOn(console, 'log').mockImplementation(() => {}); await runProjectTokenCommand(root, { subcommand: 'token', diff --git a/packages/void/test/unit/project-zero-trust-command.test.ts b/packages/void/test/unit/project-zero-trust-command.test.ts index 44636ab65..21b73b18c 100644 --- a/packages/void/test/unit/project-zero-trust-command.test.ts +++ b/packages/void/test/unit/project-zero-trust-command.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { savePlatformConnection } from '../../src/cli/platform-registry.ts'; import { runProjectCommand } from '../../src/cli/project-cmd.ts'; +import { withPlatformDiscovery } from '../helpers/platform-discovery.ts'; const roots: Array = []; @@ -101,7 +102,7 @@ describe('project Zero Trust command', () => { project: null, }), ); - vi.stubGlobal('fetch', fetchMock); + vi.stubGlobal('fetch', withPlatformDiscovery(fetchMock)); vi.spyOn(console, 'log').mockImplementation(() => undefined); const output: Array = []; vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { @@ -190,7 +191,7 @@ describe('project Zero Trust command', () => { }, }), ); - vi.stubGlobal('fetch', fetchMock); + vi.stubGlobal('fetch', withPlatformDiscovery(fetchMock)); vi.spyOn(console, 'log').mockImplementation(() => undefined); vi.spyOn(process.stdout, 'write').mockImplementation(() => true); diff --git a/packages/void/test/unit/purge-cache-command.test.ts b/packages/void/test/unit/purge-cache-command.test.ts index 922890f02..430b78d96 100644 --- a/packages/void/test/unit/purge-cache-command.test.ts +++ b/packages/void/test/unit/purge-cache-command.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; import { savePlatformConnection } from '../../src/cli/platform-registry.ts'; +import { withPlatformDiscovery } from '../helpers/platform-discovery.ts'; const tempHomes: Array = []; const tempRoots: Array = []; @@ -107,7 +108,7 @@ describe('runPurgeCacheCommand', () => { createLinkedProject(root); const fetchMock = vi.fn(async () => Response.json({ purged: 5 })); - vi.stubGlobal('fetch', fetchMock); + vi.stubGlobal('fetch', withPlatformDiscovery(fetchMock)); const capture = captureOutput(); const runPurgeCacheCommand = await importRunPurgeCacheCommand(); @@ -147,7 +148,7 @@ describe('runPurgeCacheCommand', () => { } return Response.json({ purged: 3 }); }); - vi.stubGlobal('fetch', fetchMock); + vi.stubGlobal('fetch', withPlatformDiscovery(fetchMock)); const capture = captureOutput(); const runPurgeCacheCommand = await importRunPurgeCacheCommand(); diff --git a/platform/meta/operations.md b/platform/meta/operations.md index 90433950f..025b0c181 100644 --- a/platform/meta/operations.md +++ b/platform/meta/operations.md @@ -41,6 +41,8 @@ These deployment and smoke-test workflows target Void Cloud, from `prod` for pro Both deploy workflows serialize deployments without cancelling an active rollout. Advancing `prod` requests a production deployment. A failed or interrupted deployment needs inspection before another rollout, particularly when database migrations already ran. +Hosted API and email gateway deploys report the release version from `packages/platform/package.json` as `PLATFORM_VERSION`. The `void` CLI refuses platforms older than its minimum platform version, so release in this order: advance `prod` and wait for the production deployment to succeed, then push the release tag that publishes the npm packages. + Consult the workflow files for their current inputs. Store credentials in the selected GitHub environment and the deployment owner's secret manager. Keep secret-manager locations, account access assignments and credential-issuance diff --git a/platform/packages/api/drizzle/0057_github_authority_constraints.sql b/platform/packages/api/drizzle/0057_github_authority_constraints.sql new file mode 100644 index 000000000..d0cf207ab --- /dev/null +++ b/platform/packages/api/drizzle/0057_github_authority_constraints.sql @@ -0,0 +1,41 @@ +WITH "removed_connections" AS ( + DELETE FROM "github_connections" + USING "projects" + WHERE + "projects"."id" = "github_connections"."project_id" + AND ( + "github_connections"."authorized_by_user_id" IS NULL + OR "github_connections"."repository_id" IS NULL + OR NOT ( + "github_connections"."authorized_by_user_id" = "projects"."user_id" + OR EXISTS ( + SELECT 1 + FROM "project_members" + WHERE + "project_members"."project_id" = "projects"."id" + AND "project_members"."user_id" = "github_connections"."authorized_by_user_id" + AND "project_members"."role" = 'admin' + ) + ) + ) + RETURNING "github_connections"."project_id" +) +UPDATE "builds" +SET + "status" = 'cancelled', + "error" = COALESCE("builds"."error", 'github repository authorization is no longer valid; reconnect the repository'), + "completed_at" = COALESCE("builds"."completed_at", now()) +FROM "removed_connections" +WHERE + "builds"."project_id" = "removed_connections"."project_id" + AND "builds"."status" IN ('queued', 'starting', 'building', 'deploying'); +--> statement-breakpoint +UPDATE "github_installations" +SET "user_id" = NULL +WHERE "account_type" = 'Organization' AND "user_id" IS NOT NULL; +--> statement-breakpoint +ALTER TABLE "github_connections" DROP CONSTRAINT "github_connections_authorized_by_user_id_users_id_fk"; +--> statement-breakpoint +ALTER TABLE "github_connections" ALTER COLUMN "repository_id" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "github_connections" ALTER COLUMN "authorized_by_user_id" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "github_connections" ADD CONSTRAINT "github_connections_authorized_by_user_id_users_id_fk" FOREIGN KEY ("authorized_by_user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/platform/packages/api/drizzle/compatibility.json b/platform/packages/api/drizzle/compatibility.json index a410cf9d9..869729275 100644 --- a/platform/packages/api/drizzle/compatibility.json +++ b/platform/packages/api/drizzle/compatibility.json @@ -2,34 +2,6 @@ "schemaVersion": 1, "lineage": "void-platform-postgresql-v1", "rollbackSafeFrom": [ - { - "platformVersion": "0.10.14", - "schemaDigest": "9631e7ad6596fc8cd12715d5263d32e94aafd398e5b1689321038232f64dcd74" - }, - { - "platformVersion": "0.10.14", - "schemaDigest": "6a8a3e502f01162731bdf0bdc8b2e86beac443f87629789ee312bfbc03125e47" - }, - { - "platformVersion": "0.10.14", - "schemaDigest": "07b5142d4caea007bb8dc8d0fae2c273e56fd6878282fc2b9a76e414210e2fe3" - }, - { - "platformVersion": "0.10.14", - "schemaDigest": "06c6f91f87d2ad95b1631ae48e2cd671aa6976fe53313947748c07bf2a7d6fda" - }, - { - "platformVersion": "0.10.14", - "schemaDigest": "3dbf4c31d4bc97a11edca35c59b34ffad1f072df0ad7174d69e2de1057e2610b" - }, - { - "platformVersion": "0.10.14", - "schemaDigest": "680a0af38a079d371ee8caa019a2cb6351855361b28d1041017c12fdf2abac46" - }, - { - "platformVersion": "0.20.1", - "schemaDigest": "6de871d9524b082bba675d0d653ade45f95206895a962fea2b9be4b85d486313" - }, { "platformVersion": "0.20.3", "schemaDigest": "089431e230970d03afa429c102613598e88371155cb693c076002c5d632bb0b6" @@ -77,6 +49,14 @@ { "platformVersion": "0.22.0", "schemaDigest": "bef2a1cfaad1dcad4aca2a4932ef9aa9b9d8025dca495e3b1027902405da902a" + }, + { + "platformVersion": "0.23.0", + "schemaDigest": "3e3458ba1268853dc8e9a887270863557c2a4c5a5210f8615d247bf1f4bf6bcc" + }, + { + "platformVersion": "0.24.0", + "schemaDigest": "3e3458ba1268853dc8e9a887270863557c2a4c5a5210f8615d247bf1f4bf6bcc" } ] } diff --git a/platform/packages/api/drizzle/meta/0057_snapshot.json b/platform/packages/api/drizzle/meta/0057_snapshot.json new file mode 100644 index 000000000..17b881f74 --- /dev/null +++ b/platform/packages/api/drizzle/meta/0057_snapshot.json @@ -0,0 +1,6517 @@ +{ + "id": "075ff55d-533d-44d8-acba-66fa0e8d29ae", + "prevId": "8f9c66e3-2d0e-4619-a5eb-3e9ddf2c5b99", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.auth_codes": { + "name": "auth_codes", + "schema": "", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "jwt": { + "name": "jwt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cli_state": { + "name": "cli_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_challenge": { + "name": "code_challenge", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_challenge_method": { + "name": "code_challenge_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.builds": { + "name": "builds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "executor": { + "name": "executor", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'container'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "commit_sha": { + "name": "commit_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_id": { + "name": "deployment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_run_id": { + "name": "github_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_run_attempt": { + "name": "github_run_attempt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logs_url": { + "name": "logs_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "check_run_id": { + "name": "check_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "instance_type": { + "name": "instance_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_builds_project_created": { + "name": "idx_builds_project_created", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_builds_status": { + "name": "idx_builds_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_builds_user_status": { + "name": "idx_builds_user_status", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_builds_project_github_run_id": { + "name": "idx_builds_project_github_run_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "github_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "github_run_attempt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"builds\".\"github_run_id\" IS NOT NULL AND \"builds\".\"github_run_attempt\" IS NOT NULL AND \"builds\".\"status\" IN ('starting', 'building', 'deploying')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "builds_project_id_projects_id_fk": { + "name": "builds_project_id_projects_id_fk", + "tableFrom": "builds", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "builds_user_id_users_id_fk": { + "name": "builds_user_id_users_id_fk", + "tableFrom": "builds", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "builds_deployment_id_deployments_id_fk": { + "name": "builds_deployment_id_deployments_id_fk", + "tableFrom": "builds", + "tableTo": "deployments", + "columnsFrom": ["deployment_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_operations": { + "name": "deployment_operations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_active_id": { + "name": "original_active_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cli_version": { + "name": "cli_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_digest": { + "name": "input_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_prefix": { + "name": "input_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_dispatch_at": { + "name": "next_dispatch_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "events": { + "name": "events", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "last_seq": { + "name": "last_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "inputs_deleted": { + "name": "inputs_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_deployment_operations_key": { + "name": "idx_deployment_operations_key", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployment_operations_dispatch": { + "name": "idx_deployment_operations_dispatch", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_dispatch_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployment_operations_project_id_projects_id_fk": { + "name": "deployment_operations_project_id_projects_id_fk", + "tableFrom": "deployment_operations", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployments": { + "name": "deployments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "manifest": { + "name": "manifest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commit": { + "name": "commit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_url": { + "name": "repository_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commit_url": { + "name": "commit_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "git_ref": { + "name": "git_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cli_version": { + "name": "cli_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "admission_protocol": { + "name": "admission_protocol", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "asset_hashes": { + "name": "asset_hashes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "retained": { + "name": "retained", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "applied_migrations": { + "name": "applied_migrations", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bundle_stored": { + "name": "bundle_stored", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_user_id": { + "name": "billing_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "initiated_by_user_id": { + "name": "initiated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_deployments_project_id": { + "name": "idx_deployments_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployments_project_status": { + "name": "idx_deployments_project_status", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployments_created_id": { + "name": "idx_deployments_created_id", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployments_project_id_projects_id_fk": { + "name": "deployments_project_id_projects_id_fk", + "tableFrom": "deployments", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.domains": { + "name": "domains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cf_hostname_id": { + "name": "cf_hostname_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_data": { + "name": "verification_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_domains_project_id": { + "name": "idx_domains_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "domains_project_id_projects_id_fk": { + "name": "domains_project_id_projects_id_fk", + "tableFrom": "domains", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "domains_hostname_unique": { + "name": "domains_hostname_unique", + "nullsNotDistinct": false, + "columns": ["hostname"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_destination_cleanups": { + "name": "email_destination_cleanups", + "schema": "", + "columns": { + "cf_destination_id": { + "name": "cf_destination_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "lease_started_at": { + "name": "lease_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_email_destination_cleanups_due": { + "name": "idx_email_destination_cleanups_due", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "email_destination_cleanups_status_check": { + "name": "email_destination_cleanups_status_check", + "value": "\"email_destination_cleanups\".\"status\" IN ('pending', 'deleting', 'completed')" + }, + "email_destination_cleanups_attempts_check": { + "name": "email_destination_cleanups_attempts_check", + "value": "\"email_destination_cleanups\".\"attempts\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.email_destinations": { + "name": "email_destinations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cf_destination_id": { + "name": "cf_destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "verification_source": { + "name": "verification_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'cf'" + }, + "verified_by_user_id": { + "name": "verified_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_by_email_snapshot": { + "name": "verified_by_email_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_email_destinations_project_id": { + "name": "idx_email_destinations_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_email_destinations_cf_destination_id": { + "name": "idx_email_destinations_cf_destination_id", + "columns": [ + { + "expression": "cf_destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_email_destinations_verified_by_user_id": { + "name": "idx_email_destinations_verified_by_user_id", + "columns": [ + { + "expression": "verified_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "verified_by_email_snapshot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_destinations_project_id_projects_id_fk": { + "name": "email_destinations_project_id_projects_id_fk", + "tableFrom": "email_destinations", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_destinations_verified_by_user_id_users_id_fk": { + "name": "email_destinations_verified_by_user_id_users_id_fk", + "tableFrom": "email_destinations", + "tableTo": "users", + "columnsFrom": ["verified_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_destinations_project_address_uq": { + "name": "email_destinations_project_address_uq", + "nullsNotDistinct": false, + "columns": ["project_id", "address"] + } + }, + "policies": {}, + "checkConstraints": { + "email_destinations_status_check": { + "name": "email_destinations_status_check", + "value": "\"email_destinations\".\"status\" IN ('pending', 'verified')" + }, + "email_destinations_verification_source_check": { + "name": "email_destinations_verification_source_check", + "value": "\"email_destinations\".\"verification_source\" IN ('cf', 'platform')" + } + }, + "isRLSEnabled": false + }, + "public.email_domains": { + "name": "email_domains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "zone_id": { + "name": "zone_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "credential_kind": { + "name": "credential_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_ciphertext": { + "name": "token_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_iv": { + "name": "token_iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_key_version": { + "name": "token_key_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_by": { + "name": "managed_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'owner'" + }, + "relay_ciphertext": { + "name": "relay_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "relay_iv": { + "name": "relay_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "relay_key_version": { + "name": "relay_key_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "relay_worker_name": { + "name": "relay_worker_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "relay_version": { + "name": "relay_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_id": { + "name": "token_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_on": { + "name": "token_expires_on", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempt_id": { + "name": "attempt_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "setup_state": { + "name": "setup_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_email_domains_project_id": { + "name": "idx_email_domains_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_domains_zone_uq": { + "name": "email_domains_zone_uq", + "columns": [ + { + "expression": "zone_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"email_domains\".\"zone_id\" IS NOT NULL AND \"email_domains\".\"status\" IN ('active', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_domains_relay_uq": { + "name": "email_domains_relay_uq", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "relay_worker_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"email_domains\".\"relay_worker_name\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_domains_project_id_projects_id_fk": { + "name": "email_domains_project_id_projects_id_fk", + "tableFrom": "email_domains", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_domains_domain_uq": { + "name": "email_domains_domain_uq", + "nullsNotDistinct": false, + "columns": ["domain"] + } + }, + "policies": {}, + "checkConstraints": { + "email_domains_mode_check": { + "name": "email_domains_mode_check", + "value": "\"email_domains\".\"mode\" IN ('apex', 'subdomain')" + }, + "email_domains_status_check": { + "name": "email_domains_status_check", + "value": "\"email_domains\".\"status\" IN ('pending', 'active', 'token_revoked', 'token_expired', 'failed')" + }, + "email_domains_credential_kind_check": { + "name": "email_domains_credential_kind_check", + "value": "\"email_domains\".\"credential_kind\" IN ('oauth_grant', 'api_token', 'platform')" + }, + "email_domains_platform_token_check": { + "name": "email_domains_platform_token_check", + "value": "(\"email_domains\".\"credential_kind\" = 'platform' AND \"email_domains\".\"token_ciphertext\" IS NULL AND \"email_domains\".\"token_iv\" IS NULL AND \"email_domains\".\"token_key_version\" IS NULL) OR (\"email_domains\".\"credential_kind\" <> 'platform' AND \"email_domains\".\"token_ciphertext\" IS NOT NULL AND \"email_domains\".\"token_iv\" IS NOT NULL AND \"email_domains\".\"token_key_version\" IS NOT NULL)" + }, + "email_domains_managed_by_check": { + "name": "email_domains_managed_by_check", + "value": "\"email_domains\".\"managed_by\" IN ('owner', 'admin')" + } + }, + "isRLSEnabled": false + }, + "public.github_connections": { + "name": "github_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "authorized_by_user_id": { + "name": "authorized_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "build_executor": { + "name": "build_executor", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'container'" + }, + "workflow_path": { + "name": "workflow_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'.github/workflows/void-deploy.yml'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_github_connections_installation_id": { + "name": "idx_github_connections_installation_id", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_github_connections_repo_full_name": { + "name": "idx_github_connections_repo_full_name", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_github_connections_installation_repo_id": { + "name": "idx_github_connections_installation_repo_id", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_connections_project_id_projects_id_fk": { + "name": "github_connections_project_id_projects_id_fk", + "tableFrom": "github_connections", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_connections_authorized_by_user_id_users_id_fk": { + "name": "github_connections_authorized_by_user_id_users_id_fk", + "tableFrom": "github_connections", + "tableTo": "users", + "columnsFrom": ["authorized_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_connections_project_id_unique": { + "name": "github_connections_project_id_unique", + "nullsNotDistinct": false, + "columns": ["project_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_installation_members": { + "name": "github_installation_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_account_id": { + "name": "github_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_github_installation_members_install_user": { + "name": "idx_github_installation_members_install_user", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_github_installation_members_user_id": { + "name": "idx_github_installation_members_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_installation_members_user_id_users_id_fk": { + "name": "github_installation_members_user_id_users_id_fk", + "tableFrom": "github_installation_members", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_installation_revocations": { + "name": "github_installation_revocations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_account_id": { + "name": "github_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_github_installation_revocations_install_account": { + "name": "idx_github_installation_revocations_install_account", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "github_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_installations": { + "name": "github_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repositories": { + "name": "repositories", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "repository_selection": { + "name": "repository_selection", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'selected'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_github_installations_user_id": { + "name": "idx_github_installations_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_github_installations_account_id": { + "name": "idx_github_installations_account_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_installations_user_id_users_id_fk": { + "name": "github_installations_user_id_users_id_fk", + "tableFrom": "github_installations", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_installations_installation_id_unique": { + "name": "github_installations_installation_id_unique", + "nullsNotDistinct": false, + "columns": ["installation_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_webhook_redeliveries": { + "name": "github_webhook_redeliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "guid": { + "name": "guid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "delivery_id": { + "name": "delivery_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "redelivered_at": { + "name": "redelivered_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_github_webhook_redeliveries_guid": { + "name": "idx_github_webhook_redeliveries_guid", + "columns": [ + { + "expression": "guid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitations": { + "name": "invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "email_delivery_status": { + "name": "email_delivery_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accepted_by": { + "name": "accepted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_invitations_email": { + "name": "idx_invitations_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_invitations_status": { + "name": "idx_invitations_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "invitations_email_delivery_status_check": { + "name": "invitations_email_delivery_status_check", + "value": "\"invitations\".\"email_delivery_status\" IS NULL OR \"invitations\".\"email_delivery_status\" IN ('sent', 'failed', 'unavailable')" + } + }, + "isRLSEnabled": false + }, + "public.migration_renames": { + "name": "migration_renames", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "to_name": { + "name": "to_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_migration_renames_project_id": { + "name": "idx_migration_renames_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "migration_renames_project_id_projects_id_fk": { + "name": "migration_renames_project_id_projects_id_fk", + "tableFrom": "migration_renames", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "migration_renames_project_id_from_name_to_name_pk": { + "name": "migration_renames_project_id_from_name_to_name_pk", + "columns": ["project_id", "from_name", "to_name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_accounts": { + "name": "oauth_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_oauth_accounts_provider_id": { + "name": "idx_oauth_accounts_provider_id", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_oauth_accounts_user_id": { + "name": "idx_oauth_accounts_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_accounts_user_id_users_id_fk": { + "name": "oauth_accounts_user_id_users_id_fk", + "tableFrom": "oauth_accounts", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_nonces": { + "name": "oauth_nonces", + "schema": "", + "columns": { + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.operator_events": { + "name": "operator_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "login": { + "name": "login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "http_status": { + "name": "http_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_operator_events_created": { + "name": "idx_operator_events_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.operator_sessions": { + "name": "operator_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "human_session_id": { + "name": "human_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_operator_sessions_expiry": { + "name": "idx_operator_sessions_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "operator_sessions_user_id_users_id_fk": { + "name": "operator_sessions_user_id_users_id_fk", + "tableFrom": "operator_sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "operator_sessions_human_session_id_platform_human_sessions_id_fk": { + "name": "operator_sessions_human_session_id_platform_human_sessions_id_fk", + "tableFrom": "operator_sessions", + "tableTo": "platform_human_sessions", + "columnsFrom": ["human_session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_auth_attempts": { + "name": "platform_auth_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "browser_hash": { + "name": "browser_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context": { + "name": "context", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "platform_auth_attempts_expiry": { + "name": "platform_auth_attempts_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_auth_attempts_connection_id_platform_login_connections_id_fk": { + "name": "platform_auth_attempts_connection_id_platform_login_connections_id_fk", + "tableFrom": "platform_auth_attempts", + "tableTo": "platform_login_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_auth_bootstrap": { + "name": "platform_auth_bootstrap", + "schema": "", + "columns": { + "singleton": { + "name": "singleton", + "type": "boolean", + "primaryKey": true, + "notNull": true, + "default": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_hash": { + "name": "credential_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "claim_hash": { + "name": "claim_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pending_identity": { + "name": "pending_identity", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "platform_auth_bootstrap_connection_id_platform_login_connections_id_fk": { + "name": "platform_auth_bootstrap_connection_id_platform_login_connections_id_fk", + "tableFrom": "platform_auth_bootstrap", + "tableTo": "platform_login_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "platform_auth_bootstrap_singleton": { + "name": "platform_auth_bootstrap_singleton", + "value": "\"platform_auth_bootstrap\".\"singleton\" = true" + } + }, + "isRLSEnabled": false + }, + "public.platform_auth_settings": { + "name": "platform_auth_settings", + "schema": "", + "columns": { + "singleton": { + "name": "singleton", + "type": "boolean", + "primaryKey": true, + "notNull": true, + "default": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "admission": { + "name": "admission", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "protection": { + "name": "protection", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "session_seconds": { + "name": "session_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2592000 + }, + "sessions_required": { + "name": "sessions_required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "platform_auth_settings_singleton": { + "name": "platform_auth_settings_singleton", + "value": "\"platform_auth_settings\".\"singleton\" = true" + }, + "platform_auth_settings_revision": { + "name": "platform_auth_settings_revision", + "value": "\"platform_auth_settings\".\"revision\" > 0" + }, + "platform_auth_settings_session_seconds": { + "name": "platform_auth_settings_session_seconds", + "value": "\"platform_auth_settings\".\"session_seconds\" BETWEEN 300 AND 2592000" + } + }, + "isRLSEnabled": false + }, + "public.platform_company_identities": { + "name": "platform_company_identities", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "platform_company_identity_user": { + "name": "platform_company_identity_user", + "columns": [ + { + "expression": "issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_company_identities_user_id_users_id_fk": { + "name": "platform_company_identities_user_id_users_id_fk", + "tableFrom": "platform_company_identities", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "platform_company_identities_issuer_subject_pk": { + "name": "platform_company_identities_issuer_subject_pk", + "columns": ["issuer", "subject"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_events": { + "name": "platform_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "batch_id": { + "name": "batch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "worker_name": { + "name": "worker_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "script_name": { + "name": "script_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "admin_user_id": { + "name": "admin_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "admin_login": { + "name": "admin_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_platform_events_batch_id": { + "name": "idx_platform_events_batch_id", + "columns": [ + { + "expression": "batch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_platform_events_env_created": { + "name": "idx_platform_events_env_created", + "columns": [ + { + "expression": "environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_human_sessions": { + "name": "platform_human_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_issuer": { + "name": "access_issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_subject": { + "name": "access_subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "platform_human_sessions_connection": { + "name": "platform_human_sessions_connection", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "platform_human_sessions_user": { + "name": "platform_human_sessions_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "platform_human_sessions_expiry": { + "name": "platform_human_sessions_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_human_sessions_user_id_users_id_fk": { + "name": "platform_human_sessions_user_id_users_id_fk", + "tableFrom": "platform_human_sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "platform_human_sessions_connection_id_platform_login_connections_id_fk": { + "name": "platform_human_sessions_connection_id_platform_login_connections_id_fk", + "tableFrom": "platform_human_sessions", + "tableTo": "platform_login_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "platform_human_sessions_audience": { + "name": "platform_human_sessions_audience", + "value": "\"platform_human_sessions\".\"audience\" IN ('api', 'admin')" + }, + "platform_human_sessions_access_identity": { + "name": "platform_human_sessions_access_identity", + "value": "(\"platform_human_sessions\".\"access_issuer\" IS NULL) = (\"platform_human_sessions\".\"access_subject\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.platform_login_connections": { + "name": "platform_login_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "active_revision": { + "name": "active_revision", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "platform_login_connections_kind": { + "name": "platform_login_connections_kind", + "value": "\"platform_login_connections\".\"kind\" IN ('github', 'google', 'oidc', 'cloudflare-access')" + }, + "platform_login_connections_revision": { + "name": "platform_login_connections_revision", + "value": "\"platform_login_connections\".\"revision\" > 0 AND \"platform_login_connections\".\"generation\" > 0" + }, + "platform_login_connections_active_revision": { + "name": "platform_login_connections_active_revision", + "value": "\"platform_login_connections\".\"active_revision\" IS NULL OR \"platform_login_connections\".\"active_revision\" BETWEEN 1 AND \"platform_login_connections\".\"revision\"" + }, + "platform_login_connections_enabled": { + "name": "platform_login_connections_enabled", + "value": "NOT \"platform_login_connections\".\"enabled\" OR \"platform_login_connections\".\"active_revision\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.platform_login_identities": { + "name": "platform_login_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_authenticated_at": { + "name": "last_authenticated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "platform_login_identity_subject": { + "name": "platform_login_identity_subject", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "platform_login_identity_user": { + "name": "platform_login_identity_user", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_login_identities_user_id_users_id_fk": { + "name": "platform_login_identities_user_id_users_id_fk", + "tableFrom": "platform_login_identities", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "platform_login_identities_connection_id_platform_login_connections_id_fk": { + "name": "platform_login_identities_connection_id_platform_login_connections_id_fk", + "tableFrom": "platform_login_identities", + "tableTo": "platform_login_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_login_revisions": { + "name": "platform_login_revisions", + "schema": "", + "columns": { + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "configuration": { + "name": "configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "platform_login_revisions_connection_id_platform_login_connections_id_fk": { + "name": "platform_login_revisions_connection_id_platform_login_connections_id_fk", + "tableFrom": "platform_login_revisions", + "tableTo": "platform_login_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "platform_login_revisions_connection_id_revision_pk": { + "name": "platform_login_revisions_connection_id_revision_pk", + "columns": ["connection_id", "revision"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_login_tests": { + "name": "platform_login_tests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "login": { + "name": "login", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "platform_login_tests_expiry": { + "name": "platform_login_tests_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_login_tests_user_id_users_id_fk": { + "name": "platform_login_tests_user_id_users_id_fk", + "tableFrom": "platform_login_tests", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "platform_login_tests_connection_id_platform_login_connections_id_fk": { + "name": "platform_login_tests_connection_id_platform_login_connections_id_fk", + "tableFrom": "platform_login_tests", + "tableTo": "platform_login_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_settings": { + "name": "platform_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_access_events": { + "name": "project_access_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invitation_email_snapshot": { + "name": "invitation_email_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_role": { + "name": "previous_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_role": { + "name": "new_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_project_access_events_project_created": { + "name": "idx_project_access_events_project_created", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_project_access_events_subject_created": { + "name": "idx_project_access_events_subject_created", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_access_events_project_id_projects_id_fk": { + "name": "project_access_events_project_id_projects_id_fk", + "tableFrom": "project_access_events", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "project_access_events_previous_role_check": { + "name": "project_access_events_previous_role_check", + "value": "\"project_access_events\".\"previous_role\" IS NULL OR \"project_access_events\".\"previous_role\" IN ('reader', 'collaborator', 'admin')" + }, + "project_access_events_new_role_check": { + "name": "project_access_events_new_role_check", + "value": "\"project_access_events\".\"new_role\" IS NULL OR \"project_access_events\".\"new_role\" IN ('reader', 'collaborator', 'admin')" + }, + "project_access_events_action_check": { + "name": "project_access_events_action_check", + "value": "\"project_access_events\".\"action\" IN ('invited', 'invitation_resent', 'member_added', 'accepted', 'declined', 'invitation_revoked', 'role_changed', 'member_removed', 'left')" + } + }, + "isRLSEnabled": false + }, + "public.project_cleanup_jobs": { + "name": "project_cleanup_jobs", + "schema": "", + "columns": { + "target": { + "name": "target", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_cleanup_jobs_due": { + "name": "project_cleanup_jobs_due", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_database_credentials": { + "name": "project_database_credentials", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "iv": { + "name": "iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_version": { + "name": "key_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "project_database_credentials_project_id_projects_id_fk": { + "name": "project_database_credentials_project_id_projects_id_fk", + "tableFrom": "project_database_credentials", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_deploy_credentials": { + "name": "project_deploy_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issued_by": { + "name": "issued_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_deploy_credentials_project": { + "name": "project_deploy_credentials_project", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_deploy_credentials_expiry": { + "name": "project_deploy_credentials_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_deploy_credentials_project_id_projects_id_fk": { + "name": "project_deploy_credentials_project_id_projects_id_fk", + "tableFrom": "project_deploy_credentials", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_deploy_credentials_issued_by_users_id_fk": { + "name": "project_deploy_credentials_issued_by_users_id_fk", + "tableFrom": "project_deploy_credentials", + "tableTo": "users", + "columnsFrom": ["issued_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "project_deploy_credentials_name": { + "name": "project_deploy_credentials_name", + "value": "length(\"project_deploy_credentials\".\"name\") BETWEEN 1 AND 80" + } + }, + "isRLSEnabled": false + }, + "public.project_invitations": { + "name": "project_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_user_id": { + "name": "target_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_email_snapshot": { + "name": "target_email_snapshot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "invited_by_user_id": { + "name": "invited_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accepted_by_user_id": { + "name": "accepted_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_by_user_id": { + "name": "revoked_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_project_invitations_project_status": { + "name": "idx_project_invitations_project_status", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_project_invitations_target_status": { + "name": "idx_project_invitations_target_status", + "columns": [ + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_project_invitations_pending_target": { + "name": "idx_project_invitations_pending_target", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"project_invitations\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_invitations_project_id_projects_id_fk": { + "name": "project_invitations_project_id_projects_id_fk", + "tableFrom": "project_invitations", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_invitations_target_user_id_users_id_fk": { + "name": "project_invitations_target_user_id_users_id_fk", + "tableFrom": "project_invitations", + "tableTo": "users", + "columnsFrom": ["target_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "project_invitations_role_check": { + "name": "project_invitations_role_check", + "value": "\"project_invitations\".\"role\" IN ('reader', 'collaborator', 'admin')" + }, + "project_invitations_status_check": { + "name": "project_invitations_status_check", + "value": "\"project_invitations\".\"status\" IN ('pending', 'accepted', 'declined', 'revoked', 'expired')" + } + }, + "isRLSEnabled": false + }, + "public.project_members": { + "name": "project_members", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_by_user_id": { + "name": "added_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_project_members_user_project": { + "name": "idx_project_members_user_project", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_members_project_id_projects_id_fk": { + "name": "project_members_project_id_projects_id_fk", + "tableFrom": "project_members", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_members_user_id_users_id_fk": { + "name": "project_members_user_id_users_id_fk", + "tableFrom": "project_members", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_members_project_id_user_id_pk": { + "name": "project_members_project_id_user_id_pk", + "columns": ["project_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "project_members_role_check": { + "name": "project_members_role_check", + "value": "\"project_members\".\"role\" IN ('reader', 'collaborator', 'admin')" + } + }, + "isRLSEnabled": false + }, + "public.project_migrations": { + "name": "project_migrations", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "source_deployment_id": { + "name": "source_deployment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_project_migrations_project_id": { + "name": "idx_project_migrations_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "applied_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_migrations_project_id_projects_id_fk": { + "name": "project_migrations_project_id_projects_id_fk", + "tableFrom": "project_migrations", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_migrations_source_deployment_id_deployments_id_fk": { + "name": "project_migrations_source_deployment_id_deployments_id_fk", + "tableFrom": "project_migrations", + "tableTo": "deployments", + "columnsFrom": ["source_deployment_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_migrations_project_id_name_pk": { + "name": "project_migrations_project_id_name_pk", + "columns": ["project_id", "name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_ownership_periods": { + "name": "project_ownership_periods", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "changed_by_user_id": { + "name": "changed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_project_ownership_periods_owner_time": { + "name": "idx_project_ownership_periods_owner_time", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starts_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ends_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_project_ownership_periods_project_time": { + "name": "idx_project_ownership_periods_project_time", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starts_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_project_ownership_periods_open": { + "name": "idx_project_ownership_periods_open", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"project_ownership_periods\".\"ends_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_ownership_periods_project_id_projects_id_fk": { + "name": "project_ownership_periods_project_id_projects_id_fk", + "tableFrom": "project_ownership_periods", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "project_ownership_periods_time_check": { + "name": "project_ownership_periods_time_check", + "value": "\"project_ownership_periods\".\"ends_at\" IS NULL OR \"project_ownership_periods\".\"ends_at\" >= \"project_ownership_periods\".\"starts_at\"" + } + }, + "isRLSEnabled": false + }, + "public.project_secrets": { + "name": "project_secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "iv": { + "name": "iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_version": { + "name": "key_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "managed_reason": { + "name": "managed_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_project_secrets_project_id": { + "name": "idx_project_secrets_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_secrets_project_id_projects_id_fk": { + "name": "project_secrets_project_id_projects_id_fk", + "tableFrom": "project_secrets", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_secrets_project_id_name_unique": { + "name": "project_secrets_project_id_name_unique", + "nullsNotDistinct": false, + "columns": ["project_id", "name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_zero_trust": { + "name": "project_zero_trust", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "desired_protected": { + "name": "desired_protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "effective_protected": { + "name": "effective_protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ready'" + }, + "bypass_application_id": { + "name": "bypass_application_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bypass_application_name": { + "name": "bypass_application_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "protected_application_id": { + "name": "protected_application_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "protected_application_name": { + "name": "protected_application_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "protected_application_audience": { + "name": "protected_application_audience", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_cursor": { + "name": "routing_cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reconciled_operation": { + "name": "reconciled_operation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_project_zero_trust_status": { + "name": "idx_project_zero_trust_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_zero_trust_project_id_projects_id_fk": { + "name": "project_zero_trust_project_id_projects_id_fk", + "tableFrom": "project_zero_trust", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "project_zero_trust_status_check": { + "name": "project_zero_trust_status_check", + "value": "\"project_zero_trust\".\"status\" IN ('ready', 'reconciling', 'error')" + }, + "project_zero_trust_ready_check": { + "name": "project_zero_trust_ready_check", + "value": "\"project_zero_trust\".\"status\" <> 'ready' OR \"project_zero_trust\".\"desired_protected\" = \"project_zero_trust\".\"effective_protected\"" + } + }, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'void'" + }, + "active_deployment_id": { + "name": "active_deployment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "websocket_version": { + "name": "websocket_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_projects_slug": { + "name": "idx_projects_slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_projects_user_id": { + "name": "idx_projects_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_user_id_users_id_fk": { + "name": "projects_user_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "projects_user_id_slug_unique": { + "name": "projects_user_id_slug_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resources": { + "name": "resources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cf_id": { + "name": "cf_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_name": { + "name": "binding_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "resources_project_id_projects_id_fk": { + "name": "resources_project_id_projects_id_fk", + "tableFrom": "resources", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "resources_project_id_type_binding_name_unique": { + "name": "resources_project_id_type_binding_name_unique", + "nullsNotDistinct": false, + "columns": ["project_id", "type", "binding_name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandbox_drain_checkpoints": { + "name": "sandbox_drain_checkpoints", + "schema": "", + "columns": { + "singleton": { + "name": "singleton", + "type": "boolean", + "primaryKey": true, + "notNull": true + }, + "admission_protocol": { + "name": "admission_protocol", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "generation": { + "name": "generation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cursor": { + "name": "cursor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "login": { + "name": "login", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "billing_cycle_start": { + "name": "billing_cycle_start", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "suspended_reason": { + "name": "suspended_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_users_provider_id": { + "name": "idx_users_provider_id", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_email": { + "name": "idx_users_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_cache_outbox": { + "name": "email_cache_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_kind": { + "name": "resource_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_cache_outbox_version_uq": { + "name": "email_cache_outbox_version_uq", + "columns": [ + { + "expression": "resource_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_cache_outbox_pending_idx": { + "name": "email_cache_outbox_pending_idx", + "columns": [ + { + "expression": "published_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_credentials": { + "name": "email_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "managed_by": { + "name": "managed_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manager_user_id": { + "name": "manager_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "zone_id": { + "name": "zone_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "iv": { + "name": "iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_version": { + "name": "key_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_id": { + "name": "token_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_on": { + "name": "expires_on", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'healthy'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_credentials_scope_idx": { + "name": "email_credentials_scope_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "zone_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "email_credentials_material_check": { + "name": "email_credentials_material_check", + "value": "(\"email_credentials\".\"kind\" = 'platform' AND \"email_credentials\".\"ciphertext\" IS NULL AND \"email_credentials\".\"iv\" IS NULL AND \"email_credentials\".\"key_version\" IS NULL) OR (\"email_credentials\".\"kind\" = 'api_token' AND \"email_credentials\".\"ciphertext\" IS NOT NULL AND \"email_credentials\".\"iv\" IS NOT NULL AND \"email_credentials\".\"key_version\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.email_domain_routes": { + "name": "email_domain_routes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "addressing": { + "name": "addressing", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "managed_by": { + "name": "managed_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by_user_id": { + "name": "assigned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "desired_state": { + "name": "desired_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "inbound_status": { + "name": "inbound_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "inbound_reason": { + "name": "inbound_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbound_observed_at": { + "name": "inbound_observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "outbound_status": { + "name": "outbound_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "outbound_reason": { + "name": "outbound_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outbound_observed_at": { + "name": "outbound_observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "desired_generation": { + "name": "desired_generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_domain_routes_domain_uq": { + "name": "email_domain_routes_domain_uq", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_domain_routes_connection_idx": { + "name": "email_domain_routes_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_domain_routes_project_idx": { + "name": "email_domain_routes_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_domain_routes_connection_id_email_zone_connections_id_fk": { + "name": "email_domain_routes_connection_id_email_zone_connections_id_fk", + "tableFrom": "email_domain_routes", + "tableTo": "email_zone_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "email_domain_routes_assignment_check": { + "name": "email_domain_routes_assignment_check", + "value": "(\"email_domain_routes\".\"addressing\" = 'custom' AND \"email_domain_routes\".\"project_id\" IS NOT NULL) OR (\"email_domain_routes\".\"addressing\" = 'shared' AND \"email_domain_routes\".\"project_id\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.email_operation_steps": { + "name": "email_operation_steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ordinal": { + "name": "ordinal", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_identity": { + "name": "resource_identity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_digest": { + "name": "request_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "outcome": { + "name": "outcome", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_detail": { + "name": "failure_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "email_operation_steps_order_uq": { + "name": "email_operation_steps_order_uq", + "columns": [ + { + "expression": "operation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ordinal", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_operation_steps_operation_id_email_operations_id_fk": { + "name": "email_operation_steps_operation_id_email_operations_id_fk", + "tableFrom": "email_operation_steps", + "tableTo": "email_operations", + "columnsFrom": ["operation_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_operations": { + "name": "email_operations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "route_id": { + "name": "route_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "route_generation": { + "name": "route_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "actor_kind": { + "name": "actor_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_scope": { + "name": "actor_scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_digest": { + "name": "request_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "desired_generation": { + "name": "desired_generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "current_step": { + "name": "current_step", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_detail": { + "name": "failure_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_operations_key_uq": { + "name": "email_operations_key_uq", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_operations_work_idx": { + "name": "email_operations_work_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_operations_connection_idx": { + "name": "email_operations_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_operations_connection_id_email_zone_connections_id_fk": { + "name": "email_operations_connection_id_email_zone_connections_id_fk", + "tableFrom": "email_operations", + "tableTo": "email_zone_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "email_operations_route_id_email_domain_routes_id_fk": { + "name": "email_operations_route_id_email_domain_routes_id_fk", + "tableFrom": "email_operations", + "tableTo": "email_domain_routes", + "columnsFrom": ["route_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_shared_recipients": { + "name": "email_shared_recipients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "route_id": { + "name": "route_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient": { + "name": "recipient", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rule_name": { + "name": "rule_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rule_id": { + "name": "rule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "create_started_at": { + "name": "create_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "removing": { + "name": "removing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lease_started_at": { + "name": "lease_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_shared_recipients_project_address_uq": { + "name": "email_shared_recipients_project_address_uq", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recipient", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_shared_recipients_rule_name_uq": { + "name": "email_shared_recipients_rule_name_uq", + "columns": [ + { + "expression": "rule_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_shared_recipients_reconcile_idx": { + "name": "email_shared_recipients_reconcile_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_zone_connections": { + "name": "email_zone_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "zone_id": { + "name": "zone_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "zone_name": { + "name": "zone_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "managed_by": { + "name": "managed_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manager_user_id": { + "name": "manager_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ingress_worker_name": { + "name": "ingress_worker_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ingress_version": { + "name": "ingress_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "desired_generation": { + "name": "desired_generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "active_secret_generation": { + "name": "active_secret_generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "staged_secret_generation": { + "name": "staged_secret_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "secret_ciphertext": { + "name": "secret_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_iv": { + "name": "secret_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_key_version": { + "name": "secret_key_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "staged_secret_ciphertext": { + "name": "staged_secret_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "staged_secret_iv": { + "name": "staged_secret_iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "staged_secret_key_version": { + "name": "staged_secret_key_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_secret_hash": { + "name": "previous_secret_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_secret_until": { + "name": "previous_secret_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "inbound_status": { + "name": "inbound_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "inbound_reason": { + "name": "inbound_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbound_observed_at": { + "name": "inbound_observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "management_status": { + "name": "management_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'probe_failed'" + }, + "management_reason": { + "name": "management_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "management_observed_at": { + "name": "management_observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_zone_connections_zone_uq": { + "name": "email_zone_connections_zone_uq", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "zone_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_zone_connections_ingress_uq": { + "name": "email_zone_connections_ingress_uq", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ingress_worker_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_zone_connections_manager_idx": { + "name": "email_zone_connections_manager_idx", + "columns": [ + { + "expression": "manager_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "email_zone_connections_manager_check": { + "name": "email_zone_connections_manager_check", + "value": "(\"email_zone_connections\".\"managed_by\" = 'owner' AND \"email_zone_connections\".\"manager_user_id\" IS NOT NULL) OR (\"email_zone_connections\".\"managed_by\" = 'admin')" + } + }, + "isRLSEnabled": false + }, + "public.email_action_recipients": { + "name": "email_action_recipients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "action_id": { + "name": "action_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient": { + "name": "recipient", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_reference": { + "name": "provider_reference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reserved_at": { + "name": "reserved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempt_started_at": { + "name": "attempt_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_email_action_recipients_project_reserved": { + "name": "idx_email_action_recipients_project_reserved", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reserved_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_email_action_recipients_expiring": { + "name": "idx_email_action_recipients_expiring", + "columns": [ + { + "expression": "reserved_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"email_action_recipients\".\"state\" = 'reserved'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_action_recipients_action_id_email_actions_id_fk": { + "name": "email_action_recipients_action_id_email_actions_id_fk", + "tableFrom": "email_action_recipients", + "tableTo": "email_actions", + "columnsFrom": ["action_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_action_recipients_action_recipient_uq": { + "name": "email_action_recipients_action_recipient_uq", + "nullsNotDistinct": false, + "columns": ["action_id", "recipient"] + } + }, + "policies": {}, + "checkConstraints": { + "email_action_recipients_state_check": { + "name": "email_action_recipients_state_check", + "value": "\"email_action_recipients\".\"state\" IN ('rejected', 'reserved', 'attempt_started', 'provider_accepted', 'failed', 'outcome_unknown', 'cancelled')" + } + }, + "isRLSEnabled": false + }, + "public.email_actions": { + "name": "email_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_domain": { + "name": "sender_domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "receipt_id": { + "name": "receipt_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_digest": { + "name": "payload_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "policy_version": { + "name": "policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_email_actions_project_created": { + "name": "idx_email_actions_project_created", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_actions_receipt_id_email_receipts_id_fk": { + "name": "email_actions_receipt_id_email_receipts_id_fk", + "tableFrom": "email_actions", + "tableTo": "email_receipts", + "columnsFrom": ["receipt_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_actions_project_key_uq": { + "name": "email_actions_project_key_uq", + "nullsNotDistinct": false, + "columns": ["project_id", "idempotency_key"] + } + }, + "policies": {}, + "checkConstraints": { + "email_actions_kind_check": { + "name": "email_actions_kind_check", + "value": "\"email_actions\".\"kind\" IN ('direct', 'forward', 'reply')" + } + }, + "isRLSEnabled": false + }, + "public.email_monthly_usage": { + "name": "email_monthly_usage", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "month": { + "name": "month", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_attempts": { + "name": "recipient_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "inbound_receipts": { + "name": "inbound_receipts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_monthly_usage_project_month_uq": { + "name": "email_monthly_usage_project_month_uq", + "nullsNotDistinct": false, + "columns": ["project_id", "month"] + } + }, + "policies": {}, + "checkConstraints": { + "email_monthly_usage_nonnegative_check": { + "name": "email_monthly_usage_nonnegative_check", + "value": "\"email_monthly_usage\".\"recipient_attempts\" >= 0" + }, + "email_monthly_usage_inbound_nonnegative_check": { + "name": "email_monthly_usage_inbound_nonnegative_check", + "value": "\"email_monthly_usage\".\"inbound_receipts\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.email_project_controls": { + "name": "email_project_controls", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "monthly_limit": { + "name": "monthly_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 200 + }, + "burst_limit": { + "name": "burst_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": { + "email_project_controls_project_id_projects_id_fk": { + "name": "email_project_controls_project_id_projects_id_fk", + "tableFrom": "email_project_controls", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_receipts": { + "name": "email_receipts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "payload_digest": { + "name": "payload_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "final_reject": { + "name": "final_reject", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_email_receipts_project_created": { + "name": "idx_email_receipts_project_created", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_unresolved_attempts": { + "name": "email_unresolved_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_domain": { + "name": "sender_domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_email_unresolved_attempts_project": { + "name": "idx_email_unresolved_attempts_project", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_email_unresolved_attempts_domain": { + "name": "idx_email_unresolved_attempts_domain", + "columns": [ + { + "expression": "sender_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/platform/packages/api/drizzle/meta/_journal.json b/platform/packages/api/drizzle/meta/_journal.json index 841909ee9..d6d34adb0 100644 --- a/platform/packages/api/drizzle/meta/_journal.json +++ b/platform/packages/api/drizzle/meta/_journal.json @@ -351,6 +351,13 @@ "when": 1790875562555, "tag": "0056_email_shared_recipients", "breakpoints": true + }, + { + "idx": 57, + "version": "7", + "when": 1790912739624, + "tag": "0057_github_authority_constraints", + "breakpoints": true } ] } diff --git a/platform/packages/api/src/admin/react/views/ProjectOwnerPreviewPage.tsx b/platform/packages/api/src/admin/react/views/ProjectOwnerPreviewPage.tsx index f48e451e8..2b38ea0e9 100644 --- a/platform/packages/api/src/admin/react/views/ProjectOwnerPreviewPage.tsx +++ b/platform/packages/api/src/admin/react/views/ProjectOwnerPreviewPage.tsx @@ -124,7 +124,7 @@ export function ProjectOwnerPreviewPage({ {result.github ? (

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

) : (

No GitHub connection.

diff --git a/platform/packages/api/src/auth/deploy-token.ts b/platform/packages/api/src/auth/deploy-token.ts index 82aacb818..0bb0b5ef1 100644 --- a/platform/packages/api/src/auth/deploy-token.ts +++ b/platform/packages/api/src/auth/deploy-token.ts @@ -43,11 +43,12 @@ export function deployTokenTtlSecondsForBuildMinutes(timeoutMinutes: number): nu * rejects any attempt to deploy a different project with it. Used by remote * builds (CF Containers and GitHub Actions) running the real `void deploy` CLI. * - * When `buildId` is supplied the token also carries a `build` claim binding it - * to that specific build's row. The deploy endpoint rejects the token once the - * build is no longer active (e.g. superseded/cancelled by a newer push or run), - * so a stale runner can't deploy an older commit after being superseded. Omit - * it for an unbound deploy token (no build-state guard). + * The token also carries a `build` claim binding it to that specific build's + * row. The deploy endpoint rejects the token once the build is no longer + * active (e.g. superseded/cancelled by a newer push or run), so a stale runner + * can't deploy an older commit after being superseded. The auth middleware + * rejects any deploy token that has neither a `build` nor a `credential` + * claim. * * `ttlSeconds` lets a caller size the token's lifetime to the work it must * cover (e.g. a remote build whose token is minted *before* a multi-minute @@ -61,7 +62,7 @@ export async function signDeployToken( userId, login, buildId, - }: { projectId: string; userId: string; login: string; buildId?: string }, + }: { projectId: string; userId: string; login: string; buildId: string }, secret: string, opts?: { ttlSeconds?: number }, ): Promise { @@ -71,7 +72,7 @@ export async function signDeployToken( login, aud: 'deploy', project: projectId, - ...(buildId !== undefined ? { build: buildId } : {}), + build: buildId, }, secret, { ttlSeconds: opts?.ttlSeconds ?? DEPLOY_TOKEN_TTL_SECONDS }, diff --git a/platform/packages/api/src/auth/jwt.ts b/platform/packages/api/src/auth/jwt.ts index df70367f8..b10a2b2c2 100644 --- a/platform/packages/api/src/auth/jwt.ts +++ b/platform/packages/api/src/auth/jwt.ts @@ -15,7 +15,8 @@ export type JwtPayload = Readonly<{ * specific remote build (CF Container or GitHub Actions) — ties the token * to that build's row so the deploy endpoint can reject a stale runner once * its build is no longer active (e.g. superseded/cancelled by a newer push - * or run). Absent on full `aud:'api'` tokens and legacy deploy tokens. + * or run). Absent on full `aud:'api'` tokens and CI credential tokens. A + * deploy token must carry either this or `credential`. */ build?: string; /** Revocable project automation credential record. */ diff --git a/platform/packages/api/src/build-manager.ts b/platform/packages/api/src/build-manager.ts index 3ffc8bfb6..576cdead9 100644 --- a/platform/packages/api/src/build-manager.ts +++ b/platform/packages/api/src/build-manager.ts @@ -1522,21 +1522,17 @@ export class BuildManager extends Container { // the enqueue-time check is stale. A fresh plain (non-locking) Postgres // read here observes the committed revocation and fails closed before we // mint a live deploy token, rather than relying solely on the installation - // token mint failing once its ~55-min cache expires. A missing connection - // or repository id fails closed. Treat an + // token mint failing once its ~55-min cache expires. Treat an // uncovered build like the non-identical freshness branch: cancel (not a // build failure), don't mint or start. Nothing is started yet, so there is // no container to tear down. - const repositoryId = config.repositoryId; - const covered = - repositoryId !== null && - (await installationCoversRepo( - this.db(), - config.connectionId, - config.projectId, - config.installationId, - repositoryId, - )); + const covered = await installationCoversRepo( + this.db(), + config.connectionId, + config.projectId, + config.installationId, + config.repositoryId, + ); if (!covered) { const won = await markBuildTerminalIfActive(this.db(), config.buildId, 'cancelled', { error: 'github repository authorization is no longer valid; reconnect the repository', @@ -1716,7 +1712,7 @@ export class BuildManager extends Container { // caps that blast radius to this single repo instead of every repo the // install covers. const sourceToken = await getInstallationToken(this.env, config.installationId, { - repositoryIds: [repositoryId], + repositoryIds: [config.repositoryId], permissions: { contents: 'read' }, }); // The installation-token fetch is the longest pre-launch await and was the diff --git a/platform/packages/api/src/build-scheduler.ts b/platform/packages/api/src/build-scheduler.ts index 9a98473bf..9ef163481 100644 --- a/platform/packages/api/src/build-scheduler.ts +++ b/platform/packages/api/src/build-scheduler.ts @@ -174,11 +174,8 @@ export class BuildScheduler extends DurableObject { // github_actions) must not run here — the JIT freshness check in // BuildManager is branch-HEAD-only and does NOT catch a branch/executor // mismatch. A config change is not a build failure, so mark it `cancelled`. - // Guard `build.branch != null` so a null-branch legacy row is left alone. - if ( - build.branch != null && - (build.branch !== connection.branch || build.executor !== connection.build_executor) - ) { + // A row without a branch never matches, so it is cancelled the same way. + if (build.branch !== connection.branch || build.executor !== connection.build_executor) { await updateBuildStatus(db, build.id, 'cancelled', { error: 'github connection config changed', completedAt: new Date().toISOString(), @@ -235,7 +232,7 @@ export class BuildScheduler extends DurableObject { userId: build.user_id, login: user.login, commitSha: build.commit_sha ?? '', - branch: build.branch ?? connection.branch, + branch: build.branch, repoFullName: connection.repo_full_name, installationId: connection.installation_id, connectionId: connection.id, diff --git a/platform/packages/api/src/build-types.ts b/platform/packages/api/src/build-types.ts index 556b46a3d..7760644ea 100644 --- a/platform/packages/api/src/build-types.ts +++ b/platform/packages/api/src/build-types.ts @@ -28,11 +28,10 @@ export type BuildConfig = { /** * The connection's immutable numeric GitHub repository id (as text). Carried * so `BuildManager.startBuild` can re-verify the App installation still - * covers this repo at token-mint time; a missing id fails that check closed. - * Sourced from the github connection bound to this build (the build row - * itself does not store a repository id). + * covers this repo at token-mint time. Sourced from the github connection + * bound to this build (the build row itself does not store a repository id). */ - repositoryId: string | null; + repositoryId: string; instanceType: string; timeoutMinutes: number; }; diff --git a/platform/packages/api/src/db.ts b/platform/packages/api/src/db.ts index 4c5a5f257..20245c718 100644 --- a/platform/packages/api/src/db.ts +++ b/platform/packages/api/src/db.ts @@ -3276,7 +3276,7 @@ export async function deleteUser(db: Db, id: string): Promise { * FAILS CLOSED unless the exact connection still belongs to the project, has * durable authority, and its active installation still covers the repo. Every * connection records the project owner/admin who completed the repository proof; - * that authorizer must still be the owner or an admin. A connection without an - * authorizer (its user was deleted) has no authority. + * that authorizer must still be the owner or an admin. * An `all` install covers every repo; a `selected` install requires the immutable * repo id in its cache. This is the shared predicate used by webhook and OIDC * resolvers as well as enqueue and final startup checks. @@ -4309,13 +4308,8 @@ async function installationCoversRepoInTx( connectionId: string, projectId: string, installationId: string, - repositoryId: string | null, + repositoryId: string, ): Promise { - // Every connection records its immutable repository id; a row without one - // cannot prove coverage. - if (repositoryId === null) { - return false; - } const [row] = await tx .select({ id: githubConnections.id }) .from(githubConnections) @@ -4347,8 +4341,8 @@ async function installationCoversRepoInTx( * repo. Identical fail-closed predicate — returns false unless the * `github_installations` row exists AND `status === 'active'` AND the repo is * still in the install's selection (`repository_selection === 'all'`, or the - * immutable numeric repo id is in the cached `repositories` set). A missing - * connection id or repository id fails closed. + * immutable numeric repo id is in the cached `repositories` set). A stored + * build config without a connection id fails closed. * * Used by the container `BuildManager.startBuild` to re-verify coverage at token * mint, AFTER a queue wait during which an uninstall/suspension/repo-removal @@ -4362,9 +4356,9 @@ export async function installationCoversRepo( connectionId: string, projectId: string, installationId: string, - repositoryId: string | null, + repositoryId: string, ): Promise { - if (!connectionId || !repositoryId) { + if (!connectionId) { return false; } const [row] = await db @@ -4406,7 +4400,7 @@ export async function supersedeAndCreateContainerBuild( commitSha: string; connectionId: string; installationId: string; - repositoryId: string | null; + repositoryId: string; }, ): Promise<{ build: Build | null; @@ -4632,26 +4626,24 @@ export async function supersedeAndCreateContainerBuild( * the same `run_id` with an incremented `run_attempt`, so the `workflow_run` completion * handler must scope to the attempt: a redelivered/late completion for attempt * 1 must NOT resolve to attempt 2's active build (which would let it finalize - * the wrong attempt). When `githubRunAttempt` is null (an old delivery whose - * payload lacks `run_attempt`), it falls back to `IS NULL` so old null-attempt - * rows still match. Active builds are ordered first (then `created_at desc`), + * the wrong attempt). Active builds are ordered first (then `created_at desc`), * so the in-flight attempt wins on a tie. */ export async function getBuildByProjectRunIdAndAttempt( db: Db, projectId: string, githubRunId: string, - githubRunAttempt: string | null, + githubRunAttempt: string, ): Promise { - const attemptMatch = - githubRunAttempt != null - ? eq(builds.github_run_attempt, githubRunAttempt) - : isNull(builds.github_run_attempt); const [row] = await db .select() .from(builds) .where( - and(eq(builds.project_id, projectId), eq(builds.github_run_id, githubRunId), attemptMatch), + and( + eq(builds.project_id, projectId), + eq(builds.github_run_id, githubRunId), + eq(builds.github_run_attempt, githubRunAttempt), + ), ) .orderBy(sql`(${inArray(builds.status, ACTIVE_BUILD_STATUSES)}) desc`, desc(builds.created_at)) .limit(1); @@ -4857,11 +4849,11 @@ export async function createGithubActionsBuildWithinLimit( commitSha?: string | null; branch?: string | null; githubRunId: string; - githubRunAttempt?: string | null; + githubRunAttempt: string; logsUrl?: string | null; connectionId: string; installationId: string; - repositoryId: string | null; + repositoryId: string; // The authorized workflow path the OIDC gate matched (the token's own // workflow file). Re-checked under the connection lock so a concurrent // PATCH that re-pins `workflow_path` can't let a now-de-authorized workflow @@ -5002,13 +4994,7 @@ export async function createGithubActionsBuildWithinLimit( // Idempotent reuse: an already in-flight build for this exact run ATTEMPT // does not consume a new slot and must not be double-inserted. Matching on // the attempt (not run_id alone) ensures a RERUN — same run_id, incremented - // run_attempt — does NOT wrongly reuse the prior attempt's build. Falls back - // to IS NULL when the attempt is null (backward-compat for tokens that lack - // the claim). - const attemptMatch = - args.githubRunAttempt != null - ? eq(builds.github_run_attempt, args.githubRunAttempt) - : isNull(builds.github_run_attempt); + // run_attempt — does NOT wrongly reuse the prior attempt's build. const [existing] = await tx .select() .from(builds) @@ -5016,7 +5002,7 @@ export async function createGithubActionsBuildWithinLimit( and( eq(builds.project_id, args.projectId), eq(builds.github_run_id, args.githubRunId), - attemptMatch, + eq(builds.github_run_attempt, args.githubRunAttempt), ), ) .orderBy( @@ -5077,7 +5063,7 @@ export async function createGithubActionsBuildWithinLimit( commit_sha: args.commitSha ?? null, branch: args.branch ?? null, github_run_id: args.githubRunId, - github_run_attempt: args.githubRunAttempt ?? null, + github_run_attempt: args.githubRunAttempt, logs_url: args.logsUrl ?? null, }) .returning(); @@ -5500,8 +5486,9 @@ export async function upsertGitHubInstallation( .onConflictDoUpdate({ target: githubInstallations.installation_id, set: { - // Only personal (account_type='User') installs carry an owner; - // organization installs always pass a null user. Preserve an + // Only personal (account_type='User') installs carry an owner. A + // personal account converted into an organization arrives here with the + // organization type, so it drops its owner. Otherwise preserve an // already-linked user_id when a webhook upsert arrives with no user // (install-before-signin can be backfilled later). When a user is // provided, adopt it ONLY if the row is unowned or already owned by the @@ -5510,9 +5497,11 @@ export async function upsertGitHubInstallation( // would freeze repositories/status refreshes on the webhook re-upsert, // and `user_id = NULL` is never true.) user_id: - args.userId === undefined || args.userId === null - ? sql`${githubInstallations.user_id}` - : sql`CASE WHEN ${githubInstallations.user_id} IS NULL OR ${githubInstallations.user_id} = ${args.userId} THEN ${args.userId} ELSE ${githubInstallations.user_id} END`, + args.accountType === 'Organization' + ? null + : args.userId === undefined || args.userId === null + ? sql`${githubInstallations.user_id}` + : sql`CASE WHEN ${githubInstallations.user_id} IS NULL OR ${githubInstallations.user_id} = ${args.userId} THEN ${args.userId} ELSE ${githubInstallations.user_id} END`, account_login: args.accountLogin, account_id: args.accountId, account_type: args.accountType, @@ -5742,15 +5731,14 @@ export async function getGitHubInstallationsByAccount( * app clock, so a skewed worker cannot mint a longer- or shorter-lived * membership than intended. A re-join hits the unique `(installation_id, * user_id)` index and refreshes `github_account_id`/`verified_at`/`expires_at` - * in place (count stays 1). Tolerates a null `githubAccountId` (the - * backstop/JOIN callers may not carry a caller account id). + * in place (count stays 1). */ export async function upsertGitHubInstallationMember( db: Db, args: { installationId: string; userId: string; - githubAccountId: string | null; + githubAccountId: string; ttlSeconds: number; }, ): Promise { @@ -6012,17 +6000,14 @@ export async function getInstallationRevocationAt( * Batch revocation tombstones for `(installationIds, githubAccountId)` as a Map * installation_id → revoked_at-epoch-ms. Installs with no tombstone are simply * absent from the Map. Empty id list → empty Map (never emits `WHERE ... IN ()`). - * A null/empty `githubAccountId` (a null-github artifact — e.g. a join_code with - * no proven account id) also yields an empty Map: no tombstone can apply, and such - * an artifact is deploy-inert anyway. */ export async function getInstallationRevocationsForAccount( db: Db, installationIds: ReadonlyArray, - githubAccountId: string | null, + githubAccountId: string, ): Promise> { const result = new Map(); - if (installationIds.length === 0 || !githubAccountId) { + if (installationIds.length === 0) { return result; } const rows = await db diff --git a/platform/packages/api/src/env.ts b/platform/packages/api/src/env.ts index e9b83e0ca..332c02009 100644 --- a/platform/packages/api/src/env.ts +++ b/platform/packages/api/src/env.ts @@ -209,7 +209,7 @@ export type ApiEnv = Env & { /** * Build id a scoped deploy token is bound to. Set by `authMiddleware` for * `aud:'deploy'` tokens that carry a `build` claim; absent for full - * `aud:'api'` tokens and legacy deploy tokens. The deploy endpoint rejects + * `aud:'api'` tokens and CI credential tokens. The deploy endpoint rejects * the token once this build is no longer active (superseded/cancelled), so * a stale runner can't deploy an older commit after being superseded. */ diff --git a/platform/packages/api/src/github/oidc-verify.ts b/platform/packages/api/src/github/oidc-verify.ts index 818a162d3..5d3e0cb7b 100644 --- a/platform/packages/api/src/github/oidc-verify.ts +++ b/platform/packages/api/src/github/oidc-verify.ts @@ -25,12 +25,10 @@ export type GithubOidcClaims = Readonly<{ sha: string; run_id: string; // The workflow run's attempt number (as a string), incremented on each RERUN - // of the same `run_id`. GitHub mints it on the Actions OIDC token. Optional - // for backward-compat: a token lacking it must still authorize (the build is - // then created with a NULL attempt). The exchange persists it so the - // `workflow_run` completion handler can match a redelivered completion to the - // correct attempt's build. - run_attempt?: string; + // of the same `run_id`. GitHub mints it on every Actions OIDC token. The + // exchange persists it so the `workflow_run` completion handler can match a + // redelivered completion to the correct attempt's build. + run_attempt: string; workflow: string; // `workflow_ref` identifies the ENTRY workflow file for the run, in the form // `OWNER/REPO/.github/workflows/FILE@REF`. `job_workflow_ref` is the same for diff --git a/platform/packages/api/src/middleware/auth.ts b/platform/packages/api/src/middleware/auth.ts index 2fe75e0f9..332fb7e40 100644 --- a/platform/packages/api/src/middleware/auth.ts +++ b/platform/packages/api/src/middleware/auth.ts @@ -152,7 +152,10 @@ export const authMiddleware: MiddlewareHandler = async (context, next) = } if (payload.aud === 'deploy') { - if (!payload.project) { + // Every deploy token names its project and is bound to either one build + // (remote builds) or one revocable CI credential. A token with neither + // would skip both the build-state and the revocation checks below. + if (!payload.project || (!payload.build && !payload.credential)) { return context.json( { error: 'invalid or expired token', @@ -163,10 +166,9 @@ export const authMiddleware: MiddlewareHandler = async (context, next) = ); } context.set('deployScope', payload.project); - // Bind the token to its build (when present). The deploy endpoint rejects a - // token whose build is no longer active so a stale/superseded runner can't - // deploy. Undefined for legacy deploy tokens with no build claim — the - // guard only fires when this is set. + // Bind the token to its build. The deploy endpoint rejects a token whose + // build is no longer active so a stale/superseded runner can't deploy. + // Undefined for CI credential tokens, which are checked for revocation below. context.set('deployBuild', payload.build); context.set('deployTokenExpiresAt', payload.exp); if (payload.credential) { @@ -219,8 +221,8 @@ export const authMiddleware: MiddlewareHandler = async (context, next) = // revoke centrally here, before ANY allowlisted route runs. The /deploy // handler keeps its own up-front guard and final-transaction re-check (the // latter still matters for a mid-flight supersede during the long deploy); - // this gates the other allowlisted endpoints too. A token WITHOUT a `build` - // claim (legacy deploy tokens) is unaffected. + // this gates the other allowlisted endpoints too. CI credential tokens carry + // no `build` claim and were checked for revocation above. if (payload.build) { const boundBuild = await getBuild(context.get('db'), payload.build); if ( diff --git a/platform/packages/api/src/routes/auth-oidc.ts b/platform/packages/api/src/routes/auth-oidc.ts index b1420c939..61a9b695d 100644 --- a/platform/packages/api/src/routes/auth-oidc.ts +++ b/platform/packages/api/src/routes/auth-oidc.ts @@ -101,6 +101,9 @@ app.post('/github-oidc', async (context) => { if (typeof claims.repository_owner_id !== 'string' || claims.repository_owner_id === '') { return context.json({ error: 'oidc token missing repository_owner_id' }, 401); } + if (typeof claims.run_attempt !== 'string' || claims.run_attempt === '') { + return context.json({ error: 'oidc token missing run_attempt' }, 401); + } const workflowRef = typeof claims.job_workflow_ref === 'string' && claims.job_workflow_ref !== '' ? claims.job_workflow_ref @@ -265,8 +268,8 @@ app.post('/github-oidc', async (context) => { const githubRunId = String(claims.run_id); // A RERUN reuses the same run_id with an incremented run_attempt; persist it // so the workflow_run completion handler can finalize the RIGHT attempt's - // build. Null-tolerant: a token lacking the claim creates a NULL-attempt row. - const githubRunAttempt = claims.run_attempt != null ? String(claims.run_attempt) : null; + // build. + const githubRunAttempt = claims.run_attempt; const result = await createGithubActionsBuildWithinLimit(db, { projectId: connection.project_id, commitSha: claims.sha, diff --git a/platform/packages/api/src/routes/deploy.ts b/platform/packages/api/src/routes/deploy.ts index 94d8b1f68..676df3063 100644 --- a/platform/packages/api/src/routes/deploy.ts +++ b/platform/packages/api/src/routes/deploy.ts @@ -1226,8 +1226,8 @@ const SUPERSEDED_BUILD_ERROR_MESSAGE = 'bound build was superseded'; * rows as it cancels them), so a build that gets superseded mid-flight is * observed as terminal here and the publish is aborted. * - * No-op when the token carries no `build` claim (full-API tokens, legacy - * deploy tokens). Throws SUPERSEDED_BUILD_ERROR_MESSAGE on a non-active or + * No-op when the token carries no `build` claim (full-API tokens, CI + * credential tokens). Throws SUPERSEDED_BUILD_ERROR_MESSAGE on a non-active or * project-mismatched build, mirroring the up-front guard's reject semantics. */ async function assertBoundBuildStillActive( @@ -1894,7 +1894,7 @@ async function handleManifestOnlyDeploy(context: Context): Promise; - githubAccountId: string | null; + githubAccountId: string; codeChallenge: string; codeChallengeMethod: 'S256'; // The moment GitHub access was PROVEN (the public callback ran). Compared @@ -812,7 +812,13 @@ export const githubRoutes = new Hono() } const joinCode = await verifyLinkPayload(body.join_code, context.env.JWT_SECRET); - if (!joinCode || joinCode.purpose !== 'join' || !Array.isArray(joinCode.installationIds)) { + if ( + !joinCode || + joinCode.purpose !== 'join' || + !Array.isArray(joinCode.installationIds) || + typeof joinCode.githubAccountId !== 'string' || + joinCode.githubAccountId === '' + ) { return context.json({ error: 'invalid join_code' }, 400); } if ( @@ -856,9 +862,8 @@ export const githubRoutes = new Hono() // tombstones from the webhook payload even when no Void user existed yet. // A join_code minted AT OR BEFORE a revocation (iat <= revoked_at) is a // stale artifact a removed member is replaying — its installs are skipped - // below so membership is never resurrected. When `githubAccountId` is null - // the helper returns an empty Map (the membership would be deploy-inert - // anyway). Under the install-row lock above this is now race-safe, not + // below so membership is never resurrected. Under the install-row lock + // above this is now race-safe, not // merely best-effort; the connect route's in-tx recheck remains the deploy // gate. const revocations = await getInstallationRevocationsForAccount( @@ -875,9 +880,9 @@ export const githubRoutes = new Hono() continue; } // The signed join_code lists only Organization installs (the callback's - // eligibility gate), and account_type is immutable, so every install here - // is an organization install with no owner. The installer joins like any - // other member. + // eligibility gate), and GitHub never converts an organization back into a + // personal account, so every install here is an organization install with + // no owner. The installer joins like any other member. // // Skip installs whose join_code predates a revocation (stale replay). A // legit re-join AFTER re-add mints a fresh join_code with iat > revoked_at, @@ -1441,8 +1446,9 @@ export const githubProjectRoutes = new Hono() // Org-share connect is Organization-only. Memberships are only ever written for // organization installs; this gate keeps a personal install unreachable through // membership even if one existed, with the SAME opaque 404 (never reveal the - // install exists / its type to a non-member). account_type is immutable, so the - // pre-lock row is authoritative (no locked re-read). + // install exists / its type to a non-member). GitHub never converts an + // organization back into a personal account, so the pre-lock row is + // authoritative (no locked re-read). if (installation.account_type !== 'Organization') { return context.json({ error: 'installation not found' }, 404); } diff --git a/platform/packages/api/src/routes/webhooks.ts b/platform/packages/api/src/routes/webhooks.ts index d2d126b64..f1790e700 100644 --- a/platform/packages/api/src/routes/webhooks.ts +++ b/platform/packages/api/src/routes/webhooks.ts @@ -135,7 +135,7 @@ type WorkflowRunPayload = { repository?: { full_name?: string; id?: number | string }; workflow_run?: { id?: number | string; - run_attempt?: number | string; + run_attempt: number | string; conclusion?: string | null; head_sha?: string; }; @@ -520,7 +520,7 @@ async function rerunBuildForCommit( console.error('[webhooks] failed to poke build scheduler:', err); } - return context.json({ ok: true, buildId: buildIds[0], buildIds }); + return context.json({ ok: true, buildIds }); } /** Process a webhook whose caller has already verified its raw GitHub HMAC. */ @@ -814,7 +814,7 @@ export async function handleVerifiedGitHubWebhook( console.error('[webhooks] failed to poke build scheduler:', err); } - return context.json({ ok: true, buildId: buildIds[0], buildIds }); + return context.json({ ok: true, buildIds }); } case 'installation': { @@ -1074,7 +1074,7 @@ export async function handleVerifiedGitHubWebhook( instance_type: string | null; }> = []; for (const conn of connections) { - if (conn.repository_id === null || !removedRepositoryIds.has(conn.repository_id)) { + if (!removedRepositoryIds.has(conn.repository_id)) { continue; } acc.push(...(await terminateBuildsForProject(txDb, conn.project_id, { reason }))); @@ -1137,6 +1137,11 @@ export async function handleVerifiedGitHubWebhook( if (runId === undefined || runId === null) { return context.json({ ok: true, ignored: 'missing workflow_run id' }); } + // GitHub always sends run_attempt; a payload without it is malformed. + const runAttempt: unknown = data.workflow_run?.run_attempt; + if (typeof runAttempt !== 'number' && typeof runAttempt !== 'string') { + return context.json({ ok: true, ignored: 'missing workflow_run run_attempt' }); + } // A GitHub `run_id` is unique only WITHIN a repository, so the build must // be resolved by the payload's repository (mapped to a connected project) @@ -1167,10 +1172,8 @@ export async function handleVerifiedGitHubWebhook( // build carries this run id (a repo may back multiple projects). A RERUN // reuses the same run_id with an incremented run_attempt, so resolution is // scoped to the attempt: a redelivered/late completion for attempt 1 must - // NOT resolve to attempt 2's active build. Null-tolerant — an old delivery - // whose payload lacks run_attempt falls back to matching null-attempt rows. - const runAttempt = data.workflow_run?.run_attempt; - const runAttemptStr = runAttempt != null ? String(runAttempt) : null; + // NOT resolve to attempt 2's active build. + const runAttemptStr = String(runAttempt); // A single repo can back multiple github_actions projects (matrix / // multiple --project jobs sharing one run_id+attempt with distinct // project_ids). GitHub fires workflow_run:completed ONCE, so collect EVERY @@ -1316,9 +1319,9 @@ export async function handleVerifiedGitHubWebhook( if (buildIds.length === 0) { return context.json({ ok: true, ignored: 'build already terminal' }); } - // Return both the plural list (the multi-project fan-out contract, mirrors - // the push handler) and the singular alias for backward compatibility. - return context.json({ ok: true, buildId: buildIds[0], buildIds }); + // One completion can finalize builds for several projects (mirrors the + // push handler), so return every finalized build id. + return context.json({ ok: true, buildIds }); } case 'organization': { diff --git a/platform/packages/api/src/schema.ts b/platform/packages/api/src/schema.ts index 368de210b..1c95b1b4e 100644 --- a/platform/packages/api/src/schema.ts +++ b/platform/packages/api/src/schema.ts @@ -1200,16 +1200,16 @@ export const githubConnections = pgTable( // could match a DIFFERENT repository to this connection and deploy to the // wrong project. Webhook (`repository.id`) and OIDC (`claims.repository_id`) // resolution match on this immutable id, treating `repo_full_name` as - // display/fetch metadata. Every connection populates it at connect time; - // a row without one never matches a resolver (fail closed). - repository_id: text('repository_id'), + // display/fetch metadata. + repository_id: text('repository_id').notNull(), // The project owner or administrator who proved repository access during // connect. Runtime authorization accepts the row only while this still - // names the owner or a project administrator; a row without one has no - // authority. - authorized_by_user_id: text('authorized_by_user_id').references(() => users.id, { - onDelete: 'set null', - }), + // names the owner or a project administrator. Every path that removes that + // authority deletes the connection first, so deleting a user that still + // authorizes a connection fails instead of leaving it behind. + authorized_by_user_id: text('authorized_by_user_id') + .notNull() + .references(() => users.id), branch: text('branch').notNull().default('main'), build_executor: text('build_executor') .notNull() @@ -1247,8 +1247,9 @@ export const githubConnections = pgTable( // installation id as text with NO `.references()`, deliberately mirroring // `github_connections.installation_id` (every call site has the numeric id in // hand). `user_id` cascades on user delete (mirrors `oauth_accounts`). -// `github_account_id` is NULLABLE because the backstop/JOIN paths may not carry -// a caller account id. +// `github_account_id` is the GitHub account that proved membership. Every +// writer sets it. The column stays nullable for now: the schema-compat CI job +// runs the deployed runtime's tests, which still insert rows without it. // --------------------------------------------------------------------------- export const githubInstallationMembers = pgTable( 'github_installation_members', @@ -1399,11 +1400,10 @@ export const builds = pgTable( branch: text('branch'), deployment_id: text('deployment_id').references(() => deployments.id), github_run_id: text('github_run_id'), - // The GitHub Actions `run_attempt` (as text, nullable). A workflow RERUN - // reuses the same `run_id` with an incremented `run_attempt`, so the pair + // The GitHub Actions `run_attempt` (as text). A workflow RERUN reuses the + // same `run_id` with an incremented `run_attempt`, so the pair // `(github_run_id, github_run_attempt)` — not `run_id` alone — identifies a - // single attempt. Nullable for backward-compat: container builds and rows - // created before this column carry NULL. The `workflow_run` completion + // single attempt. NULL means a container build. The `workflow_run` completion // handler matches on the attempt so a redelivered/late completion from // attempt 1 cannot finalize attempt 2's active build. github_run_attempt: text('github_run_attempt'), @@ -1441,9 +1441,8 @@ export const builds = pgTable( // attempt is still rejected. A GitHub RERUN after the prior attempt reaches // a terminal status (`complete`/`failed`/`cancelled`) must be able to start // a fresh build, so terminal rows are intentionally NOT covered. Rows with a - // NULL `github_run_attempt` (container builds, or rows created before this - // column) are not covered by the partial WHERE — fine, this feature is not - // yet in production. + // NULL `github_run_attempt` are container builds and are not covered by the + // partial WHERE. uniqueIndex('idx_builds_project_github_run_id') .on(table.project_id, table.github_run_id, table.github_run_attempt) .where( diff --git a/platform/packages/api/test/integration/api-check-runs.test.ts b/platform/packages/api/test/integration/api-check-runs.test.ts index 47439f963..b92098252 100644 --- a/platform/packages/api/test/integration/api-check-runs.test.ts +++ b/platform/packages/api/test/integration/api-check-runs.test.ts @@ -156,9 +156,9 @@ vi.mock('../../src/db.ts', () => ({ connectionId: string, _projectId: string, _installationId: string, - repositoryId: string | null, + _repositoryId: string, ) => { - if (!connectionId || !repositoryId) { + if (!connectionId) { return false; } return dbState.installationCovered; diff --git a/platform/packages/api/test/integration/api-oidc.test.ts b/platform/packages/api/test/integration/api-oidc.test.ts index 60e86bca0..750984721 100644 --- a/platform/packages/api/test/integration/api-oidc.test.ts +++ b/platform/packages/api/test/integration/api-oidc.test.ts @@ -181,6 +181,7 @@ function makeClaims(overrides: Partial = {}): GithubOidcClaims ref, sha: 'sha-oidc', run_id: '987654', + run_attempt: '1', workflow: 'Deploy to Void', workflow_ref: scaffoldRef, job_workflow_ref: scaffoldRef, @@ -731,8 +732,7 @@ describe('POST /auth/github-oidc', () => { // Rerun-correctness — the exchange captures the OIDC `run_attempt` claim onto // the build row so the workflow_run completion handler can match a redelivered - // completion to the right attempt. A claim lacking it creates a NULL-attempt - // row (backward-compat). + // completion to the right attempt. A token without the claim is rejected. it('captures the github_run_attempt claim onto the created build', async () => { const user = await createTestUser(env.db, 'octo-attempt'); const project = await createTestProject(env.db, user.id, 'oidc-attempt'); @@ -757,7 +757,7 @@ describe('POST /auth/github-oidc', () => { expect(projectBuilds[0]!.github_run_attempt).toBe('3'); }); - it('creates a NULL github_run_attempt build when the claim lacks run_attempt', async () => { + it('returns 401 and creates no build when the run_attempt claim is missing', async () => { const user = await createTestUser(env.db, 'octo-no-attempt'); const project = await createTestProject(env.db, user.id, 'oidc-no-attempt'); await seedActiveInstallation(env.db, '1'); @@ -770,15 +770,16 @@ describe('POST /auth/github-oidc', () => { buildExecutor: 'github_actions', }); const app = makeApp(env); - // makeClaims default omits run_attempt. - setVerifyResult(makeClaims({ run_id: '626262' })); + setVerifyResult({ + ...makeClaims({ run_id: '626262' }), + run_attempt: undefined, + } as unknown as GithubOidcClaims); const res = await exchange(app, env, { oidcToken: 'good', project: project.slug }); - expect(res.status).toBe(200); - - const projectBuilds = await getProjectBuilds(env.db, project.id); - expect(projectBuilds.length).toBe(1); - expect(projectBuilds[0]!.github_run_attempt).toBeNull(); + expect(res.status).toBe(401); + const json = (await res.json()) as { error?: string }; + expect(json.error).toBe('oidc token missing run_attempt'); + expect((await getProjectBuilds(env.db, project.id)).length).toBe(0); }); // The minted deploy token is exchanged BEFORE the workflow runs @@ -1494,6 +1495,7 @@ describe('POST /auth/github-oidc', () => { createGithubActionsBuildWithinLimit(env.db, { projectId: project.id, githubRunId: '301', + githubRunAttempt: '1', connectionId: connection.id, installationId: '1', repositoryId: '42', @@ -1502,6 +1504,7 @@ describe('POST /auth/github-oidc', () => { createGithubActionsBuildWithinLimit(env.db, { projectId: project.id, githubRunId: '302', + githubRunAttempt: '1', connectionId: connection.id, installationId: '1', repositoryId: '42', diff --git a/platform/packages/api/test/integration/api-projects.test.ts b/platform/packages/api/test/integration/api-projects.test.ts index 7eae9f725..e42cec57e 100644 --- a/platform/packages/api/test/integration/api-projects.test.ts +++ b/platform/packages/api/test/integration/api-projects.test.ts @@ -348,8 +348,13 @@ describe('GET /projects/resolve', () => { const owner = await createTestUser(env.db, 'resolve-deploy-owner'); const assigned = await createTestProject(env.db, owner.id, 'resolve-assigned'); const sibling = await createTestProject(env.db, owner.id, 'resolve-sibling'); + const build = await createTestBuild(env.db, { + projectId: assigned.id, + userId: owner.id, + status: 'building', + }); const token = await signDeployToken( - { projectId: assigned.id, userId: owner.id, login: owner.login }, + { projectId: assigned.id, userId: owner.id, login: owner.login, buildId: build.id }, env.JWT_SECRET, ); @@ -403,8 +408,13 @@ describe('GET /projects/choices', () => { it('requires a full account credential', async () => { const owner = await createTestUser(env.db, 'choices-token-owner'); const project = await createTestProject(env.db, owner.id, 'choices-token-project'); + const build = await createTestBuild(env.db, { + projectId: project.id, + userId: owner.id, + status: 'building', + }); const token = await signDeployToken( - { projectId: project.id, userId: owner.id, login: owner.login }, + { projectId: project.id, userId: owner.id, login: owner.login, buildId: build.id }, env.JWT_SECRET, ); expect((await app.fetch(new Request('http://localhost/projects/choices'), env)).status).toBe( @@ -459,8 +469,13 @@ describe('GET /projects/:id', () => { const owner = await createTestUser(env.db, 'get-deploy-owner'); const assigned = await createTestProject(env.db, owner.id, 'get-deploy-assigned'); const sibling = await createTestProject(env.db, owner.id, 'get-deploy-sibling'); + const build = await createTestBuild(env.db, { + projectId: assigned.id, + userId: owner.id, + status: 'building', + }); const token = await signDeployToken( - { projectId: assigned.id, userId: owner.id, login: owner.login }, + { projectId: assigned.id, userId: owner.id, login: owner.login, buildId: build.id }, env.JWT_SECRET, ); const read = (id: string) => @@ -798,8 +813,13 @@ describe('POST /projects/:id/prerender-token', () => { it('allows the project-scoped deploy token to mint it', async () => { const user = await createTestUser(env.db, 'prerender-ci'); const project = await createTestProject(env.db, user.id, 'prerender-ci-app'); + const build = await createTestBuild(env.db, { + projectId: project.id, + userId: user.id, + status: 'building', + }); const deployToken = await signDeployToken( - { projectId: project.id, userId: user.id, login: user.login }, + { projectId: project.id, userId: user.id, login: user.login, buildId: build.id }, env.JWT_SECRET, ); diff --git a/platform/packages/api/test/integration/api-webhooks.test.ts b/platform/packages/api/test/integration/api-webhooks.test.ts index 6d385bcb9..206e73182 100644 --- a/platform/packages/api/test/integration/api-webhooks.test.ts +++ b/platform/packages/api/test/integration/api-webhooks.test.ts @@ -478,11 +478,11 @@ describe('POST /webhooks/github', () => { }); expect(res.status).toBe(200); - const json = (await res.json()) as { ok: boolean; buildId?: string }; + const json = (await res.json()) as { ok: boolean; buildIds: ReadonlyArray }; expect(json.ok).toBe(true); - expect(json.buildId).toBeTruthy(); + expect(json.buildIds).toHaveLength(1); - const build = await getBuild(env.db, json.buildId!); + const build = await getBuild(env.db, json.buildIds[0]); expect(build).not.toBeNull(); expect(build!.executor).toBe('container'); expect(build!.status).toBe('queued'); @@ -525,10 +525,10 @@ describe('POST /webhooks/github', () => { }); expect(res.status).toBe(200); - const json = (await res.json()) as { ok: boolean; buildId?: string }; - expect(json.buildId).toBeTruthy(); + const json = (await res.json()) as { ok: boolean; buildIds: ReadonlyArray }; + expect(json.buildIds).toHaveLength(1); - const build = await getBuild(env.db, json.buildId!); + const build = await getBuild(env.db, json.buildIds[0]); expect(build!.executor).toBe('container'); expect(build!.user_id).toBe(user.id); // pro plan → standard-4, stamped at enqueue. @@ -605,9 +605,9 @@ describe('POST /webhooks/github', () => { }); expect(res.status).toBe(200); - const json = (await res.json()) as { buildId?: string }; - expect(json.buildId).toBeTruthy(); - const build = await getBuild(env.db, json.buildId!); + const json = (await res.json()) as { buildIds: ReadonlyArray }; + expect(json.buildIds).toHaveLength(1); + const build = await getBuild(env.db, json.buildIds[0]); expect(build!.commit_sha).toBe('headsha1'); expect(build!.status).toBe('queued'); }); @@ -1225,6 +1225,7 @@ describe('POST /webhooks/github', () => { commitSha: 'sha-success', branch: 'main', githubRunId: '123456', + githubRunAttempt: '1', }); const { app, appEnv } = makeApp(env); @@ -1232,7 +1233,7 @@ describe('POST /webhooks/github', () => { action: 'completed', installation: { id: 321 }, repository: { full_name: 'octo/wfr', id: 855536 }, - workflow_run: { id: 123456, conclusion: 'success', head_sha: 'sha-success' }, + workflow_run: { id: 123456, run_attempt: 1, conclusion: 'success', head_sha: 'sha-success' }, }); expect(res.status).toBe(200); @@ -1262,6 +1263,7 @@ describe('POST /webhooks/github', () => { commitSha: 'sha-fail', branch: 'main', githubRunId: '654321', + githubRunAttempt: '1', }); const { app, appEnv } = makeApp(env); @@ -1269,7 +1271,7 @@ describe('POST /webhooks/github', () => { action: 'completed', installation: { id: 322 }, repository: { full_name: 'octo/wfr-fail', id: 871475 }, - workflow_run: { id: 654321, conclusion: 'failure', head_sha: 'sha-fail' }, + workflow_run: { id: 654321, run_attempt: 1, conclusion: 'failure', head_sha: 'sha-fail' }, }); expect(res.status).toBe(200); @@ -1278,6 +1280,44 @@ describe('POST /webhooks/github', () => { expect(updated!.deployment_id).toBeNull(); }); + it('ignores a workflow_run:completed without run_attempt (build unchanged)', async () => { + const user = await createTestUser(env.db); + const project = await createTestProject(env.db, user.id, 'wh-wfr-no-attempt'); + await createGitHubConnection(env.db, { + projectId: project.id, + installationId: '323', + repoFullName: 'octo/wfr-no-attempt', + repositoryId: '871476', + branch: 'main', + buildExecutor: 'github_actions', + }); + await seedInstallation(env.db, '323'); + const build = await createTestBuild(env.db, { + projectId: project.id, + userId: user.id, + executor: 'github_actions', + status: 'building', + commitSha: 'sha-no-attempt', + branch: 'main', + githubRunId: '654322', + githubRunAttempt: '1', + }); + + const { app, appEnv } = makeApp(env); + const res = await post(app, appEnv, 'workflow_run', { + action: 'completed', + installation: { id: 323 }, + repository: { full_name: 'octo/wfr-no-attempt', id: 871476 }, + workflow_run: { id: 654322, conclusion: 'success', head_sha: 'sha-no-attempt' }, + }); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true, ignored: 'missing workflow_run run_attempt' }); + const unchanged = await getBuild(env.db, build.id); + expect(unchanged!.status).toBe('building'); + expect(unchanged!.completed_at).toBeNull(); + }); + // github_actions builds have no BuildManager DO, so the "Void Build" check run // created at OIDC-exchange time is finalized HERE, on workflow_run:completed. // A successful run PATCHes the run to completed/success. @@ -1301,6 +1341,7 @@ describe('POST /webhooks/github', () => { commitSha: 'sha-check-ok', branch: 'main', githubRunId: '424250', + githubRunAttempt: '1', }); await setBuildCheckRunId(env.db, build.id, '5001'); @@ -1316,7 +1357,7 @@ describe('POST /webhooks/github', () => { action: 'completed', installation: { id: 331 }, repository: { full_name: 'octo/check-ok', id: 766742 }, - workflow_run: { id: 424250, conclusion: 'success', head_sha: 'sha-check-ok' }, + workflow_run: { id: 424250, run_attempt: 1, conclusion: 'success', head_sha: 'sha-check-ok' }, }); expect(res.status).toBe(200); @@ -1352,6 +1393,7 @@ describe('POST /webhooks/github', () => { commitSha: 'sha-check-fail', branch: 'main', githubRunId: '424251', + githubRunAttempt: '1', }); await setBuildCheckRunId(env.db, build.id, '5002'); @@ -1365,7 +1407,12 @@ describe('POST /webhooks/github', () => { action: 'completed', installation: { id: 332 }, repository: { full_name: 'octo/check-fail', id: 739072 }, - workflow_run: { id: 424251, conclusion: 'failure', head_sha: 'sha-check-fail' }, + workflow_run: { + id: 424251, + run_attempt: 1, + conclusion: 'failure', + head_sha: 'sha-check-fail', + }, }); expect(res.status).toBe(200); @@ -1400,6 +1447,7 @@ describe('POST /webhooks/github', () => { commitSha: 'sha-check-none', branch: 'main', githubRunId: '424252', + githubRunAttempt: '1', }); // No setBuildCheckRunId — check_run_id stays NULL. @@ -1413,7 +1461,12 @@ describe('POST /webhooks/github', () => { action: 'completed', installation: { id: 333 }, repository: { full_name: 'octo/check-none', id: 811598 }, - workflow_run: { id: 424252, conclusion: 'success', head_sha: 'sha-check-none' }, + workflow_run: { + id: 424252, + run_attempt: 1, + conclusion: 'success', + head_sha: 'sha-check-none', + }, }); expect(res.status).toBe(200); @@ -1443,6 +1496,7 @@ describe('POST /webhooks/github', () => { commitSha: 'sha-check-throw', branch: 'main', githubRunId: '424253', + githubRunAttempt: '1', }); await setBuildCheckRunId(env.db, build.id, '5003'); @@ -1458,7 +1512,12 @@ describe('POST /webhooks/github', () => { action: 'completed', installation: { id: 334 }, repository: { full_name: 'octo/check-throw', id: 870692 }, - workflow_run: { id: 424253, conclusion: 'success', head_sha: 'sha-check-throw' }, + workflow_run: { + id: 424253, + run_attempt: 1, + conclusion: 'success', + head_sha: 'sha-check-throw', + }, }); expect(res.status).toBe(200); @@ -1500,6 +1559,7 @@ describe('POST /webhooks/github', () => { commitSha: 'sha-check-terminal', branch: 'main', githubRunId: '424260', + githubRunAttempt: '1', }); await setBuildCheckRunId(env.db, build.id, '5010'); @@ -1513,7 +1573,12 @@ describe('POST /webhooks/github', () => { action: 'completed', installation: { id: 335 }, repository: { full_name: 'octo/check-terminal', id: 796140 }, - workflow_run: { id: 424260, conclusion: 'cancelled', head_sha: 'sha-check-terminal' }, + workflow_run: { + id: 424260, + run_attempt: 1, + conclusion: 'cancelled', + head_sha: 'sha-check-terminal', + }, }); expect(res.status).toBe(200); @@ -1555,6 +1620,7 @@ describe('POST /webhooks/github', () => { commitSha: 'sha-throw', branch: 'main', githubRunId: '777777', + githubRunAttempt: '1', }); // A transient Hyperdrive/Postgres failure on the deployment lookup must NOT @@ -1571,7 +1637,7 @@ describe('POST /webhooks/github', () => { action: 'completed', installation: { id: 323 }, repository: { full_name: 'octo/wfr-throw', id: 823577 }, - workflow_run: { id: 777777, conclusion: 'success', head_sha: 'sha-throw' }, + workflow_run: { id: 777777, run_attempt: 1, conclusion: 'success', head_sha: 'sha-throw' }, }); expect(res.status).toBe(200); @@ -1624,6 +1690,7 @@ describe('POST /webhooks/github', () => { commitSha: 'sha-x', branch: 'main', githubRunId: '500', + githubRunAttempt: '1', }); const buildY = await createTestBuild(env.db, { projectId: projectY.id, @@ -1633,6 +1700,7 @@ describe('POST /webhooks/github', () => { commitSha: 'sha-y', branch: 'main', githubRunId: '500', + githubRunAttempt: '1', }); const { app, appEnv } = makeApp(env); @@ -1641,7 +1709,7 @@ describe('POST /webhooks/github', () => { action: 'completed', installation: { id: 900 }, repository: { full_name: 'octo/x', id: 872525 }, - workflow_run: { id: 500, conclusion: 'failure', head_sha: 'sha-x' }, + workflow_run: { id: 500, run_attempt: 1, conclusion: 'failure', head_sha: 'sha-x' }, }); expect(res.status).toBe(200); @@ -1704,6 +1772,7 @@ describe('POST /webhooks/github', () => { commitSha: 'sha-multi', branch: 'main', githubRunId: '960', + githubRunAttempt: '1', }); const buildB = await createTestBuild(env.db, { projectId: projectB.id, @@ -1713,6 +1782,7 @@ describe('POST /webhooks/github', () => { commitSha: 'sha-multi', branch: 'main', githubRunId: '960', + githubRunAttempt: '1', }); const { app, appEnv } = makeApp(env); @@ -1720,7 +1790,7 @@ describe('POST /webhooks/github', () => { action: 'completed', installation: { id: 960 }, repository: { full_name: 'octo/multi', id: 960960 }, - workflow_run: { id: 960, conclusion: 'success', head_sha: 'sha-multi' }, + workflow_run: { id: 960, run_attempt: 1, conclusion: 'success', head_sha: 'sha-multi' }, }); expect(res.status).toBe(200); @@ -1789,9 +1859,9 @@ describe('POST /webhooks/github', () => { }); expect(res.status).toBe(200); - const body = (await res.json()) as { ok: boolean; buildId: string }; + const body = (await res.json()) as { ok: boolean; buildIds: ReadonlyArray }; // The webhook resolved and finalized the active rerun build. - expect(body.buildId).toBe(rerun.id); + expect(body.buildIds).toEqual([rerun.id]); const updatedRerun = await getBuild(env.db, rerun.id); expect(updatedRerun!.status).toBe('complete'); expect(updatedRerun!.completed_at).not.toBeNull(); @@ -1878,6 +1948,7 @@ describe('POST /webhooks/github', () => { commitSha: 'sha-z', branch: 'main', githubRunId: '600', + githubRunAttempt: '1', }); const { app, appEnv } = makeApp(env); @@ -1885,7 +1956,7 @@ describe('POST /webhooks/github', () => { action: 'completed', installation: { id: 999 }, repository: { full_name: 'octo/unknown', id: 881839 }, - workflow_run: { id: 600, conclusion: 'success', head_sha: 'sha-z' }, + workflow_run: { id: 600, run_attempt: 1, conclusion: 'success', head_sha: 'sha-z' }, }); expect(res.status).toBe(200); @@ -1965,9 +2036,9 @@ describe('POST /webhooks/github', () => { }); expect(res.status).toBe(200); - const json = (await res.json()) as { buildId?: string }; - expect(json.buildId).toBeTruthy(); - const build = await getBuild(env.db, json.buildId!); + const json = (await res.json()) as { buildIds: ReadonlyArray }; + expect(json.buildIds).toHaveLength(1); + const build = await getBuild(env.db, json.buildIds[0]); expect(build!.project_id).toBe(project.id); }); @@ -2011,8 +2082,8 @@ describe('POST /webhooks/github', () => { }); expect(res.status).toBe(200); - const json = (await res.json()) as { buildId?: string }; - expect(json.buildId).toBeTruthy(); + const json = (await res.json()) as { buildIds: ReadonlyArray }; + expect(json.buildIds).toHaveLength(1); // The stored name was healed to the new full_name. const healed = await getGitHubConnection(env.db, project.id); @@ -2111,7 +2182,7 @@ describe('POST /webhooks/github', () => { }); expect(res.status).toBe(200); - const json = (await res.json()) as { buildId?: string; buildIds?: ReadonlyArray }; + const json = (await res.json()) as { buildIds?: ReadonlyArray }; expect(json.buildIds).toHaveLength(1); const rows = await env.db.select().from(builds); @@ -2189,6 +2260,7 @@ describe('POST /webhooks/github', () => { commitSha: 'sha-x', branch: 'main', githubRunId: '700', + githubRunAttempt: '1', }); const { app, appEnv } = makeApp(env); @@ -2197,7 +2269,7 @@ describe('POST /webhooks/github', () => { installation: { id: 612 }, // Same name, DIFFERENT immutable id => resolves no connection. repository: { full_name: 'octo/repo', id: 999 }, - workflow_run: { id: 700, conclusion: 'success', head_sha: 'sha-x' }, + workflow_run: { id: 700, run_attempt: 1, conclusion: 'success', head_sha: 'sha-x' }, }); expect(res.status).toBe(200); @@ -2322,9 +2394,9 @@ describe('POST /webhooks/github', () => { }); expect(res.status).toBe(200); - const json = (await res.json()) as { buildId?: string }; - expect(json.buildId).toBeTruthy(); - const build = await getBuild(env.db, json.buildId!); + const json = (await res.json()) as { buildIds: ReadonlyArray }; + expect(json.buildIds).toHaveLength(1); + const build = await getBuild(env.db, json.buildIds[0]); expect(build!.status).toBe('queued'); expect(build!.commit_sha).toBe('abc1234'); }); @@ -2381,7 +2453,7 @@ describe('POST /webhooks/github — push idempotency + latest-wins', () => { app: Hono, appEnv: TestEnv, after: string, - ): Promise<{ buildId: string; buildIds: ReadonlyArray }> { + ): Promise<{ buildId: string }> { const res = await post(app, appEnv, 'push', { ref: 'refs/heads/main', after, @@ -2389,7 +2461,10 @@ describe('POST /webhooks/github — push idempotency + latest-wins', () => { repository: { full_name: 'octo/idem', id: 70 }, }); expect(res.status).toBe(200); - return (await res.json()) as { buildId: string; buildIds: ReadonlyArray }; + const body = (await res.json()) as { buildIds: ReadonlyArray }; + // Each push here creates or reuses exactly one build. + expect(body.buildIds).toHaveLength(1); + return { buildId: body.buildIds[0] }; } it('newer push supersedes the older queued build; redelivery dedups; running IS cancelled', async () => { @@ -2473,9 +2548,10 @@ describe('POST /webhooks/github — push idempotency + latest-wins', () => { const second = await postThroughIngressOperation(app, appEnv, 'push', payload); expect(first.status).toBe(200); expect(second.status).toBe(200); - const firstBody = await first.json<{ buildId: string }>(); - const secondBody = await second.json<{ buildId: string }>(); - expect(secondBody.buildId).toBe(firstBody.buildId); + const firstBody = await first.json<{ buildIds: ReadonlyArray }>(); + const secondBody = await second.json<{ buildIds: ReadonlyArray }>(); + expect(firstBody.buildIds).toHaveLength(1); + expect(secondBody.buildIds).toEqual(firstBody.buildIds); expect( (await env.db.select().from(builds)).filter((build) => build.project_id === project.id), ).toHaveLength(1); @@ -2668,8 +2744,6 @@ describe('POST /webhooks/github — push idempotency + latest-wins', () => { const { app, appEnv } = pokeApp(); const res = await pushCommit(app, appEnv, 'zzz'); - expect(res.buildId).toBeTruthy(); - expect(res.buildIds).toHaveLength(1); const build = await getBuild(env.db, res.buildId); expect(build!.status).toBe('queued'); expect(build!.commit_sha).toBe('zzz'); @@ -3366,9 +3440,9 @@ describe('POST /webhooks/github — supersede stale container builds on push', ( }); expect(res.status).toBe(200); - const json = (await res.json()) as { ok: boolean; buildId?: string }; + const json = (await res.json()) as { ok: boolean; buildIds: ReadonlyArray }; expect(json.ok).toBe(true); - expect(json.buildId).toBeTruthy(); + expect(json.buildIds).toHaveLength(1); // The older active build is cancelled (token revoked + slot freed) and its // DO was torn down. const updated = await getBuild(env.db, stale.id); @@ -3376,7 +3450,7 @@ describe('POST /webhooks/github — supersede stale container builds on push', ( expect(updated!.error).toBe('superseded by newer push'); expect(cancelledIds).toContain(stale.id); // A new queued build for c2 was created. - const fresh = await getBuild(env.db, json.buildId!); + const fresh = await getBuild(env.db, json.buildIds[0]); expect(fresh!.status).toBe('queued'); expect(fresh!.commit_sha).toBe('c2'); }); @@ -3412,10 +3486,10 @@ describe('POST /webhooks/github — supersede stale container builds on push', ( }); expect(res.status).toBe(200); - const json = (await res.json()) as { buildId?: string }; + const json = (await res.json()) as { buildIds: ReadonlyArray }; // Same-commit in-flight build is REUSED (dedup), not cancelled, and no DO // teardown happened. - expect(json.buildId).toBe(fresh.id); + expect(json.buildIds).toEqual([fresh.id]); expect((await getBuild(env.db, fresh.id))!.status).toBe('building'); expect(cancelledIds).not.toContain(fresh.id); }); @@ -3486,9 +3560,9 @@ describe('POST /webhooks/github — supersede stale container builds on push', ( }); expect(res.status).toBe(200); - const json = (await res.json()) as { buildId?: string }; + const json = (await res.json()) as { buildIds: ReadonlyArray }; // The same-commit building build is reused, not self-cancelled. - expect(json.buildId).toBe(building.id); + expect(json.buildIds).toEqual([building.id]); expect((await getBuild(env.db, building.id))!.status).toBe('building'); expect(cancelledIds).not.toContain(building.id); // No extra build row was created. @@ -3728,6 +3802,7 @@ describe('POST /webhooks/github — CAS workflow_run completion', () => { commitSha: 'sha-cas', branch: 'main', githubRunId: '87001', + githubRunAttempt: '1', }); const { app, appEnv } = makeApp(env); @@ -3735,7 +3810,7 @@ describe('POST /webhooks/github — CAS workflow_run completion', () => { action: 'completed', installation: { id: 8201 }, repository: { full_name: 'octo/cas', id: 791148 }, - workflow_run: { id: 87001, conclusion: 'success', head_sha: 'sha-cas' }, + workflow_run: { id: 87001, run_attempt: 1, conclusion: 'success', head_sha: 'sha-cas' }, }); expect(res.status).toBe(200); @@ -4874,11 +4949,11 @@ describe('POST /webhooks/github — rerun from the check run', () => { const res = await post(app, appEnv, 'check_run', checkRunPayload({ externalId: failed.id })); expect(res.status).toBe(200); - const json = (await res.json()) as { buildId?: string }; - expect(json.buildId).toBeTruthy(); + const json = (await res.json()) as { buildIds: ReadonlyArray }; + expect(json.buildIds).toHaveLength(1); // A NEW row — a failed build is never reused. - expect(json.buildId).not.toBe(failed.id); - const build = await getBuild(env.db, json.buildId!); + expect(json.buildIds[0]).not.toBe(failed.id); + const build = await getBuild(env.db, json.buildIds[0]); expect(build!.project_id).toBe(project.id); expect(build!.commit_sha).toBe('cafe1234'); expect(build!.status).toBe('queued'); @@ -5021,9 +5096,9 @@ describe('POST /webhooks/github — rerun from the check run', () => { check_suite: { head_sha: 'cafe1234', head_branch: 'main' }, }); - const json = (await res.json()) as { buildId?: string }; - expect(json.buildId).toBeTruthy(); - const build = await getBuild(env.db, json.buildId!); + const json = (await res.json()) as { buildIds: ReadonlyArray }; + expect(json.buildIds).toHaveLength(1); + const build = await getBuild(env.db, json.buildIds[0]); expect(build!.project_id).toBe(project.id); }); @@ -5061,8 +5136,8 @@ describe('POST /webhooks/github — rerun from the check run', () => { const res = await post(app, appEnv, 'check_run', checkRunPayload({ externalId: clicked.id })); - const json = (await res.json()) as { buildId?: string }; - expect(json.buildId).toBeTruthy(); + const json = (await res.json()) as { buildIds: ReadonlyArray }; + expect(json.buildIds).toHaveLength(1); const connection = await getGitHubConnection(env.db, project.id); expect(connection!.repo_full_name).toBe('octo/site'); }); diff --git a/platform/packages/api/test/integration/db-builds.test.ts b/platform/packages/api/test/integration/db-builds.test.ts index 0ec2ed122..628b635a8 100644 --- a/platform/packages/api/test/integration/db-builds.test.ts +++ b/platform/packages/api/test/integration/db-builds.test.ts @@ -1419,35 +1419,6 @@ describe('builds', () => { expect(result.build!.commit_sha).toBe('aaa'); }); - // A missing repository id can't prove coverage, even on an `all`-repos install. - it('supersedeAndCreateContainerBuild skips a missing repo id even on an ALL install (fails closed)', async () => { - const user = await createTestUser(env.db); - const project = await createTestProject(env.db, user.id); - await createGitHubConnection(env.db, { - projectId: project.id, - installationId: '12345', - repoFullName: 'acme/app', - repositoryId: DEFAULT_REPO_ID, - branch: 'main', - buildExecutor: 'container', - }); - // Default `all`-repos install (membership not enforced). - await seedActiveInstall(env.db, '12345'); - - const result = await supersedeAndCreateContainerBuild(env.db, { - projectId: project.id, - connectionId: (await getGitHubConnection(env.db, project.id))!.id, - branch: 'main', - commitSha: 'aaa', - installationId: '12345', - repositoryId: null, - }); - expect(result.build).toBeNull(); - expect(result.skipped).toBe('install-revoked'); - expect(result.cancelled).toEqual([]); - expect(await getProjectBuilds(env.db, project.id)).toEqual([]); - }); - // The enqueue tx must assert the LOCKED connection is the SAME row the // caller resolved. `github_connections.project_id` is UNIQUE, so a // disconnect+reconnect to a DIFFERENT repo (same branch/executor, and crucially @@ -2010,6 +1981,7 @@ describe('createGithubActionsBuildWithinLimit', () => { connectionId: (await getGitHubConnection(env.db, project.id))!.id, branch: 'main', githubRunId: 'suspended-owner-1', + githubRunAttempt: '1', installationId: 'actions-suspended-owner', repositoryId: DEFAULT_REPO_ID, expectedWorkflowPath: '.github/workflows/void-deploy.yml', @@ -2078,6 +2050,7 @@ describe('createGithubActionsBuildWithinLimit', () => { connectionId: (await getGitHubConnection(env.db, project.id))!.id, branch: 'main', githubRunId: 'owner-race-1', + githubRunAttempt: '1', installationId: 'actions-owner-race', repositoryId: DEFAULT_REPO_ID, expectedWorkflowPath: '.github/workflows/void-deploy.yml', @@ -2120,6 +2093,7 @@ describe('createGithubActionsBuildWithinLimit', () => { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, githubRunId: '9001', + githubRunAttempt: '1', installationId: 'inst-wf', repositoryId: DEFAULT_REPO_ID, expectedWorkflowPath: '.github/workflows/ci.yml', @@ -2144,6 +2118,7 @@ describe('createGithubActionsBuildWithinLimit', () => { connectionId: (await getGitHubConnection(env.db, project.id))!.id, branch: 'main', githubRunId: '1001', + githubRunAttempt: '1', installationId: 'inst-supersede', repositoryId: DEFAULT_REPO_ID, expectedWorkflowPath: '.github/workflows/void-deploy.yml', @@ -2159,6 +2134,7 @@ describe('createGithubActionsBuildWithinLimit', () => { connectionId: (await getGitHubConnection(env.db, project.id))!.id, branch: 'main', githubRunId: '1002', + githubRunAttempt: '1', installationId: 'inst-supersede', repositoryId: DEFAULT_REPO_ID, expectedWorkflowPath: '.github/workflows/void-deploy.yml', @@ -2194,6 +2170,7 @@ describe('createGithubActionsBuildWithinLimit', () => { connectionId: (await getGitHubConnection(env.db, project.id))!.id, branch: 'main', githubRunId: '2001', + githubRunAttempt: '1', installationId: 'inst-reuse', repositoryId: DEFAULT_REPO_ID, expectedWorkflowPath: '.github/workflows/void-deploy.yml', @@ -2208,6 +2185,7 @@ describe('createGithubActionsBuildWithinLimit', () => { connectionId: (await getGitHubConnection(env.db, project.id))!.id, branch: 'main', githubRunId: '2001', + githubRunAttempt: '1', installationId: 'inst-reuse', repositoryId: DEFAULT_REPO_ID, expectedWorkflowPath: '.github/workflows/void-deploy.yml', @@ -2254,6 +2232,7 @@ describe('createGithubActionsBuildWithinLimit', () => { connectionId: (await getGitHubConnection(env.db, project.id))!.id, branch: 'main', githubRunId: '3002', + githubRunAttempt: '1', installationId: 'inst-branch', repositoryId: DEFAULT_REPO_ID, expectedWorkflowPath: '.github/workflows/void-deploy.yml', @@ -2282,6 +2261,7 @@ describe('createGithubActionsBuildWithinLimit', () => { connectionId: (await getGitHubConnection(env.db, project.id))!.id, branch: 'main', githubRunId: '4001', + githubRunAttempt: '1', installationId: 'inst-cap', repositoryId: DEFAULT_REPO_ID, expectedWorkflowPath: '.github/workflows/void-deploy.yml', @@ -2298,6 +2278,7 @@ describe('createGithubActionsBuildWithinLimit', () => { connectionId: (await getGitHubConnection(env.db, project.id))!.id, branch: 'main', githubRunId: '4002', + githubRunAttempt: '1', installationId: 'inst-cap', repositoryId: DEFAULT_REPO_ID, expectedWorkflowPath: '.github/workflows/void-deploy.yml', @@ -2326,6 +2307,7 @@ describe('createGithubActionsBuildWithinLimit', () => { // already occupies its single slot. branch: 'main', githubRunId: '4003', + githubRunAttempt: '1', installationId: 'inst-cap-other', repositoryId: DEFAULT_REPO_ID, expectedWorkflowPath: '.github/workflows/void-deploy.yml', @@ -2348,6 +2330,7 @@ describe('createGithubActionsBuildWithinLimit', () => { connectionId: 'ghc_absent', branch: 'main', githubRunId: '5001', + githubRunAttempt: '1', installationId: 'none', repositoryId: DEFAULT_REPO_ID, expectedWorkflowPath: '.github/workflows/void-deploy.yml', @@ -2379,6 +2362,7 @@ describe('createGithubActionsBuildWithinLimit', () => { connectionId: (await getGitHubConnection(env.db, project.id))!.id, branch: 'main', githubRunId: '5002', + githubRunAttempt: '1', installationId: 'inst-container', repositoryId: DEFAULT_REPO_ID, expectedWorkflowPath: '.github/workflows/void-deploy.yml', @@ -2411,6 +2395,7 @@ describe('createGithubActionsBuildWithinLimit', () => { connectionId: (await getGitHubConnection(env.db, project.id))!.id, branch: 'feature', githubRunId: '7001', + githubRunAttempt: '1', installationId: 'inst-branch-mismatch', repositoryId: DEFAULT_REPO_ID, expectedWorkflowPath: '.github/workflows/void-deploy.yml', @@ -2440,6 +2425,7 @@ describe('createGithubActionsBuildWithinLimit', () => { projectId: project.id, connectionId: (await getGitHubConnection(env.db, project.id))!.id, githubRunId: '7002', + githubRunAttempt: '1', installationId: 'inst-no-branch', repositoryId: DEFAULT_REPO_ID, expectedWorkflowPath: '.github/workflows/void-deploy.yml', @@ -2598,6 +2584,7 @@ describe('createGithubActionsBuildWithinLimit', () => { connectionId: (await getGitHubConnection(env.db, project.id))!.id, branch: 'main', githubRunId: '8001', + githubRunAttempt: '1', installationId: 'inst-revoked-susp', repositoryId: DEFAULT_REPO_ID, expectedWorkflowPath: '.github/workflows/void-deploy.yml', @@ -2624,6 +2611,7 @@ describe('createGithubActionsBuildWithinLimit', () => { connectionId: (await getGitHubConnection(env.db, project.id))!.id, branch: 'main', githubRunId: '8002', + githubRunAttempt: '1', installationId: 'inst-revoked-del', repositoryId: DEFAULT_REPO_ID, expectedWorkflowPath: '.github/workflows/void-deploy.yml', @@ -2655,6 +2643,7 @@ describe('createGithubActionsBuildWithinLimit', () => { connectionId: (await getGitHubConnection(env.db, project.id))!.id, branch: 'main', githubRunId: '8003', + githubRunAttempt: '1', installationId: 'inst-revoked-sel', repositoryId: DEFAULT_REPO_ID, expectedWorkflowPath: '.github/workflows/void-deploy.yml', @@ -2686,6 +2675,7 @@ describe('createGithubActionsBuildWithinLimit', () => { connectionId: (await getGitHubConnection(env.db, project.id))!.id, branch: 'main', githubRunId: '8004', + githubRunAttempt: '1', installationId: 'inst-revoked-ok', repositoryId: DEFAULT_REPO_ID, expectedWorkflowPath: '.github/workflows/void-deploy.yml', @@ -2735,6 +2725,7 @@ describe('createGithubActionsBuildWithinLimit', () => { connectionId: cOld.id, branch: 'main', githubRunId: '9001', + githubRunAttempt: '1', installationId: 'inst-replaced', repositoryId: DEFAULT_REPO_ID, expectedWorkflowPath: '.github/workflows/void-deploy.yml', @@ -2748,6 +2739,7 @@ describe('createGithubActionsBuildWithinLimit', () => { connectionId: cNew.id, branch: 'main', githubRunId: '9001', + githubRunAttempt: '1', installationId: 'inst-replaced', repositoryId: DEFAULT_REPO_ID, expectedWorkflowPath: '.github/workflows/void-deploy.yml', @@ -2758,12 +2750,11 @@ describe('createGithubActionsBuildWithinLimit', () => { }); describe('getBuildByProjectRunIdAndAttempt', () => { - it('matches a build by run_attempt and falls back to IS NULL', async () => { + it('matches a build by run_id and run_attempt', async () => { const user = await createTestUser(env.db); const project = await createTestProject(env.db, user.id); - // Attempt 1 (terminal) and attempt 2 (active) for the same run_id, plus an - // old null-attempt row. + // Attempt 1 (terminal) and attempt 2 (active) for the same run_id. const a1 = await createTestBuild(env.db, { projectId: project.id, userId: user.id, @@ -2780,13 +2771,6 @@ describe('getBuildByProjectRunIdAndAttempt', () => { githubRunId: '7001', githubRunAttempt: '2', }); - const aNull = await createTestBuild(env.db, { - projectId: project.id, - userId: user.id, - executor: 'github_actions', - status: 'building', - githubRunId: '7002', - }); // Exact attempt match. expect((await getBuildByProjectRunIdAndAttempt(env.db, project.id, '7001', '1'))!.id).toBe( @@ -2797,12 +2781,8 @@ describe('getBuildByProjectRunIdAndAttempt', () => { ); // A non-matching attempt resolves nothing. expect(await getBuildByProjectRunIdAndAttempt(env.db, project.id, '7001', '3')).toBeNull(); - // Null fallback matches the null-attempt row only. - expect((await getBuildByProjectRunIdAndAttempt(env.db, project.id, '7002', null))!.id).toBe( - aNull.id, - ); - // Null fallback does NOT match the attempt-tagged rows. - expect(await getBuildByProjectRunIdAndAttempt(env.db, project.id, '7001', null)).toBeNull(); + // A different run_id resolves nothing. + expect(await getBuildByProjectRunIdAndAttempt(env.db, project.id, '7002', '1')).toBeNull(); }); }); @@ -2994,7 +2974,7 @@ describe('github_connections', () => { // The build manager re-checks coverage from stored build state, which may lack // either id; both fail closed even on an `all`-repos install. - it('installationCoversRepo fails closed without a connection or repository id', async () => { + it('installationCoversRepo fails closed without a connection id', async () => { const user = await createTestUser(env.db); const project = await createTestProject(env.db, user.id); await seedActiveInstall(env.db, '12345'); @@ -3015,7 +2995,6 @@ describe('github_connections', () => { '42', ), ).toBe(false); - expect(await installationCoversRepo(env.db, conn.id, project.id, '12345', null)).toBe(false); }); }); @@ -3165,6 +3144,31 @@ describe('github_installations', () => { expect(adopted.user_id).toBe(userB.id); }); + // GitHub can convert a personal account into an organization; later payloads + // report the same installation with the organization type. + it('upsert drops the owner when a personal installation becomes an organization', async () => { + const owner = await createTestUser(env.db, 'converted'); + await upsertGitHubInstallation(env.db, { + installationId: '559', + userId: owner.id, + accountLogin: 'acme', + accountId: 'acc_z', + accountType: 'User', + repositories: [], + }); + + const converted = await upsertGitHubInstallation(env.db, { + installationId: '559', + userId: null, + accountLogin: 'acme', + accountId: 'acc_z', + accountType: 'Organization', + repositories: [], + }); + expect(converted.account_type).toBe('Organization'); + expect(converted.user_id).toBeNull(); + }); + it('getGitHubInstallation + getGitHubInstallationsByUser read by key/user', async () => { const user = await createTestUser(env.db); await upsertGitHubInstallation(env.db, { diff --git a/platform/packages/api/test/integration/db-github-members.test.ts b/platform/packages/api/test/integration/db-github-members.test.ts index 5eb081d39..e3750e918 100644 --- a/platform/packages/api/test/integration/db-github-members.test.ts +++ b/platform/packages/api/test/integration/db-github-members.test.ts @@ -115,20 +115,6 @@ describe('github_installation_members', () => { expect(all).toHaveLength(1); }); - it('upsert accepts a null github_account_id', async () => { - const user = await createTestUser(env.db); - await seedInstall(env.db, '100'); - - const row = await upsertGitHubInstallationMember(env.db, { - installationId: '100', - userId: user.id, - githubAccountId: null, - ttlSeconds: MEMBERSHIP_TTL_SECONDS, - }); - - expect(row.github_account_id).toBeNull(); - }); - it('hasActiveInstallationMembership: true when fresh, false when absent, false when expired', async () => { const user = await createTestUser(env.db); await seedInstall(env.db, '100'); @@ -151,7 +137,7 @@ describe('github_installation_members', () => { id: generateId('ghm'), installation_id: '200', user_id: user.id, - github_account_id: null, + github_account_id: 'gh-member', expires_at: sql`now() - interval '1 second'`, }); expect(await hasActiveInstallationMembership(env.db, '200', user.id)).toBe(false); @@ -166,19 +152,19 @@ describe('github_installation_members', () => { await upsertGitHubInstallationMember(env.db, { installationId: '100', userId: alice.id, - githubAccountId: null, + githubAccountId: 'gh-member', ttlSeconds: MEMBERSHIP_TTL_SECONDS, }); await upsertGitHubInstallationMember(env.db, { installationId: '100', userId: bob.id, - githubAccountId: null, + githubAccountId: 'gh-member', ttlSeconds: MEMBERSHIP_TTL_SECONDS, }); await upsertGitHubInstallationMember(env.db, { installationId: '200', userId: alice.id, - githubAccountId: null, + githubAccountId: 'gh-member', ttlSeconds: MEMBERSHIP_TTL_SECONDS, }); @@ -199,7 +185,7 @@ describe('github_installation_members', () => { await upsertGitHubInstallationMember(env.db, { installationId: inst, userId: alice.id, - githubAccountId: null, + githubAccountId: 'gh-member', ttlSeconds: MEMBERSHIP_TTL_SECONDS, }); } @@ -207,7 +193,7 @@ describe('github_installation_members', () => { await upsertGitHubInstallationMember(env.db, { installationId: '100', userId: bob.id, - githubAccountId: null, + githubAccountId: 'gh-member', ttlSeconds: MEMBERSHIP_TTL_SECONDS, }); @@ -240,7 +226,7 @@ describe('github_installation_members', () => { await upsertGitHubInstallationMember(env.db, { installationId: '100', userId: user.id, - githubAccountId: null, + githubAccountId: 'gh-member', ttlSeconds: MEMBERSHIP_TTL_SECONDS, }); @@ -251,13 +237,13 @@ describe('github_installation_members', () => { id: generateId('ghm'), installation_id: '100', user_id: user.id, - github_account_id: null, + github_account_id: 'gh-member', expires_at: sql`now() + interval '1 hour'`, }), ).rejects.toThrow(); }); - it('deleteGitHubInstallationMembersByGithubAccount: deletes X-keyed rows for any bearer, spares Z-keyed + NULL + out-of-scope + empty ids', async () => { + it('deleteGitHubInstallationMembersByGithubAccount: deletes X-keyed rows for any bearer, spares Z-keyed + out-of-scope + empty ids', async () => { const alice = await createTestUser(env.db, 'alice'); const bob = await createTestUser(env.db, 'bob', 'google', 'google-bob'); const carol = await createTestUser(env.db, 'carol', 'google', 'google-carol'); @@ -294,12 +280,12 @@ describe('github_installation_members', () => { githubAccountId: 'Z', ttlSeconds: MEMBERSHIP_TTL_SECONDS, }); - // Alice also holds a NULL-proof membership under 300 — not attributable to X, - // must SURVIVE. + // Bob also holds a Y-keyed membership under 300 — out of scope and not + // X-keyed, must SURVIVE. await upsertGitHubInstallationMember(env.db, { installationId: '300', userId: bob.id, - githubAccountId: null, + githubAccountId: 'Y', ttlSeconds: MEMBERSHIP_TTL_SECONDS, }); @@ -318,7 +304,7 @@ describe('github_installation_members', () => { expect(m100[0]!.github_account_id).toBe('Z'); // Install 200: alice's X-keyed row is gone. expect(await getTestGitHubInstallationMembers(env.db, '200')).toHaveLength(0); - // Install 300: bob's NULL-proof row was out of scope AND not X-keyed → survives. + // Install 300: bob's Y-keyed row was out of scope AND not X-keyed → survives. const m300 = await getTestGitHubInstallationMembers(env.db, '300'); expect(m300).toHaveLength(1); expect(m300[0]!.user_id).toBe(bob.id); @@ -525,16 +511,6 @@ describe('github_installation_revocations — monotonic revocation tombstone', ( expect((await getInstallationRevocationsForAccount(env.db, [], X)).size).toBe(0); }); - it('getInstallationRevocationsForAccount returns an empty Map for a null/empty github account id', async () => { - await seedInstall(env.db, '100'); - // A tombstone exists for X, but a null-account artifact (e.g. a join_code with - // no proven account id) can never match — the helper short-circuits to empty. - await recordInstallationRevocations(env.db, ['100'], X); - - expect((await getInstallationRevocationsForAccount(env.db, ['100'], null)).size).toBe(0); - expect((await getInstallationRevocationsForAccount(env.db, ['100'], '')).size).toBe(0); - }); - it('a raw duplicate insert with a new id raises a unique violation', async () => { await seedInstall(env.db, '100'); diff --git a/platform/packages/api/test/integration/deploy-token-scope.test.ts b/platform/packages/api/test/integration/deploy-token-scope.test.ts index a4e3ad50b..109577922 100644 --- a/platform/packages/api/test/integration/deploy-token-scope.test.ts +++ b/platform/packages/api/test/integration/deploy-token-scope.test.ts @@ -10,6 +10,7 @@ import { vi, } from 'vite-plus/test'; import { signDeployToken } from '../../src/auth/deploy-token.ts'; +import { signJwt } from '../../src/auth/jwt.ts'; import { createDeployment } from '../../src/db.ts'; import * as db from '../../src/db.ts'; import app from '../../src/index.ts'; @@ -54,13 +55,25 @@ afterEach(() => { // every other owner-scoped /projects/* route (delete, secrets PUT, github // connect, ...) and any cross-project use. describe('deploy token scope enforcement', () => { + // Without an explicit build, bind the token to a fresh active build of the + // scoped project, as remote builds do. async function deployToken( projectId: string, userId: string, login = 'octocat', buildId?: string, ): Promise { - return signDeployToken({ projectId, userId, login, buildId }, env.JWT_SECRET); + const boundBuildId = + buildId ?? + ( + await createTestBuild(env.db, { + projectId, + userId, + executor: 'github_actions', + status: 'building', + }) + ).id; + return signDeployToken({ projectId, userId, login, buildId: boundBuildId }, env.JWT_SECRET); } it('lets the real deploy endpoint through (auth passes; not 403)', async () => { @@ -436,22 +449,41 @@ describe('deploy token scope enforcement', () => { expect(res.status).not.toBe(403); }); - it('lets a deploy token with NO build claim through (backward compat)', async () => { + // A deploy token must be bound to a build or a CI credential. One with + // neither would skip both the build-state and the revocation checks. + it('rejects a deploy token with neither a build nor a credential claim', async () => { const user = await createTestUser(env.db); const project = await createTestProject(env.db, user.id, 'build-unbound'); - // No buildId → no `build` claim → the build-state guard is skipped. - const token = await deployToken(project.id, user.id); + const token = await signJwt( + { sub: user.id, login: user.login, aud: 'deploy', project: project.id }, + env.JWT_SECRET, + { ttlSeconds: 15 * 60 }, + ); mockCfApi(); - const res = await app.fetch( + const requests = [ new Request(`http://localhost/projects/${project.id}/deploy`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: '{}', }), - env, - ); - expect(res.status).not.toBe(403); + new Request(`http://localhost/projects/${project.id}`, { + headers: { Authorization: `Bearer ${token}` }, + }), + ]; + for (const request of requests) { + const res = await app.fetch(request, env); + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ + error: 'invalid or expired token', + message: 'This deploy credential is invalid. Ask the project owner to issue a replacement.', + }); + } + const [deployment] = await env.db + .select({ id: deployments.id }) + .from(deployments) + .where(eq(deployments.project_id, project.id)); + expect(deployment).toBeUndefined(); }); // The build-state guard lives in authMiddleware, so it gates EVERY allowlisted diff --git a/platform/packages/api/test/integration/github-join-routes.test.ts b/platform/packages/api/test/integration/github-join-routes.test.ts index 7f5b5df1e..fc37a7d40 100644 --- a/platform/packages/api/test/integration/github-join-routes.test.ts +++ b/platform/packages/api/test/integration/github-join-routes.test.ts @@ -169,7 +169,7 @@ async function mintJoinState(payload: { // be exercised without racing the DB clock. async function mintJoinCode(payload: { installationIds: Array; - githubAccountId?: string | null; + githubAccountId?: string; codeChallenge: string; iat: number; exp?: number; @@ -1002,6 +1002,39 @@ describe('POST /github/join/complete', () => { expect(await countMemberRows(env.db, '700', joiner.id)).toBe(0); }); + // A membership must record the GitHub account that proved access. + it.each([null, ''])('rejects a join_code with GitHub account id %j', async (githubAccountId) => { + const joiner = await createTestUser(env.db, 'joiner', 'google', 'google-joiner'); + await seedInstall({ installationId: '700' }); + + const challenge = await createCodeChallenge(CODE_VERIFIER); + const encoded = btoa( + JSON.stringify({ + purpose: 'join', + installationIds: ['700'], + githubAccountId, + codeChallenge: challenge, + codeChallengeMethod: 'S256', + iat: Date.now(), + exp: Date.now() + 5 * 60 * 1000, + }), + ); + const signature = await hmacSignState(encoded, JWT_SECRET); + const joinCode = `${encoded}.${signature}`; + + const res = await app.fetch( + new Request( + 'http://localhost/github/join/complete', + authedPost(joiner.jwt, { join_code: joinCode, code_verifier: CODE_VERIFIER }), + ), + { ...env, ...GITHUB_APP_USER_OAUTH_ENV }, + ); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'invalid join_code' }); + expect(await countMemberRows(env.db, '700', joiner.id)).toBe(0); + }); + // F5 (Task 18): the tombstone-read + upsert loop now runs inside a transaction // that locks the target install rows install-first. That serializes a concurrent // member_removed (not deterministically reproducible under PGlite's serialized diff --git a/platform/packages/api/test/integration/operator-operations.test.ts b/platform/packages/api/test/integration/operator-operations.test.ts index dbae66709..1b3474416 100644 --- a/platform/packages/api/test/integration/operator-operations.test.ts +++ b/platform/packages/api/test/integration/operator-operations.test.ts @@ -1,5 +1,5 @@ import { PLATFORM_APPLICATION_KEY, PLATFORM_SANDBOX_DRAIN_KEY } from '@void/platform'; -import { eq, sql } from 'drizzle-orm'; +import { asc, eq, sql } from 'drizzle-orm'; import { afterAll, afterEach, @@ -1076,7 +1076,12 @@ describe('operator installation-aware system and worker operations', () => { state: 'complete', }); expect(await env.ROUTING.get(PLATFORM_SANDBOX_DRAIN_KEY)).toBeNull(); - const events = await env.db.select().from(operatorEvents); + // Each audit row is inserted, then updated with its outcome, so its physical + // position does not follow request order. + const events = await env.db + .select() + .from(operatorEvents) + .orderBy(asc(operatorEvents.created_at), asc(operatorEvents.id)); expect(events).toHaveLength(1 + resumedPages.length); expect(events.at(-1)).toEqual( expect.objectContaining({ diff --git a/platform/packages/api/test/integration/platform-access-protection.test.ts b/platform/packages/api/test/integration/platform-access-protection.test.ts index 3d3f64c24..24fbd7dca 100644 --- a/platform/packages/api/test/integration/platform-access-protection.test.ts +++ b/platform/packages/api/test/integration/platform-access-protection.test.ts @@ -21,6 +21,7 @@ import { app } from '../../src/index.ts'; import { platformAuthSettings, users } from '../../src/schema.ts'; import { clearTables, + createTestBuild, createTestProject, createTestUser, setupEnv, @@ -333,8 +334,13 @@ describe('Access-protected platform requests', () => { expect((await request('/auth/login', access)).status).toBe(403); expect((await request('/projects', access)).status).toBe(403); const project = await createTestProject(env.db, user.id, 'ci-project'); + const build = await createTestBuild(env.db, { + projectId: project.id, + userId: user.id, + status: 'building', + }); const deploy = await signJwt( - { sub: user.id, login: user.login, aud: 'deploy', project: project.id }, + { sub: user.id, login: user.login, aud: 'deploy', project: project.id, build: build.id }, env.JWT_SECRET, ); expect((await request('/projects', access, deploy)).status).toBe(200); diff --git a/platform/packages/api/test/integration/platform-bootstrap.test.ts b/platform/packages/api/test/integration/platform-bootstrap.test.ts index 6e1d61b44..ab7621373 100644 --- a/platform/packages/api/test/integration/platform-bootstrap.test.ts +++ b/platform/packages/api/test/integration/platform-bootstrap.test.ts @@ -40,6 +40,7 @@ import { } from '../../src/schema.ts'; import { clearTables, + createTestBuild, createTestProject, createTestUser, setupEnv, @@ -361,12 +362,18 @@ describe('provider-independent first administrator setup', () => { env.JWT_SECRET, ); const project = await createTestProject(env.db, administrator.id, 'recovery-scoped-token'); + const build = await createTestBuild(env.db, { + projectId: project.id, + userId: administrator.id, + status: 'building', + }); const scopedDeployToken = await signJwt( { sub: administrator.id, login: administrator.login, aud: 'deploy', project: project.id, + build: build.id, }, env.JWT_SECRET, ); diff --git a/platform/packages/api/test/integration/platform-human-sessions.test.ts b/platform/packages/api/test/integration/platform-human-sessions.test.ts index 72153fc0b..b9f30eff8 100644 --- a/platform/packages/api/test/integration/platform-human-sessions.test.ts +++ b/platform/packages/api/test/integration/platform-human-sessions.test.ts @@ -8,7 +8,7 @@ import { revokeConnectionSessions, validateHumanSession, } from '../../src/auth/sessions.ts'; -import type { Db } from '../../src/db.ts'; +import { type Db } from '../../src/db.ts'; import { app } from '../../src/index.ts'; import { platformAuthSettings, @@ -17,6 +17,7 @@ import { } from '../../src/schema.ts'; import { clearTables, + createTestBuild, createTestProject, createTestUser, mockCfApi, @@ -107,8 +108,13 @@ describe('platform human session revocation', () => { mockCfApi(); await env.db.update(platformAuthSettings).set({ sessions_required: true }); const project = await createTestProject(env.db, admin.id, 'project'); + const build = await createTestBuild(env.db, { + projectId: project.id, + userId: admin.id, + status: 'building', + }); const deploy = await signJwt( - { sub: admin.id, login: admin.login, aud: 'deploy', project: project.id }, + { sub: admin.id, login: admin.login, aud: 'deploy', project: project.id, build: build.id }, env.JWT_SECRET, ); await revokeConnectionSessions(env.db, 'github'); diff --git a/platform/packages/api/test/integration/setup.ts b/platform/packages/api/test/integration/setup.ts index bc940ca1e..e64901317 100644 --- a/platform/packages/api/test/integration/setup.ts +++ b/platform/packages/api/test/integration/setup.ts @@ -1,4 +1,43 @@ -import { vi } from 'vite-plus/test'; +import { afterEach, vi } from 'vite-plus/test'; + +// A JSON deploy keeps writing to the database after its route returns, until +// its NDJSON body is read (routes/deploy.ts runs that pipeline detached). A test +// that checks only the status would leave the deploy running into the next +// test, where its project-then-deployment row locks deadlock with +// clearTables' deployment-then-project deletes on PostgreSQL. Finish every +// unread deploy stream before the next test's setup runs. +const unreadDeployStreams = new Set(); +const NativeResponse = globalThis.Response; +globalThis.Response = class TrackedResponse extends NativeResponse { + constructor(body?: BodyInit | null, init?: ResponseInit) { + super(body, init); + if ( + body instanceof ReadableStream && + this.headers.get('content-type')?.includes('application/x-ndjson') + ) { + unreadDeployStreams.add(this); + } + } +} as typeof Response; + +afterEach(async () => { + const pending = [...unreadDeployStreams]; + unreadDeployStreams.clear(); + await Promise.all( + pending.map(async (response) => { + if (response.bodyUsed || !response.body || response.body.locked) { + return; + } + // A receipt stream only polls the database; cancelling stops it. + if (response.headers.has('x-void-deployment-id')) { + await response.body.cancel().catch(() => {}); + return; + } + // The inline pipeline closes its stream after its last database write. + await response.text().catch(() => {}); + }), + ); +}); // Polyfill the Cloudflare-Workers-only `FixedLengthStream` global for Node, // so code paths that rely on it (e.g. `streamStaticAssetToR2` in assets.ts) diff --git a/platform/packages/api/test/integration/unread-deploy-cleanup.test.ts b/platform/packages/api/test/integration/unread-deploy-cleanup.test.ts new file mode 100644 index 000000000..80793c49a --- /dev/null +++ b/platform/packages/api/test/integration/unread-deploy-cleanup.test.ts @@ -0,0 +1,115 @@ +import { sql } from 'drizzle-orm'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vite-plus/test'; +import app from '../../src/index.ts'; +import { deployments } from '../../src/schema.ts'; +import { + clearTables, + createTestProject, + createTestUser, + mockCfApi, + setupEnv, + teardownEnv, + type TestEnv, +} from './helpers.ts'; +import { setGetProjectOverride } from './setup.ts'; + +// A JSON deploy keeps running after its response is returned until the NDJSON +// body is read. A test that only checks the status leaves that work running +// into the next test. On PostgreSQL, the deploy locks the project row and then +// the deployment row, while clearTables deletes deployments before projects: +// opposite orders, so the two transactions deadlock. PGlite has one connection, +// so only a real PostgreSQL database can show this. +const postgres = it.runIf(Boolean(process.env.VOID_TEST_DATABASE_URL)); +const HASH = (n: number) => n.toString(16).padStart(32, '0'); + +let env: TestEnv; +beforeAll(async () => { + env = await setupEnv(); +}); +afterAll(async () => { + setGetProjectOverride(null); + await teardownEnv(); +}); +beforeEach(async () => { + await clearTables(env.db); +}); + +/** Resolves once another backend of this database waits on a lock, or after `limitMs`. */ +async function lockWaitOrTimeout(limitMs: number): Promise { + const deadline = Date.now() + limitMs; + while (Date.now() < deadline) { + const { rows } = (await env.db.execute( + sql`select count(*)::int as n from pg_stat_activity + where datname = current_database() and wait_event_type = 'Lock'`, + )) as unknown as { rows: Array<{ n: number }> }; + if (rows[0]!.n > 0) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +describe('fixture cleanup after an unread deploy stream', () => { + postgres('leaves a JSON deploy unread while it holds the project row lock', async () => { + const user = await createTestUser(env.db); + const project = await createTestProject(env.db, user.id, 'unread-deploy'); + mockCfApi(); + const bytes = new TextEncoder().encode('

hi

'); + await env.STATIC_ASSETS_R2.put(`assets/${project.id}/${HASH(1)}`, bytes.buffer as ArrayBuffer); + + let reached!: () => void; + const locked = new Promise((resolve) => (reached = resolve)); + let release!: () => void; + const gate = new Promise((resolve) => (release = resolve)); + setGetProjectOverride(async ({ original, argsList }) => { + const [db, id] = argsList; + if (db !== env.db) { + // A saved placeholder row means this is the manifest transaction, which + // already holds `SELECT ... FROM projects ... FOR UPDATE`. + const { rows } = (await env.db.execute( + sql`select count(*)::int as n from deployments where project_id = ${id}`, + )) as unknown as { rows: Array<{ n: number }> }; + if (rows[0]!.n > 0) { + setGetProjectOverride(null); + reached(); + await gate; + } + } + return original(...argsList); + }); + + const res = await app.fetch( + new Request(`http://localhost/projects/${project.id}/deploy`, { + method: 'POST', + headers: { Authorization: `Bearer ${user.jwt}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + manifest: { + version: 3, + assetManifestVersion: 2, + bindings: {}, + type: 'spa', + assetConfig: { not_found_handling: 'single-page-application' }, + }, + assetManifest: { + '/index.html': { blake3: HASH(1), md5: HASH(2), size: bytes.byteLength }, + }, + source: null, + commit: null, + preflight: null, + }), + }), + env, + ); + expect(res.status).toBe(200); + await locked; + // Let the deploy continue once the next cleanup waits on its lock, or after + // a short bound when the harness finishes the deploy before cleanup. + void lockWaitOrTimeout(1_000).then(release); + // Return without reading `res`, as a status-only test does. + }); + + postgres('starts the next test only after that deploy finished', async () => { + // Without the harness drain, beforeEach(clearTables) fails here with 40P01. + expect(await env.db.select().from(deployments)).toEqual([]); + }); +}); diff --git a/platform/packages/api/test/unit/admin-upgrade-compatibility.test.ts b/platform/packages/api/test/unit/admin-upgrade-compatibility.test.ts index d83cf5dec..4dc455b29 100644 --- a/platform/packages/api/test/unit/admin-upgrade-compatibility.test.ts +++ b/platform/packages/api/test/unit/admin-upgrade-compatibility.test.ts @@ -54,67 +54,16 @@ it('normalizes worker project modes without changing deployment pointers and ret } }, 30_000); -it('preserves the 0038 database schema used by the running 0.10.14 platform', async () => { - const root = resolve(import.meta.dirname, '../../drizzle'); - const journal = JSON.parse(readFileSync(resolve(root, 'meta/_journal.json'), 'utf8')) as { - entries: Array<{ tag: string; idx: number; when: number }>; - }; - const compatibility = JSON.parse(readFileSync(resolve(root, 'compatibility.json'), 'utf8')) as { - lineage: string; - rollbackSafeFrom: Array<{ platformVersion: string; schemaDigest: string }>; - }; - const baseline = journal.entries.filter((entry) => entry.idx <= 38); - expect(baseline.at(-1)?.tag).toBe('0038_deployment_admission_protocol'); - const baselineDigest = platformDatabaseSchemaDigest( - compatibility.lineage, - baseline.map((entry) => ({ - index: entry.idx, - tag: entry.tag, - timestampMillis: entry.when, - sha256: createHash('sha256') - .update(readFileSync(resolve(root, `${entry.tag}.sql`))) - .digest('hex'), - })), - ); - expect(baselineDigest).toBe('680a0af38a079d371ee8caa019a2cb6351855361b28d1041017c12fdf2abac46'); - expect(compatibility.rollbackSafeFrom).toContainEqual({ - platformVersion: '0.10.14', - schemaDigest: baselineDigest, - }); - - const db = new PGlite(); - try { - for (const entry of baseline) { - await db.exec(readFileSync(resolve(root, `${entry.tag}.sql`), 'utf8')); - } - const columns = async () => - ( - await db.query<{ - table_name: string; - column_name: string; - data_type: string; - is_nullable: string; - column_default: string | null; - }>( - "select table_name, column_name, data_type, is_nullable, column_default from information_schema.columns where table_schema = 'public' order by table_name, ordinal_position", - ) - ).rows; - const before = await columns(); - for (const entry of journal.entries.slice(baseline.length)) { - await db.exec(readFileSync(resolve(root, `${entry.tag}.sql`), 'utf8')); - } - const after = await columns(); - for (const column of before) { - expect(after).toContainEqual( - column.table_name === 'projects' && column.column_name === 'mode' - ? { ...column, column_default: "'void'::text" } - : column, - ); - } - } finally { - await db.close(); - } -}, 30_000); +// 0.10.14 and 0.20.1 can write GitHub connections without an authorizer and +// delete users without removing the connections they authorized. +it('requires 0.10.14 and 0.20.1 to upgrade through a release that enforces GitHub authority', () => { + const compatibility = JSON.parse( + readFileSync(resolve(import.meta.dirname, '../../drizzle/compatibility.json'), 'utf8'), + ) as { rollbackSafeFrom: Array<{ platformVersion: string }> }; + const versions = compatibility.rollbackSafeFrom.map((entry) => entry.platformVersion); + expect(versions).not.toContain('0.10.14'); + expect(versions).not.toContain('0.20.1'); +}); it('allows rollback to the 0.20.3 schema after adding invitation delivery status', () => { const root = resolve(import.meta.dirname, '../../drizzle'); diff --git a/platform/packages/api/test/unit/build-manager.test.ts b/platform/packages/api/test/unit/build-manager.test.ts index d87d00815..8f7160371 100644 --- a/platform/packages/api/test/unit/build-manager.test.ts +++ b/platform/packages/api/test/unit/build-manager.test.ts @@ -241,9 +241,9 @@ vi.mock('../../src/db.ts', () => ({ connectionId: string | undefined, _projectId: string, _installationId: string, - repositoryId: string | null, + _repositoryId: string, ) => { - if (!connectionId || !repositoryId) { + if (!connectionId) { return false; } return dbState.installationCovered; @@ -1617,18 +1617,6 @@ describe('BuildManager.startBuild source-token scoping', () => { permissions: { contents: 'read' }, }); }); - - it('mints no source token when repositoryId is null', async () => { - const ctx = makeStubCtx(); - const env = makeEnv(); - const m = makeManager(ctx, env); - - // A null repository id fails the coverage recheck closed BEFORE any mint. - await m.startBuild(makeConfig({ repositoryId: null })); - - expect(vi.mocked(getInstallationToken)).not.toHaveBeenCalled(); - expect(containerCalls.started).toBe(0); - }); }); // The container executor starts from the queued row's stored commit_sha @@ -1782,21 +1770,6 @@ describe('BuildManager.startBuild installation coverage recheck (TOCTOU)', () => ).toHaveBeenCalled(); }); - it('fails closed (cancels) when the connection carries a null repository id', async () => { - // A build without a repository id can't prove membership, regardless of - // dbState.installationCovered. - dbState.installationCovered = true; - const ctx = makeStubCtx(); - const env = makeEnv(); - const m = makeManager(ctx, env); - - await m.startBuild(makeConfig({ repositoryId: null })); - - expect(dbState.status.get('b1')).toBe('cancelled'); - expect(vi.mocked(signDeployToken)).not.toHaveBeenCalled(); - expect(containerCalls.started).toBe(0); - }); - it('fails closed (cancels) when a stored build config has no connection id', async () => { // Durable Object state written before the connection id was recorded can // still be replayed once; it must cancel rather than crash or start. diff --git a/platform/packages/api/test/unit/build-scheduler.test.ts b/platform/packages/api/test/unit/build-scheduler.test.ts index 365e6a711..c355ee8e4 100644 --- a/platform/packages/api/test/unit/build-scheduler.test.ts +++ b/platform/packages/api/test/unit/build-scheduler.test.ts @@ -1057,8 +1057,7 @@ describe('BuildScheduler.alarm', () => { // it `cancelled` ('github connection config changed') and never calls // startBuild. A config change is not a build failure, so it is NOT failed, and // the JIT branch-head check in BuildManager would not catch this (it is - // branch-HEAD-only). `build.branch != null` so a null-branch legacy row is - // exempt (covered by the existing tests that use the default 'main' branch). + // branch-HEAD-only). db.users.set('u1', { plan: 'free', login: 'octocat' }); enqueue({ id: 'b1', user_id: 'u1', project_id: 'p1', branch: 'main' }); // Connection retargeted to a different branch after the build was queued. @@ -1082,6 +1081,22 @@ describe('BuildScheduler.alarm', () => { expect(db.failed).toEqual([]); }); + it('cancels a queued build without a branch instead of starting it', async () => { + db.users.set('u1', { plan: 'free', login: 'octocat' }); + enqueue({ id: 'b1', user_id: 'u1', project_id: 'p1', branch: null }); + const startBuild = vi.fn(async () => {}); + const ctx = makeStubCtx(); + const env = makeEnv(startBuild); + const s = makeScheduler(ctx, env); + + await s.alarm(); + + expect(startBuild).not.toHaveBeenCalled(); + expect(db.claimArgs).toEqual([]); + expect(db.cancelled).toEqual([{ id: 'b1', error: 'github connection config changed' }]); + expect(db.failed).toEqual([]); + }); + it('cancels a queued build whose executor no longer matches the connection (DiD)', async () => { // The connection was switched to the github_actions executor after this // container build was queued. The container scheduler must not run a build the diff --git a/platform/packages/api/test/unit/deploy-token.test.ts b/platform/packages/api/test/unit/deploy-token.test.ts index c9549eff3..f20c7845d 100644 --- a/platform/packages/api/test/unit/deploy-token.test.ts +++ b/platform/packages/api/test/unit/deploy-token.test.ts @@ -12,7 +12,7 @@ const SECRET = 'test-secret-key-for-deploy-token'; describe('deploy token', () => { it('round-trips and verifies with aud deploy', async () => { const token = await signDeployToken( - { projectId: 'proj_abc', userId: 'user_xyz', login: 'octocat' }, + { projectId: 'proj_abc', userId: 'user_xyz', login: 'octocat', buildId: 'bld_123' }, SECRET, ); @@ -22,11 +22,13 @@ describe('deploy token', () => { expect(payload!.sub).toBe('user_xyz'); expect(payload!.login).toBe('octocat'); expect(payload!.project).toBe('proj_abc'); + expect(payload!.build).toBe('bld_123'); + expect(payload!.credential).toBeUndefined(); }); it('mints a ~15-minute token', async () => { const token = await signDeployToken( - { projectId: 'proj_abc', userId: 'user_xyz', login: 'octocat' }, + { projectId: 'proj_abc', userId: 'user_xyz', login: 'octocat', buildId: 'bld_123' }, SECRET, ); const payload = await verifyDeployToken(token, SECRET); @@ -36,7 +38,7 @@ describe('deploy token', () => { it('a deploy token fails verifyJwt with aud api', async () => { const token = await signDeployToken( - { projectId: 'proj_abc', userId: 'user_xyz', login: 'octocat' }, + { projectId: 'proj_abc', userId: 'user_xyz', login: 'octocat', buildId: 'bld_123' }, SECRET, ); expect(await verifyJwt(token, SECRET, 'api')).toBeNull(); @@ -49,7 +51,7 @@ describe('deploy token', () => { it('verifyDeployToken returns null for wrong secret', async () => { const token = await signDeployToken( - { projectId: 'proj_abc', userId: 'user_xyz', login: 'octocat' }, + { projectId: 'proj_abc', userId: 'user_xyz', login: 'octocat', buildId: 'bld_123' }, SECRET, ); expect(await verifyDeployToken(token, 'wrong-secret')).toBeNull(); diff --git a/platform/packages/api/test/unit/email-migration.test.ts b/platform/packages/api/test/unit/email-migration.test.ts index e161b92c4..a953ef55b 100644 --- a/platform/packages/api/test/unit/email-migration.test.ts +++ b/platform/packages/api/test/unit/email-migration.test.ts @@ -14,9 +14,9 @@ it('adds the email ledger without changing tables used by the previous runtime', expect(journal.entries[emailIndex]?.idx).toBe(47); const previous = journal.entries.slice(0, emailIndex); const compatibility = JSON.parse(readFileSync(resolve(root, 'compatibility.json'), 'utf8')); - expect(compatibility.rollbackSafeFrom).toContainEqual({ - platformVersion: '0.20.1', - schemaDigest: platformDatabaseSchemaDigest( + // The schema used by the 0.20.1 runtime. + expect( + platformDatabaseSchemaDigest( compatibility.lineage, previous.map((entry) => ({ index: entry.idx, @@ -27,7 +27,7 @@ it('adds the email ledger without changing tables used by the previous runtime', .digest('hex'), })), ), - }); + ).toBe('6de871d9524b082bba675d0d653ade45f95206895a962fea2b9be4b85d486313'); const db = new PGlite(); try { for (const entry of previous) { diff --git a/platform/packages/api/test/unit/email-shared-recipient-migration.test.ts b/platform/packages/api/test/unit/email-shared-recipient-migration.test.ts index 9dcf1169b..cd55398bd 100644 --- a/platform/packages/api/test/unit/email-shared-recipient-migration.test.ts +++ b/platform/packages/api/test/unit/email-shared-recipient-migration.test.ts @@ -20,8 +20,9 @@ it('declares only the exact canonical 0.22.0 predecessor and fences legacy delet { idx: 54, tag: '0054_project_mode', when: 1790841384175 }, { idx: 55, tag: '0055_durable_deployment_execution', when: 1790864531640 }, ]); - expect(journal.entries.at(-1)).toMatchObject({ idx: 56, tag: '0056_email_shared_recipients' }); - expect(journal.entries.at(-1)!.when).toBeGreaterThan(baseline.at(-1)!.when); + const migration = journal.entries.find((entry) => entry.idx === 56); + expect(migration).toMatchObject({ idx: 56, tag: '0056_email_shared_recipients' }); + expect(migration!.when).toBeGreaterThan(baseline.at(-1)!.when); const previousSnapshot = JSON.parse( readFileSync(resolve(root, 'meta/0055_snapshot.json'), 'utf8'), ); diff --git a/platform/packages/api/test/unit/github-authority-constraints-migration.test.ts b/platform/packages/api/test/unit/github-authority-constraints-migration.test.ts new file mode 100644 index 000000000..f0d509db3 --- /dev/null +++ b/platform/packages/api/test/unit/github-authority-constraints-migration.test.ts @@ -0,0 +1,157 @@ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { PGlite } from '@electric-sql/pglite'; +import { platformDatabaseSchemaDigest } from '@void/platform/installer'; +import { expect, it } from 'vite-plus/test'; + +const root = resolve(import.meta.dirname, '../../drizzle'); +const MIGRATION = '0057_github_authority_constraints'; + +type JournalEntry = { tag: string; idx: number; when: number }; + +function readJournal(): Array { + return ( + JSON.parse(readFileSync(resolve(root, 'meta/_journal.json'), 'utf8')) as { + entries: Array; + } + ).entries; +} + +async function applyMigration(db: PGlite, tag: string): Promise { + for (const statement of readFileSync(resolve(root, `${tag}.sql`), 'utf8') + .split('--> statement-breakpoint') + .map((value) => value.trim()) + .filter(Boolean)) { + await db.query(statement); + } +} + +it('removes rows without authority and then enforces GitHub authority constraints', async () => { + const journal = readJournal(); + expect(journal.find((entry) => entry.tag === MIGRATION)?.idx).toBe(57); + const db = new PGlite(); + try { + for (const entry of journal.filter((candidate) => candidate.idx < 57)) { + await applyMigration(db, entry.tag); + } + await db.exec(` + INSERT INTO users (id, provider_id, login) VALUES + ('owner', '1', 'owner'), + ('admin', '2', 'admin'), + ('collaborator', '3', 'collaborator'), + ('gone', '4', 'gone'); + INSERT INTO projects (id, user_id, slug) VALUES + ('p-owner', 'owner', 'owner'), + ('p-admin', 'owner', 'admin'), + ('p-deleted-authorizer', 'owner', 'deleted-authorizer'), + ('p-no-repo', 'owner', 'no-repo'), + ('p-collaborator', 'owner', 'collaborator'); + INSERT INTO project_members (project_id, user_id, role) VALUES + ('p-admin', 'admin', 'admin'), + ('p-deleted-authorizer', 'gone', 'admin'), + ('p-collaborator', 'collaborator', 'collaborator'); + INSERT INTO github_installations (id, installation_id, user_id, account_login, account_id, account_type) VALUES + ('ghi-user', '100', 'owner', 'owner', '1', 'User'), + ('ghi-org-owned', '200', 'owner', 'org', '50', 'Organization'); + INSERT INTO github_connections (id, project_id, installation_id, repo_full_name, repository_id, authorized_by_user_id) VALUES + ('c-owner', 'p-owner', '100', 'owner/a', '1', 'owner'), + ('c-admin', 'p-admin', '200', 'org/b', '2', 'admin'), + ('c-deleted-authorizer', 'p-deleted-authorizer', '200', 'org/c', '3', 'gone'), + ('c-no-repo', 'p-no-repo', '100', 'owner/d', NULL, 'owner'), + ('c-collaborator', 'p-collaborator', '200', 'org/e', '5', 'collaborator'); + DELETE FROM users WHERE id = 'gone'; + INSERT INTO builds (id, project_id, user_id, executor, status) VALUES + ('b-owner', 'p-owner', 'owner', 'container', 'building'), + ('b-admin', 'p-admin', 'owner', 'github_actions', 'deploying'), + ('b-deleted-authorizer', 'p-deleted-authorizer', 'owner', 'container', 'building'), + ('b-no-repo', 'p-no-repo', 'owner', 'github_actions', 'starting'), + ('b-collaborator', 'p-collaborator', 'collaborator', 'container', 'queued'); + `); + // The user delete above left this connection without an authorizer. + expect( + ( + await db.query( + "select authorized_by_user_id from github_connections where id = 'c-deleted-authorizer'", + ) + ).rows, + ).toEqual([{ authorized_by_user_id: null }]); + + await applyMigration(db, MIGRATION); + + expect((await db.query('select id from github_connections order by id')).rows).toEqual([ + { id: 'c-admin' }, + { id: 'c-owner' }, + ]); + const cancelled = + 'github repository authorization is no longer valid; reconnect the repository'; + expect( + ( + await db.query( + 'select id, status, error, completed_at is not null as completed from builds order by id', + ) + ).rows, + ).toEqual([ + { id: 'b-admin', status: 'deploying', error: null, completed: false }, + { id: 'b-collaborator', status: 'cancelled', error: cancelled, completed: true }, + { id: 'b-deleted-authorizer', status: 'cancelled', error: cancelled, completed: true }, + { id: 'b-no-repo', status: 'cancelled', error: cancelled, completed: true }, + { id: 'b-owner', status: 'building', error: null, completed: false }, + ]); + expect( + ( + await db.query( + 'select installation_id, user_id from github_installations order by installation_id', + ) + ).rows, + ).toEqual([ + { installation_id: '100', user_id: 'owner' }, + { installation_id: '200', user_id: null }, + ]); + + await expect( + db.query( + "INSERT INTO github_connections (id, project_id, installation_id, repo_full_name, repository_id) VALUES ('c-new', 'p-no-repo', '100', 'owner/f', '6')", + ), + ).rejects.toThrow(/authorized_by_user_id.*not-null/); + await expect( + db.query( + "INSERT INTO github_connections (id, project_id, installation_id, repo_full_name, authorized_by_user_id) VALUES ('c-new', 'p-no-repo', '100', 'owner/f', 'owner')", + ), + ).rejects.toThrow(/repository_id.*not-null/); + await expect(db.query("DELETE FROM users WHERE id = 'admin'")).rejects.toThrow( + /github_connections_authorized_by_user_id_users_id_fk/, + ); + } finally { + await db.close(); + } +}, 30_000); + +// 0.23.0 and 0.24.0 only create connections with an authorizer and repository id, and +// deletes the connections a user authorized before deleting that user. +it('allows upgrading the 0.23.0 and 0.24.0 schema to the GitHub authority constraints', () => { + const compatibility = JSON.parse(readFileSync(resolve(root, 'compatibility.json'), 'utf8')) as { + lineage: string; + rollbackSafeFrom: Array<{ platformVersion: string; schemaDigest: string }>; + }; + const previous = readJournal().filter((entry) => entry.idx < 57); + expect(previous.at(-1)?.tag).toBe('0056_email_shared_recipients'); + const schemaDigest = platformDatabaseSchemaDigest( + compatibility.lineage, + previous.map((entry) => ({ + index: entry.idx, + tag: entry.tag, + timestampMillis: entry.when, + sha256: createHash('sha256') + .update(readFileSync(resolve(root, `${entry.tag}.sql`))) + .digest('hex'), + })), + ); + expect(schemaDigest).toBe('3e3458ba1268853dc8e9a887270863557c2a4c5a5210f8615d247bf1f4bf6bcc'); + expect( + compatibility.rollbackSafeFrom.filter((entry) => entry.schemaDigest === schemaDigest), + ).toEqual([ + { platformVersion: '0.23.0', schemaDigest }, + { platformVersion: '0.24.0', schemaDigest }, + ]); +}); diff --git a/platform/packages/api/test/unit/platform-version-deploy.test.ts b/platform/packages/api/test/unit/platform-version-deploy.test.ts new file mode 100644 index 000000000..489ecf6fd --- /dev/null +++ b/platform/packages/api/test/unit/platform-version-deploy.test.ts @@ -0,0 +1,45 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vite-plus/test'; +import { + platformVersionDeployArgs, + readPlatformReleaseVersion, +} from '../../../../scripts/platform-version.ts'; + +const workerDirectory = (name: string) => + fileURLToPath(new URL(`../../../${name}`, import.meta.url)); + +describe('hosted platform version', () => { + it('reads the release version of packages/platform', () => { + const { version } = JSON.parse( + readFileSync( + new URL('../../../../../packages/platform/package.json', import.meta.url), + 'utf8', + ), + ) as { version: string }; + expect(readPlatformReleaseVersion()).toBe(version); + expect(version).toMatch(/^\d+\.\d+\.\d+/); + }); + + it.each(['api', 'email-gateway'])('sets PLATFORM_VERSION on the %s deploy', (name) => { + expect(platformVersionDeployArgs(workerDirectory(name), false, '0.23.0')).toEqual([ + '--var', + 'PLATFORM_VERSION:0.23.0', + ]); + }); + + it('defaults to the release version of this checkout', () => { + expect(platformVersionDeployArgs(workerDirectory('api'), false)).toEqual([ + '--var', + `PLATFORM_VERSION:${readPlatformReleaseVersion()}`, + ]); + }); + + it.each(['dispatch', 'proxy', 'tail', 'dashboard'])('leaves the %s deploy unchanged', (name) => { + expect(platformVersionDeployArgs(workerDirectory(name), false, '0.23.0')).toEqual([]); + }); + + it('leaves a deploy with its own config unchanged', () => { + expect(platformVersionDeployArgs(workerDirectory('api'), true, '0.23.0')).toEqual([]); + }); +}); diff --git a/platform/packages/api/test/unit/sandbox-drain-migration.test.ts b/platform/packages/api/test/unit/sandbox-drain-migration.test.ts index fc8611a9e..49df60390 100644 --- a/platform/packages/api/test/unit/sandbox-drain-migration.test.ts +++ b/platform/packages/api/test/unit/sandbox-drain-migration.test.ts @@ -19,7 +19,6 @@ describe('Sandbox drain database compatibility', () => { it('pins the verified 0036 baseline digest and keeps 0037 additive', () => { const compatibility = readJson(new URL('compatibility.json', drizzleRoot)) as { lineage: string; - rollbackSafeFrom: Array<{ platformVersion: string; schemaDigest: string }>; }; const journal = readJson(new URL('meta/_journal.json', drizzleRoot)) as { entries: Array<{ idx: number; tag: string; when: number }>; @@ -36,10 +35,6 @@ describe('Sandbox drain database compatibility', () => { JSON.stringify({ lineage: compatibility.lineage, migrations: baselineMigrations }), ); expect(baselineDigest).toBe('06c6f91f87d2ad95b1631ae48e2cd671aa6976fe53313947748c07bf2a7d6fda'); - expect(compatibility.rollbackSafeFrom).toContainEqual({ - platformVersion: '0.10.14', - schemaDigest: baselineDigest, - }); const drainMigration = readFileSync(new URL('0037_unusual_bloodaxe.sql', drizzleRoot), 'utf8'); const statements = drainMigration diff --git a/platform/scripts/platform-version.ts b/platform/scripts/platform-version.ts new file mode 100644 index 000000000..95a8a0976 --- /dev/null +++ b/platform/scripts/platform-version.ts @@ -0,0 +1,45 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +/** + * Workers that report the platform version: the API (discovery and the + * operator system overview) and the email gateway (health). + */ +const PLATFORM_VERSION_WORKER_DIRECTORIES = new Set( + ['api', 'email-gateway'].map((name) => + fileURLToPath(new URL(`../packages/${name}`, import.meta.url)), + ), +); + +const RELEASE_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; + +/** + * The release version of this checkout. Installed platforms report the same + * version, which the installer reads from the packaged runtime. + */ +export function readPlatformReleaseVersion(): string { + const { version } = JSON.parse( + readFileSync(new URL('../../packages/platform/package.json', import.meta.url), 'utf8'), + ) as { version?: unknown }; + if (typeof version !== 'string' || !RELEASE_VERSION.test(version)) { + throw new Error(`packages/platform/package.json has no release version: ${String(version)}`); + } + return version; +} + +/** + * Wrangler arguments that make a hosted deploy report its release version, so + * the CLI's minimum platform version check applies to hosted platforms too. + * Deploys with their own config (such as the GitHub webhook ingress) and other + * Workers get none. + */ +export function platformVersionDeployArgs( + workerDirectory: string, + customConfig: boolean, + version: string = readPlatformReleaseVersion(), +): Array { + if (customConfig || !PLATFORM_VERSION_WORKER_DIRECTORIES.has(workerDirectory)) { + return []; + } + return ['--var', `PLATFORM_VERSION:${version}`]; +} diff --git a/platform/scripts/wrangler-deploy.ts b/platform/scripts/wrangler-deploy.ts index 16fb167eb..3456794c7 100644 --- a/platform/scripts/wrangler-deploy.ts +++ b/platform/scripts/wrangler-deploy.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { parseArgs } from 'node:util'; +import { platformVersionDeployArgs } from './platform-version.ts'; const EMAIL_SIGNING_SECRET = 'EMAIL_SIGNING_SECRET'; const DASHBOARD_DIRECTORY = fileURLToPath(new URL('../packages/dashboard', import.meta.url)); @@ -135,6 +136,7 @@ try { }); deployArgs.push('--secrets-file', secretsPath); } + deployArgs.push(...platformVersionDeployArgs(process.cwd(), values.config !== undefined)); const deploy = spawnSync('wrangler', deployArgs, { env: subprocessEnv,