Skip to content
Merged
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
150 changes: 97 additions & 53 deletions .github/scripts/schema-fixture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,62 +4,106 @@ import { readFileSync, writeFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { pathToFileURL } from 'node:url';

// Resolve the driver from the checkout being tested, even when this script was
// saved in RUNNER_TEMP before Actions checked out another immutable revision.
const { Pool } = createRequire(
pathToFileURL(`${process.cwd()}/platform/packages/api/package.json`),
)('pg');
const pool = new Pool({ connectionString: process.env.COMPAT_DATABASE_URL });
const snapshot =
process.env.COMPAT_FIXTURE_PATH ??
(process.env.RUNNER_TEMP ? `${process.env.RUNNER_TEMP}/void-schema-fixture.json` : undefined);
if (!snapshot || !process.env.COMPAT_DATABASE_URL) {
throw new Error('A disposable compatibility database and fixture path are required.');
const PROJECT_MODE_MIGRATION = {
hash: '8f4452ea39f4fb6504fd6384cdc73634b1f38b4abfde604a366f83a38163d252',
timestamp: 1790841384175,
};

export function isProjectModeMigrationApplied(rows) {
return (
rows.length === 1 &&
rows[0].hash === PROJECT_MODE_MIGRATION.hash &&
String(rows[0].created_at) === String(PROJECT_MODE_MIGRATION.timestamp)
);
}
try {
if (process.argv[2] === 'seed') {
await pool.query(
"INSERT INTO users (id, provider, provider_id, login, email, role, plan) VALUES ('user_compat', 'github', 'compat', 'compat', 'compat@example.invalid', 'admin', 'custom')",
);
await pool.query(
"INSERT INTO projects (id, user_id, slug) VALUES ('proj_compat', 'user_compat', 'schema-compat-fixture')",
);
// Test-only key and nonce; match the platform's AES-GCM storage format.
const iv = Buffer.alloc(12, 1);
const cipher = createCipheriv('aes-256-gcm', Buffer.alloc(32, 7), iv);
const ciphertext = Buffer.concat([
cipher.update('schema compatibility fixture'),
cipher.final(),
cipher.getAuthTag(),
]).toString('base64');
await pool.query(
"INSERT INTO project_secrets (id, project_id, name, ciphertext, iv, key_version) VALUES ('psc_compat', 'proj_compat', 'COMPAT_SECRET', $1, $2, '1')",
[ciphertext, iv.toString('base64')],
);
} else if (process.argv[2] !== 'verify') {
throw new Error('Use seed or verify.');

export function assertSchemaFixturePreserved(original, rows, projectModeMigrationApplied = false) {
for (const [table, row] of Object.entries(original)) {
for (const [key, value] of Object.entries(row)) {
// 0054 deliberately normalizes the obsolete Worker mode to the public
// Void mode. Permit only this exact conversion after its immutable SQL
// was applied; every other pre-existing value must remain identical.
const expected =
projectModeMigrationApplied &&
table === 'projects' &&
row.id === 'proj_compat' &&
key === 'mode' &&
value === 'worker'
? 'void'
: value;
assert.deepEqual(
rows[table]?.[key],
expected,
`${table}.${key}: upgrade changed existing data`,
);
}
}
}

export async function runSchemaFixture(command, env = process.env) {
const snapshot =
env.COMPAT_FIXTURE_PATH ??
(env.RUNNER_TEMP ? `${env.RUNNER_TEMP}/void-schema-fixture.json` : undefined);
if (!snapshot || !env.COMPAT_DATABASE_URL) {
throw new Error('A disposable compatibility database and fixture path are required.');
}
const rows = {};
for (const [table, id] of [
['users', 'user_compat'],
['projects', 'proj_compat'],
['project_secrets', 'psc_compat'],
]) {
const result = await pool.query(`SELECT * FROM ${table} WHERE id = $1`, [id]);
assert.equal(result.rows.length, 1, `${table}: upgrade lost the compatibility fixture`);
rows[table] = JSON.parse(JSON.stringify(result.rows[0]));
if (command !== 'seed' && command !== 'verify') {
throw new Error('Use seed or verify.');
}
if (process.argv[2] === 'seed') {
writeFileSync(snapshot, JSON.stringify(rows), { mode: 0o600 });
} else {
const original = JSON.parse(readFileSync(snapshot, 'utf8'));
for (const [table, row] of Object.entries(original)) {
for (const [key, value] of Object.entries(row)) {
assert.deepEqual(rows[table][key], value, `${table}.${key}: upgrade changed existing data`);
}
// Resolve the driver from the checkout being tested, even when this script
// was saved in RUNNER_TEMP before checking out another immutable revision.
const { Pool } = createRequire(
pathToFileURL(`${process.cwd()}/platform/packages/api/package.json`),
)('pg');
const pool = new Pool({ connectionString: env.COMPAT_DATABASE_URL });
try {
if (command === 'seed') {
await pool.query(
"INSERT INTO users (id, provider, provider_id, login, email, role, plan) VALUES ('user_compat', 'github', 'compat', 'compat', 'compat@example.invalid', 'admin', 'custom')",
);
await pool.query(
"INSERT INTO projects (id, user_id, slug) VALUES ('proj_compat', 'user_compat', 'schema-compat-fixture')",
);
// Test-only key and nonce; match the platform's AES-GCM storage format.
const iv = Buffer.alloc(12, 1);
const cipher = createCipheriv('aes-256-gcm', Buffer.alloc(32, 7), iv);
const ciphertext = Buffer.concat([
cipher.update('schema compatibility fixture'),
cipher.final(),
cipher.getAuthTag(),
]).toString('base64');
await pool.query(
"INSERT INTO project_secrets (id, project_id, name, ciphertext, iv, key_version) VALUES ('psc_compat', 'proj_compat', 'COMPAT_SECRET', $1, $2, '1')",
[ciphertext, iv.toString('base64')],
);
}
const rows = {};
for (const [table, id] of [
['users', 'user_compat'],
['projects', 'proj_compat'],
['project_secrets', 'psc_compat'],
]) {
const result = await pool.query(`SELECT * FROM ${table} WHERE id = $1`, [id]);
assert.equal(result.rows.length, 1, `${table}: upgrade lost the compatibility fixture`);
rows[table] = JSON.parse(JSON.stringify(result.rows[0]));
}
console.log('Upgrade preserved the previous runtime user, project, and encrypted secret.');
if (command === 'seed') {
writeFileSync(snapshot, JSON.stringify(rows), { mode: 0o600 });
} else {
const original = JSON.parse(readFileSync(snapshot, 'utf8'));
const migration = await pool.query(
'SELECT "hash", "created_at" FROM "drizzle"."__drizzle_migrations" WHERE "created_at" = $1',
[PROJECT_MODE_MIGRATION.timestamp],
);
const projectModeMigrationApplied = isProjectModeMigrationApplied(migration.rows);
assertSchemaFixturePreserved(original, rows, projectModeMigrationApplied);
console.log('Upgrade preserved the previous runtime user, project, and encrypted secret.');
}
} finally {
await pool.end();
}
} finally {
await pool.end();
}

if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
await runSchemaFixture(process.argv[2]);
}
12 changes: 12 additions & 0 deletions .github/workflows/platform-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ jobs:
with:
cache: true
node-version-file: .node-version
- name: Install Chromium for native API forms
if: ${{ startsWith(matrix.project, 'platform-api-integration') && github.repository == 'voidzero-dev/void' }}
run: vp exec playwright install chromium
- name: Install Chromium and system dependencies for native API forms
if: ${{ startsWith(matrix.project, 'platform-api-integration') && github.repository != 'voidzero-dev/void' }}
run: vp exec playwright install --with-deps chromium
- run: vp run build
- run: vp run --filter './platform/packages/**' build
- run: vp test run --project '${{ matrix.project }}*'
Expand Down Expand Up @@ -168,6 +174,12 @@ jobs:
with:
cache: true
node-version-file: .node-version
- name: Install Chromium for deployed runtime API forms
if: ${{ github.repository == 'voidzero-dev/void' }}
run: vp exec playwright install chromium
- name: Install Chromium and system dependencies for deployed runtime API forms
if: ${{ github.repository != 'voidzero-dev/void' }}
run: vp exec playwright install --with-deps chromium
- name: Run the deployed runtime against the upgraded schema
run: |
vp run build
Expand Down
4 changes: 2 additions & 2 deletions docs/guide/email.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ void email domain add acme.com

`add` opens a Cloudflare API-token template. Restrict the token to the selected account and mail zone before creating it; Workers Scripts permission applies across that account. The CLI accepts a masked paste or a newly copied token and asks you to confirm those restrictions. The platform encrypts the credential for the zone connection, so projects sharing that zone do not need separate ingress Workers.

When an apex already receives mail, the CLI proposes `mail.<domain>`. Use `--subdomain <label|host>` to choose another mail subdomain. Void refuses to replace a foreign enabled catch-all. A domain belongs to one project; multiple exact domains can share a zone, and a project can register more than one domain.
Platform-managed custom inbound email requires the apex of a Cloudflare zone. Cloudflare’s catch-all only covers that apex, so a subdomain of the selected zone cannot receive arbitrary addresses. Choose an apex without another mail provider, or use the platform’s shared mail address. Void preserves foreign MX records and enabled catch-alls. Each custom apex belongs to one project; a project can register more than one apex.

Setup returns an operation ID. If your connection drops, the platform keeps the recorded operation and the CLI checks that operation's status. An uncertain Cloudflare write stops conflicting changes until its outcome can be established.

Expand Down Expand Up @@ -395,7 +395,7 @@ remain available through that domain's sending capability.
### Configuring inbound delivery

On an email-enabled platform, `<slug>+anything@<mail domain>` reaches your
deployed `email/` handlers without per-project DNS setup. A
deployed `email/` handlers without per-project DNS setup once the platform has verified your recipient rule and enabled plus addressing. Deployment reports a routing conflict or capacity limit if setup cannot complete. A
[registered custom domain](#your-own-domain-on-the-platform) reaches those
handlers once its inbound readiness is `ready`. Use `void email domain status`
to check current provider routing and any remaining setup steps.
Expand Down
16 changes: 9 additions & 7 deletions docs/guide/platform/administration/email.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ outline: deep

## Managing Email

These commands apply to a platform that enables email. Every project can send from and receive at `<slug>+tag@<mail domain>` on the platform's shared mail domain; the [email guide](/guide/email) describes what applications do with that.
These commands apply to a platform that enables email. Every project can send from `<slug>+tag@<mail domain>` on the platform’s shared mail domain and receive there once its recipient rule is ready; the [email guide](/guide/email) describes what applications do with that.

Shared inbound mail uses one exact routing rule per project and enables Cloudflare’s per-zone plus addressing. Existing rules and the apex catch-all remain in place. Cloudflare normally allows [200 routing rules per domain](https://developers.cloudflare.com/email-service/platform/limits/); remove unused rules or request a higher limit if the shared domain reaches capacity. An unrelated rule for a project’s base address or a plus address blocks its setup until that conflict is removed.

### Deciding who mail may reach

Expand Down Expand Up @@ -75,16 +77,16 @@ void platform email settings-set --domains admin
Then register a domain for a project. The zone must be in the platform's Cloudflare account; the platform's own credential does the setup, and no Cloudflare credential is stored per domain:

```sh
void platform email domain-add mail.example.com --project hr-portal
void platform email domain-status mail.example.com
void platform email domain-rotate-secret mail.example.com
void platform email domain-add example.com --project hr-portal
void platform email domain-status example.com
void platform email domain-rotate-secret example.com
void platform email domains
```

Name the exact mail domain: a subdomain such as `mail.example.com` when the apex already receives mail, otherwise the apex itself. `domain-status` reports inbound, outbound, and management readiness separately. Follow any required DNS or Cloudflare dashboard step, then use `domain-sync` to reconcile. Use `domain-rotate-secret` when you need to replace the zone ingress credential explicitly. Email Sending onboarding needs Workers Paid; after upgrading, `domain-sync` re-attempts it. For a zone in another Cloudflare account, pipe an API token for that account on standard input:
Use the apex of the selected Cloudflare zone. Platform-managed custom inbound email uses its catch-all, which does not cover subdomains; choose an apex without another mail provider or use the platform’s shared mail address. `domain-status` reports inbound, outbound, and management readiness separately. Follow any required DNS or Cloudflare dashboard step, then use `domain-sync` to reconcile. Use `domain-rotate-secret` when you need to replace the zone ingress credential explicitly. Email Sending onboarding needs Workers Paid; after upgrading, `domain-sync` re-attempts it. For a zone in another Cloudflare account, pipe an API token for that account on standard input:

```sh
void platform email domain-add mail.other.example --project hr-portal --token-stdin --yes < token.txt
void platform email domain-add other.example --project hr-portal --token-stdin --yes < token.txt
```

Project owners see administrator-registered domains in `void email domain list` and `status`; `status` names the `void platform email` command for any step they cannot take themselves, and `sync` and `remove` point them at `domain-sync` and `domain-remove`. A domain an owner registered before you switched to `admin` stays theirs to renew, sync and remove. The runtime token needs the email permissions listed in the [self-hosting guide](/guide/platform/installation/credentials#runtime-token-permissions) for this to work.
Expand All @@ -97,7 +99,7 @@ to finish the retained cleanup. If its token has expired or been revoked, provid
a replacement scoped to the same account and zone:

```sh
void platform email domain-remove mail.other.example --token-stdin --yes < token.txt
void platform email domain-remove other.example --token-stdin --yes < token.txt
```

This recovery is available only after project deletion has begun and no other
Expand Down
4 changes: 3 additions & 1 deletion docs/guide/platform/installation/credentials.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,11 @@ void platform upgrade your-installation-id

Set the same two variables before `void platform install` to enable email during a new installation. Void records the pair for later upgrades; an ordinary upgrade cannot replace it.

Before installing, enable [Cloudflare Email Routing](https://developers.cloudflare.com/email-service/get-started/route-emails/) for the exact shared sender domain and confirm that its MX records point to Cloudflare. For a subdomain, add that name under the zone's **Email Routing → Settings → Subdomains**. Cloudflare adds the required MX and SPF records. The installer's shared-mail bootstrap verifies those records; it does not add them. Keep existing mail-provider records on other domain names in place.

Enabling email lets administrators [register email domains for projects](/guide/platform/administration/email#registering-email-domains-for-projects) and lets projects register destination addresses through the runtime token. That needs **Email Routing Addresses: Edit** and **Email Sending: Edit** on the account, plus **Zone: Read**, **Zone Settings: Edit** and **Email Routing Rules: Edit** on the zones that will carry mail. The runtime-token link preselects them when email is enabled. Email Sending onboarding for arbitrary recipients needs Workers Paid.

The installer deploys the email gateway, prepares the shared mail route, and verifies inbound readiness before opening platform traffic. If setup fails, correct the reported permission, mail-zone configuration, or routing conflict, then rerun the same install or upgrade command. A fresh install resumes with `void platform install --resume --name <installation-id>`.
The installer deploys the email gateway, prepares the shared mail route, and verifies inbound readiness before opening platform traffic. If setup fails, correct the reported permission, exact-domain MX records, or routing conflict, then rerun the same install or upgrade command. A fresh install resumes with `void platform install --resume --name <installation-id>`; it continues the recorded email operation after the configuration is corrected.

## R2 Upload Credentials

Expand Down
28 changes: 28 additions & 0 deletions docs/guide/platform/installation/domains.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ If nameservers, certificates, or project Zero Trust protection are pending, foll

Use `--installation <id>` to select an installation explicitly, `--zone example.com` for an app domain such as `apps.example.com`, or `--dedicated-zone` for catch-all routing on a dedicated zone. Nested domains still need the wildcard certificate described below. This command adds the first domain; replacing an existing application domain is not currently supported. It uses the installed runtime and does not require `--runtime` or an app redeploy.

Choose an application domain whose wildcard leaves existing Worker Custom Domains reachable. Existing more-specific routes covering all requests can preserve those hostnames. If Void reports a conflict, choose another application domain; for a new installation, you can start with `void platform install --workers-dev` and add a suitable domain later.

Browser login sessions are specific to each origin. Apps using their own OAuth providers may need to register their new callback URLs. Void's built-in auth uses the request origin automatically unless the app overrides that configuration.

### What Changes in Testing Mode?
Expand Down Expand Up @@ -49,6 +51,32 @@ The management token needs **SSL and Certificates: Read** (or Edit) on that zone

:::

## Optional Dashboard

The API's `/admin/` pages are included in the core installation. You can deploy
the optional user dashboard separately and configure its HTTPS origin during
installation with `--dashboard-url https://dash.example.com`.

For an existing installation, preview and apply the configuration with:

```sh
void platform repair <installation-id> --dashboard-url https://dash.example.com --plan
void platform repair <installation-id> --dashboard-url https://dash.example.com --yes
```

The origin must contain no credentials, path, query, or fragment. Void saves it
for login callbacks and keeps it across upgrades and repairs. The command does
not create a dashboard Worker or DNS records; deploy that app separately. Omit
the option to keep the saved origin. The dashboard provides sign-in, linked
login methods, and sign-out; use the CLI for user project and team management.

If Access protects the platform, its application must cover this dashboard
origin too. Configure the origin when installing protection. To change it on an
already protected platform, deliberately remove protection through authentication
configuration, apply the origin, then enable protection again. Choose a separate
admission rule first if signup depends on the Access gate. Maintenance stops if
the configured origin is outside the active coverage.

## Cloudflare footprint

New platform resources use deterministic `void-<installation-name>-<role>` names where Cloudflare allows them, such as `void-team-api`. Choose an unused installation name in the account; Void stops on an unowned name conflict instead of replacing that resource. Existing installations keep their recorded names, including older names with suffixes.
Expand Down
Loading
Loading