Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/guide/platform/installation/prerequisites.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<installation-name>-database`, point to the same database, and have SQL result caching disabled. Void records its identity for later maintenance without taking ownership of it.
2 changes: 2 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions packages/void/skills/void/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ Use `--runtime <directory>` 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 <earlier>` 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-<installation-name>-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.
Expand Down
62 changes: 60 additions & 2 deletions packages/void/src/cli/platform-auth-database.ts
Original file line number Diff line number Diff line change
@@ -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<PlatformCloudflareClient, 'getHyperdriveConfig' | 'disableHyperdriveCaching'>,
Expand All @@ -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.',
Expand Down
50 changes: 43 additions & 7 deletions packages/void/src/cli/platform-installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
20 changes: 20 additions & 0 deletions packages/void/src/cli/platform-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,17 @@ export async function runPlatformLifecycleCommand(args: PlatformArgs): Promise<v
}
const checkpoint = existing ? readPendingInstallationSecrets(existing) : null;
const databaseUrl = resolvePlatformInstallDatabaseUrl(checkpoint, existing);
const externalHyperdriveId = process.env.VOID_PLATFORM_HYPERDRIVE_ID?.trim() || '';
const externalHyperdriveHost = process.env.VOID_PLATFORM_HYPERDRIVE_ORIGIN_HOST?.trim() || '';
const externalHyperdriveUser = process.env.VOID_PLATFORM_HYPERDRIVE_ORIGIN_USER?.trim() || '';
if (
[externalHyperdriveId, externalHyperdriveHost, externalHyperdriveUser].some(Boolean) &&
![externalHyperdriveId, externalHyperdriveHost, externalHyperdriveUser].every(Boolean)
) {
throw new Error(
'platform: VOID_PLATFORM_HYPERDRIVE_ID, VOID_PLATFORM_HYPERDRIVE_ORIGIN_HOST, and VOID_PLATFORM_HYPERDRIVE_ORIGIN_USER must be set together.',
);
}
let authoritativeProvisionIntents = false;
if (existing && !isPlatformSetupDraft(existing)) {
if (!databaseUrl) {
Expand Down Expand Up @@ -433,6 +444,15 @@ export async function runPlatformLifecycleCommand(args: PlatformArgs): Promise<v
controlPlaneDomain,
dedicatedZone,
displayName,
...(externalHyperdriveId
? {
externalHyperdrive: {
id: externalHyperdriveId,
host: externalHyperdriveHost,
user: externalHyperdriveUser,
},
}
: {}),
...(process.env.VOID_EMAIL_SHARED_ZONE_ID
? { emailSharedZoneId: process.env.VOID_EMAIL_SHARED_ZONE_ID }
: {}),
Expand Down
11 changes: 10 additions & 1 deletion packages/void/src/cli/platform-management.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from '@void/platform';
import { createSpinner } from './output.ts';
import type { PlatformArgs } from './platform-args.ts';
import { platformDatabaseNameFromOwnerUrl } from './platform-auth-database.ts';
import {
assertPlatformOperatorCredentialCanApplyPlan,
resolvePlatformCloudflareCredential,
Expand Down Expand Up @@ -1236,7 +1237,7 @@ async function verifyPlatformMutationOwnership(
.update(JSON.stringify({ kind: resource.kind, name: resource.name }))
.digest('hex');
const malformedLegacyHash = !/^[a-f0-9]{64}$/.test(resource.configHash);
if (options.databaseUrl) {
if (options.databaseUrl && resource.ownership !== 'adopted') {
const expected = hyperdriveOriginFromConnectionString(options.databaseUrl);
if (!hyperdriveOriginMatches(live.origin, expected)) {
throw ownershipError(
Expand All @@ -1254,6 +1255,14 @@ async function verifyPlatformMutationOwnership(
);
}
} else if (
resource.ownership === 'adopted' &&
(resource.configHash !== hyperdriveOriginConfigHash(live.origin) ||
(options.databaseUrl &&
live.origin.database !== platformDatabaseNameFromOwnerUrl(options.databaseUrl)))
) {
throw ownershipError(`hyperdrive ${resource.name} changed configuration`);
} else if (
resource.ownership !== 'adopted' &&
resource.configHash !== hyperdriveOriginConfigHash(live.origin) &&
resource.configHash !== legacyHash &&
!malformedLegacyHash
Expand Down
Loading
Loading