diff --git a/docs/guide/platform/installation/prerequisites.md b/docs/guide/platform/installation/prerequisites.md index 5ead7353b..63d524c36 100644 --- a/docs/guide/platform/installation/prerequisites.md +++ b/docs/guide/platform/installation/prerequisites.md @@ -84,3 +84,5 @@ Void does not require a particular database provider. Use an existing PostgreSQL For a dedicated Supabase project, [disable the Data API](https://supabase.com/docs/guides/api/securing-your-api#disable-the-data-api) so the platform's tables are not exposed through Supabase's auto-generated endpoints. Void uses the PostgreSQL connection, not Supabase API keys. Once installation claims the database, continue using that same database for resume and maintenance commands. Uninstall never deletes external PostgreSQL. + +If your organization provisions Hyperdrive separately, use a database owner connection for installation and migrations. Set `VOID_PLATFORM_DATABASE_URL` to that connection, then set `VOID_PLATFORM_HYPERDRIVE_ID`, `VOID_PLATFORM_HYPERDRIVE_ORIGIN_HOST`, and `VOID_PLATFORM_HYPERDRIVE_ORIGIN_USER` to the existing Hyperdrive's ID, exact origin host, and runtime database user. The Hyperdrive name must be `void--database`, point to the same database, and have SQL result caching disabled. Void records its identity for later maintenance without taking ownership of it. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 8106e2069..0677542f0 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -774,6 +774,8 @@ To enable email during install or upgrade, set both `VOID_EMAIL_SENDER_DOMAIN` a Use an empty PostgreSQL database dedicated to the installation. You can correct a failed initial connection, but after the database is claimed or Hyperdrive is provisioned, commands reject a different URL. +For an externally managed Hyperdrive, set `VOID_PLATFORM_HYPERDRIVE_ID`, `VOID_PLATFORM_HYPERDRIVE_ORIGIN_HOST`, and `VOID_PLATFORM_HYPERDRIVE_ORIGIN_USER` together. Supply an owner connection in `VOID_PLATFORM_DATABASE_URL` for database claims and migrations. Void adopts the named Hyperdrive after verifying its origin and disabled SQL result caching; its configuration stays under the external manager's control. + Recovery secrets are encrypted with AES-256-GCM using a key in your system keychain. The encrypted data is tied to the installation identity. Without a keychain, supply a canonical base64-encoded 32-byte `VOID_PLATFORM_RECOVERY_KEY`; otherwise Void stops before saving secrets. CI can generate a temporary key when its original credentials remain in protected secrets. If a newly created zone is waiting for registrar delegation, resume after it becomes active: diff --git a/packages/void/skills/void/SKILL.md b/packages/void/skills/void/SKILL.md index 141e6cc87..d2ad627d7 100644 --- a/packages/void/skills/void/SKILL.md +++ b/packages/void/skills/void/SKILL.md @@ -93,6 +93,8 @@ Use `--runtime ` on platform install, upgrade, repair, enable, or rol For self-hosted platform installation, require a dedicated empty PostgreSQL database. Interactive lifecycle commands use Cloudflare browser OAuth with keyring storage. Wrangler OAuth has Zone Read, so a plan that creates a zone or DNS record requires a scoped `CLOUDFLARE_API_TOKEN` with Account → Zone → Edit and/or Zone → DNS → Edit; finish Workers onboarding and choose a workers.dev subdomain for a fresh Cloudflare account. The installed platform uses a separate `VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN`. Its core permissions include Hyperdrive: Write at account scope and Cache Purge: Purge on the application zone; SSL and Certificates: Edit is needed only when a custom runtime enables custom project domains. Managed Sandbox apps additionally require Workers Paid plus Account → Containers → Edit and Account → Cloudchamber → Edit on that runtime token; these are checked on the first Sandbox application deploy, not during platform install or upgrade. The installer preflights account and Hyperdrive access and safely verifies Cache Purge for an existing zone with a unique nonexistent URL. Fresh installs require unique per-installation values from the operator's secret manager: `VOID_PLATFORM_JWT_SECRET` with at least 32 random bytes and `VOID_PLATFORM_PROJECT_SECRET_KEY` as canonical base64 for 32 random bytes. Never let a local checkpoint or ephemeral CI runner be their only custodian. Enabling email generates a separate signing key in encrypted recovery state; supply `VOID_PLATFORM_EMAIL_SIGNING_SECRET` from the secret manager for headless installs without persistent recovery files. The installer transactionally claims the database for one installation, pins that URL after a successful claim, never removes the claim during uninstall, and uses it for a cross-host lifecycle lock plus a secret-free authoritative lifecycle manifest. Local recovery checkpoints are AES-256-GCM encrypted with an OS-keychain key; never place platform secrets in plaintext files. After discovery on a new machine, provide `VOID_PLATFORM_DATABASE_URL`. For email-enabled installations without encrypted recovery state, also restore the original `VOID_PLATFORM_EMAIL_SIGNING_SECRET`; recreating only the email gateway needs this key, not JWT or runtime credentials. Normal upgrades preserve other deployed Worker secrets; restore the original runtime, GitHub, R2, JWT, and project-encryption values when recreating the API or proxy as documented. Upgrades enable managed Sandboxes automatically. An upgrade from the tenant-owned legacy may still require `void platform system sandbox-drain`: preview with `--plan`, follow each `nextCursor`, then apply with `--yes` until the DB-backed response reports `complete: true`; never delete an unverified app by name. The installer trusts only DB-backed sandbox-drain probe protocol v1. Before it accepts an empty inventory, the database admission barrier makes older deployment inserts finish and become visible or rejects them after the protocol floor is armed. Lifecycle redeploys preserve unmanaged Worker routes and custom domains in both the new trigger state and rollback snapshot. They stop before migrations or uploads when live Hyperdrive origin metadata differs from the pinned database. Platform upgrade SQL is forward-only: packaged hashes and the live Drizzle prefix must match, pending migrations require an exact source-version/schema rollback edge, and the old version remains authoritative until target health succeeds. Use `void platform rollback --runtime ` only when the installed runtime declares the exact earlier version/schema compatible; pass `--from-runtime` for the exact current custom artifact. Rollback preserves the forward database schema and cannot lower a database-required safety protocol. Safe uninstall retains Worker routes, custom domains, and R2 for explicit manual cleanup because Cloudflare cannot condition their deletion on an immutable generation. +For an organization-managed Hyperdrive, set `VOID_PLATFORM_HYPERDRIVE_ID`, `VOID_PLATFORM_HYPERDRIVE_ORIGIN_HOST`, and `VOID_PLATFORM_HYPERDRIVE_ORIGIN_USER` together before installation. Use `VOID_PLATFORM_DATABASE_URL` for a separate owner connection that runs migrations; the Hyperdrive must be named `void--database` and have SQL result caching disabled. See `docs/guide/platform/installation/prerequisites.md`. + For self-hosted recovery, `VOID_PLATFORM_PROJECT_SECRET_KEY` restores an original `v1` project-secret keyring. After rotation, restore every retained version with `VOID_PLATFORM_PROJECT_SECRET_KEYS_JSON` and its active entry with `VOID_PLATFORM_PROJECT_SECRET_ACTIVE_KEY_VERSION`. These inputs restore a missing API Worker and never replace the live keyring of an existing Worker. Inject them from a secret manager without logging them or writing plaintext files. For Cloudflare user switching, `void cloudflare login` always opens a fresh browser sign-in. `void cloudflare status` distinguishes the authenticated user and credential method from the project's pinned deployment account. Login never retargets a project. Remove API-credential environment overrides from the current shell before browser sign-in, without printing their values; automatic deployment checks continue to reuse valid sessions. diff --git a/packages/void/src/cli/platform-auth-database.ts b/packages/void/src/cli/platform-auth-database.ts index 11582aa12..28e131c1e 100644 --- a/packages/void/src/cli/platform-auth-database.ts +++ b/packages/void/src/cli/platform-auth-database.ts @@ -1,10 +1,60 @@ import type { InstallationResource } from '@void/platform'; import { + hyperdriveOriginConfigHash, hyperdriveOriginFromConnectionString, hyperdriveOriginMatches, + type CloudflareHyperdriveConfig, type PlatformCloudflareClient, } from './platform-cloudflare.ts'; +/** Read the database name from an owner URL without applying Hyperdrive origin restrictions. */ +export function platformDatabaseNameFromOwnerUrl(databaseUrl: string): string { + let url: URL; + try { + url = new URL(databaseUrl); + } catch { + throw new Error('platform: DATABASE_URL must be a PostgreSQL connection string.'); + } + if ((url.protocol !== 'postgres:' && url.protocol !== 'postgresql:') || !url.hostname) { + throw new Error('platform: DATABASE_URL must use postgres:// or postgresql://.'); + } + const database = decodeURIComponent(url.pathname.replace(/^\//, '')); + if (!database) { + throw new Error('platform: DATABASE_URL must include a database.'); + } + return database; +} + +/** Pin an externally managed Hyperdrive without treating the owner URL as its runtime origin. */ +export function verifyExternalPlatformHyperdrive(input: { + config: CloudflareHyperdriveConfig; + databaseUrl: string; + expected: { id: string; name: string; host: string; user: string }; + recordedHash?: string; +}): string { + const { config, expected } = input; + const database = platformDatabaseNameFromOwnerUrl(input.databaseUrl); + const hash = hyperdriveOriginConfigHash(config.origin); + if ( + config.id !== expected.id || + config.name !== expected.name || + config.origin.host !== expected.host.toLowerCase() || + config.origin.user !== expected.user || + config.origin.database !== database || + (input.recordedHash && input.recordedHash !== hash) + ) { + throw new Error( + 'platform: The Terraform-managed Hyperdrive does not match the expected database origin.', + ); + } + if (config.cachingDisabled !== true) { + throw new Error( + 'platform: Disable SQL result caching on the Terraform-managed Hyperdrive before installing.', + ); + } + return hash; +} + /** Authentication requires fresh reads; mutate only the installation-owned configuration. */ export async function verifyPlatformAuthenticationDatabase( client: Pick, @@ -21,13 +71,21 @@ export async function verifyPlatformAuthenticationDatabase( 'platform: Authentication database verification requires the platform Hyperdrive resource.', ); } - const expected = hyperdriveOriginFromConnectionString(databaseUrl); + const expectedDatabase = + resource.ownership === 'adopted' ? platformDatabaseNameFromOwnerUrl(databaseUrl) : undefined; + const expectedOrigin = + resource.ownership !== 'adopted' + ? hyperdriveOriginFromConnectionString(databaseUrl) + : undefined; const read = async () => { const current = await client.getHyperdriveConfig(accountId, resource.id); if ( current.id !== resource.id || current.name !== resource.name || - !hyperdriveOriginMatches(current.origin, expected) + (resource.ownership === 'adopted' + ? current.origin.database !== expectedDatabase || + hyperdriveOriginConfigHash(current.origin) !== resource.configHash + : expectedOrigin === undefined || !hyperdriveOriginMatches(current.origin, expectedOrigin)) ) { throw new Error( 'platform: Hyperdrive does not match this installation’s pinned database. No authentication configuration was activated.', diff --git a/packages/void/src/cli/platform-installer.ts b/packages/void/src/cli/platform-installer.ts index 864d5355e..fae0e9db2 100644 --- a/packages/void/src/cli/platform-installer.ts +++ b/packages/void/src/cli/platform-installer.ts @@ -63,7 +63,10 @@ import { initializeInstalledPlatformAuthentication, resumeInstalledPlatformAuthentication, } from './platform-auth-bootstrap.ts'; -import { verifyPlatformAuthenticationDatabase } from './platform-auth-database.ts'; +import { + verifyExternalPlatformHyperdrive, + verifyPlatformAuthenticationDatabase, +} from './platform-auth-database.ts'; import { hyperdriveOriginConfigHash, hyperdriveOriginFromConnectionString, @@ -848,6 +851,35 @@ export async function installPlatform( ); } verifyRuntimeArtifacts(runtimeDirectory, runtime); + const hyperdriveAction = plan.actions.find((action) => action.key === 'hyperdrive'); + const recordedHyperdrive = existing?.resources.find((resource) => resource.key === 'hyperdrive'); + let externalHyperdriveOriginHash: string | undefined; + if ( + hyperdriveAction && + (hyperdriveAction.action === 'adopt' || recordedHyperdrive?.ownership === 'adopted') + ) { + if (!hyperdriveAction.id) { + throw new Error('platform: The Terraform-managed Hyperdrive has no ID.'); + } + if (!plan.externalHyperdrive && !recordedHyperdrive) { + throw new Error('platform: The Terraform-managed Hyperdrive origin must be specified.'); + } + const live = await dependencies.client.getHyperdriveConfig( + plan.account.id, + hyperdriveAction.id, + ); + externalHyperdriveOriginHash = verifyExternalPlatformHyperdrive({ + config: live, + databaseUrl: secrets.databaseUrl, + expected: { + id: hyperdriveAction.id, + name: hyperdriveAction.name, + host: plan.externalHyperdrive?.host ?? live.origin.host, + user: plan.externalHyperdrive?.user ?? live.origin.user ?? '', + }, + ...(recordedHyperdrive ? { recordedHash: recordedHyperdrive.configHash } : {}), + }); + } const runtimeProvenance = { manifestSha256: resolvedRuntime.manifestSha256, source: resolvedRuntime.source, @@ -1271,6 +1303,7 @@ export async function installPlatform( 'adopted', now(), secrets.databaseUrl, + action.kind === 'hyperdrive' ? externalHyperdriveOriginHash : undefined, ); writeInstallationManifest(manifest); await writeRemoteInstallationCheckpoint(dependencies.client, plan, manifest); @@ -1289,6 +1322,7 @@ export async function installPlatform( undefined, now(), secrets.databaseUrl, + action.kind === 'hyperdrive' ? externalHyperdriveOriginHash : undefined, ); writeInstallationManifest(manifest); await writeR2OwnershipMarkerIfNeeded(dependencies.client, plan, manifest, action); @@ -1313,7 +1347,7 @@ export async function installPlatform( 'created', now(), secrets.databaseUrl, - resource.fingerprint, + action.kind === 'hyperdrive' ? externalHyperdriveOriginHash : resource.fingerprint, ); writeInstallationManifest(manifest); await writeR2OwnershipMarkerIfNeeded(dependencies.client, plan, manifest, action); @@ -2596,11 +2630,13 @@ export function recordResource( configHash: action.kind === 'dns-record' ? dnsRecordConfigHash(action.name, 'AAAA', '100::', true) - : action.kind === 'hyperdrive' && databaseUrl - ? hyperdriveOriginConfigHash(hyperdriveOriginFromConnectionString(databaseUrl)) - : action.kind === 'r2-bucket' && resourceFingerprint - ? r2BucketConfigHash(action.name, resourceFingerprint) - : hash(JSON.stringify({ kind: action.kind, name: action.name })), + : action.kind === 'hyperdrive' && resourceFingerprint + ? resourceFingerprint + : action.kind === 'hyperdrive' && databaseUrl + ? hyperdriveOriginConfigHash(hyperdriveOriginFromConnectionString(databaseUrl)) + : action.kind === 'r2-bucket' && resourceFingerprint + ? r2BucketConfigHash(action.name, resourceFingerprint) + : hash(JSON.stringify({ kind: action.kind, name: action.name })), id, key: action.key, kind: action.kind, diff --git a/packages/void/src/cli/platform-lifecycle.ts b/packages/void/src/cli/platform-lifecycle.ts index 94628fdc6..edf1370b3 100644 --- a/packages/void/src/cli/platform-lifecycle.ts +++ b/packages/void/src/cli/platform-lifecycle.ts @@ -392,6 +392,17 @@ export async function runPlatformLifecycleCommand(args: PlatformArgs): Promise; apiUrl: string; deployment: PlatformDeploymentManifest; + externalHyperdrive?: PlatformExternalHyperdrive; installationId: string; proxyUrl: string; workersSubdomain: string; zone: CloudflareZone | null; }>; +export type PlatformExternalHyperdrive = Readonly<{ + id: string; + host: string; + user: string; +}>; + export type PlatformInstallPlanInput = Readonly<{ authentication?: import('@void/platform').PlatformDeploymentManifest['authentication']; accountId: string; @@ -51,6 +58,7 @@ export type PlatformInstallPlanInput = Readonly<{ controlPlaneDomain: string; dedicatedZone: boolean; displayName: string; + externalHyperdrive?: PlatformExternalHyperdrive; emailSharedZoneId?: string; emailSenderDomain?: string; installationSlug: string; @@ -557,17 +565,46 @@ export async function createPlatformInstallPlan( options, remoteOwnership, ); - addNamedPlan( - actions, - existing, - 'hyperdrive', - 'hyperdrive', - names.hyperdrive, - hyperdrives.filter((resource) => resource.name === names.hyperdrive), - 'reuse', - options, - remoteOwnership, - ); + const recordedHyperdrive = existing?.resources.find((resource) => resource.key === 'hyperdrive'); + const externalHyperdriveId = + input.externalHyperdrive?.id || + (recordedHyperdrive?.ownership === 'adopted' ? recordedHyperdrive.id : ''); + if (externalHyperdriveId) { + if ( + recordedHyperdrive && + (recordedHyperdrive.ownership !== 'adopted' || recordedHyperdrive.id !== externalHyperdriveId) + ) { + throw new Error('platform: The Terraform-managed Hyperdrive differs from this installation.'); + } + const remote = hyperdrives.find((resource) => resource.id === externalHyperdriveId); + if (!remote || remote.name !== names.hyperdrive) { + throw new Error( + `platform: Terraform-managed Hyperdrive ${JSON.stringify(externalHyperdriveId)} must exist with the name ${JSON.stringify(names.hyperdrive)}.`, + ); + } + actions.push({ + action: recordedHyperdrive ? 'reuse' : 'adopt', + id: remote.id, + key: 'hyperdrive', + kind: 'hyperdrive', + name: remote.name, + reason: recordedHyperdrive + ? 'Reuse the recorded Terraform-managed Hyperdrive.' + : 'Use the Terraform-managed Hyperdrive without taking ownership.', + }); + } else { + addNamedPlan( + actions, + existing, + 'hyperdrive', + 'hyperdrive', + names.hyperdrive, + hyperdrives.filter((resource) => resource.name === names.hyperdrive), + 'reuse', + options, + remoteOwnership, + ); + } for (const [key, name] of [ ['api-worker', names.apiWorker], ['proxy-worker', names.proxyWorker], @@ -613,6 +650,7 @@ export async function createPlatformInstallPlan( actions, apiUrl, deployment: resolvedDeployment, + ...(input.externalHyperdrive ? { externalHyperdrive: input.externalHyperdrive } : {}), installationId, proxyUrl, workersSubdomain, @@ -636,6 +674,7 @@ export async function revalidatePlatformInstallAction( controlPlaneDomain: deployment.controlPlane.apiHostname ?? '', dedicatedZone: deployment.application.dedicatedZone, displayName: deployment.displayName, + ...(original.externalHyperdrive ? { externalHyperdrive: original.externalHyperdrive } : {}), installationSlug: deployment.installationSlug, zoneName: deployment.application.zoneName, }, diff --git a/packages/void/test/unit/platform-auth-database.test.ts b/packages/void/test/unit/platform-auth-database.test.ts index 84743aae6..4abe2db03 100644 --- a/packages/void/test/unit/platform-auth-database.test.ts +++ b/packages/void/test/unit/platform-auth-database.test.ts @@ -1,7 +1,11 @@ import type { InstallationResource } from '@void/platform'; import { describe, expect, it, vi } from 'vite-plus/test'; -import { verifyPlatformAuthenticationDatabase } from '../../src/cli/platform-auth-database.ts'; import { + verifyExternalPlatformHyperdrive, + verifyPlatformAuthenticationDatabase, +} from '../../src/cli/platform-auth-database.ts'; +import { + hyperdriveOriginConfigHash, hyperdriveOriginFromConnectionString, PlatformCloudflareClient, } from '../../src/cli/platform-cloudflare.ts'; @@ -24,6 +28,59 @@ const config = { }; describe('fresh platform authentication database reads', () => { + it('pins an uncached Terraform-managed origin while migrations use the owner connection', async () => { + const ownerDatabaseUrl = `${databaseUrl}?sslmode=disable`; + const external = { + ...config, + origin: { + ...config.origin, + host: 'hyperdrive-productiondb.databases.cfdata.org/itdb/primary', + user: 'hyperdrive_void_platform_service', + }, + }; + const hash = verifyExternalPlatformHyperdrive({ + config: external, + databaseUrl: ownerDatabaseUrl, + expected: { + id: resource.id, + name: resource.name, + host: external.origin.host, + user: external.origin.user, + }, + }); + expect(hash).toBe(hyperdriveOriginConfigHash(external.origin)); + const client = { + getHyperdriveConfig: vi.fn(async () => external), + disableHyperdriveCaching: vi.fn(), + }; + await verifyPlatformAuthenticationDatabase( + client, + 'account', + { ...resource, ownership: 'adopted', configHash: hash }, + ownerDatabaseUrl, + ); + expect(client.disableHyperdriveCaching).not.toHaveBeenCalled(); + expect(() => + verifyExternalPlatformHyperdrive({ + config: { ...external, origin: { ...external.origin, database: 'other' } }, + databaseUrl: ownerDatabaseUrl, + expected: { + id: resource.id, + name: resource.name, + host: external.origin.host, + user: external.origin.user, + }, + }), + ).toThrow('expected database origin'); + await expect( + verifyPlatformAuthenticationDatabase( + client, + 'account', + { ...resource, ownership: 'adopted', configHash: '0'.repeat(64) }, + ownerDatabaseUrl, + ), + ).rejects.toThrow('pinned database'); + }); it('accepts a verified uncached database without changing it', async () => { const client = { getHyperdriveConfig: vi.fn(async () => config), @@ -53,7 +110,11 @@ describe('fresh platform authentication database reads', () => { verifyPlatformAuthenticationDatabase( client, 'account', - { ...resource, ownership: 'adopted' }, + { + ...resource, + ownership: 'adopted', + configHash: hyperdriveOriginConfigHash(config.origin), + }, databaseUrl, ), ).rejects.toThrow('by its owner'); diff --git a/packages/void/test/unit/platform-plan.test.ts b/packages/void/test/unit/platform-plan.test.ts index 7a264db40..a5b181e40 100644 --- a/packages/void/test/unit/platform-plan.test.ts +++ b/packages/void/test/unit/platform-plan.test.ts @@ -80,6 +80,55 @@ function legacyInstallation(suffix = '0123456789ab') { } describe('platform installation planning', () => { + it('adopts only the explicitly selected Terraform-managed Hyperdrive', async () => { + const fake = client({ + listHyperdriveConfigs: async () => [{ id: 'terraform-id', name: 'void-example-database' }], + }); + await expect(createPlatformInstallPlan(fake, input, null)).rejects.toThrow('not owned'); + const externalHyperdrive = { + id: 'terraform-id', + host: 'hyperdrive-productiondb.databases.cfdata.org/itdb/primary', + user: 'hyperdrive_void_platform_service', + }; + const plan = await createPlatformInstallPlan(fake, { ...input, externalHyperdrive }, null); + expect(plan.actions.find((action) => action.key === 'hyperdrive')).toMatchObject({ + action: 'adopt', + id: 'terraform-id', + name: 'void-example-database', + }); + const recorded = recordInstallationResource( + createPlatformInstallationManifest({ + account: plan.account, + createdAt: '2026-09-25T00:00:00.000Z', + deployment: plan.deployment, + id: plan.installationId, + ownershipNonce: 'a'.repeat(43), + platformVersion: '1.0.0', + }), + { + key: 'hyperdrive', + kind: 'hyperdrive', + id: 'terraform-id', + name: 'void-example-database', + ownership: 'adopted', + state: 'ready', + configHash: '0'.repeat(64), + }, + '2026-09-25T00:00:00.000Z', + ); + const resumed = await createPlatformInstallPlan(fake, input, recorded); + expect(resumed.actions.find((action) => action.key === 'hyperdrive')).toMatchObject({ + action: 'reuse', + id: 'terraform-id', + }); + await expect( + createPlatformInstallPlan( + fake, + { ...input, externalHyperdrive: { ...externalHyperdrive, id: 'other' } }, + null, + ), + ).rejects.toThrow('must exist'); + }); it.each(['install', 'upgrade'] as const)( 'rejects incomplete shared email settings during %s', async (mode) => {