Skip to content

Commit b993b99

Browse files
authored
Fix Cloudflare platform installation, native readiness, auth forms, and shared email (#357)
Self-hosted Cloudflare installation and native custom-domain deployment exposed failures in authentication forms, recovery, candidate readiness, and shared inbound email. This change fixes those paths and adds guarded acceptance fixtures and regressions. - Verify actual native Worker versions and custom-domain readiness, preserve existing bindings/triggers, and publish pinned secrets before initial readiness. Apps remain Workers Free compatible unless they opt into a paid feature. - Fix installation TLS validation and unnamed Access identity providers; preserve a configured dashboard origin, reconcile interrupted cutovers, and bound durable email bootstrap retries. - Make setup, administrator, and account-link forms submit with the correct origin while retaining exact-origin checks; handle dashboard discovery and OAuth failures safely. - Provision literal shared-email recipients with durable ownership gates, verify plus addressing, preserve unrelated rules, and fence deletion/slug reuse until teardown is verified. Migration 0056 preserves upstream 0054/0055 and declares only its exact tested canonical predecessor as rollback safe. - Add guarded runtime acceptance fixtures and move temporary test apps outside workspace package scans. Install Chromium in the CI jobs that execute native browser forms, and verify the intentional canonical schema conversion without relaxing other preserved data checks. - Emit each inferred Durable Object binding and migration tag once, preserve authored migration chronology and external bindings, and reject incompatible Sandbox namespace storage before any provisioning or entitlement request. Validation at head ee32f5b: - Local CI=true vp run test:all passed: formatting, lint, builds, all 28 package checks, 653 test files and 12,150 tests; 25 skipped. Three optional Deno cases skip an explicit unsupported user npm configuration; Node/Bun and Chromium cases execute. - The Durable Object/Sandbox batch passed 700 focused unit tests and 14 real plugin/deployment build regressions. Combined and repeated builds preserve external bindings and authored migration order; Free apps emit no paid bindings. - Canonical migrations 0054/0055/0056 passed against disposable PostgreSQL seeded from the immutable previous release b4a4749. Its projects/secrets API suites passed 137/137 against the upgraded database; 27 fixture/baseline regressions passed. - Live isolated installation and native custom-domain dashboard deployment succeeded. Eleven public health/access checks, ten additional anonymous GET checks and 50 anonymous feature authorization checks passed. Genuine Cloudflare OIDC reached the first-administrator review. - Exact-head GitHub Actions CI, Platform CI (including schema compatibility), Security Analysis and both npm/pnpm Prepublish Test jobs passed. The main-only live-e2e job was skipped; its status does not establish live acceptance. Independent native runtime acceptance is still in progress. - The separate external Workers Builds check for void-cloud failed (build ad6c6447-cb4d-40a2-8fda-324a8a9941d9). Its production-account logs are unavailable in the logged-in test account, and no failure cause or production deployment success is claimed. Managed live acceptance remains incomplete: the built-in browser blocks the approved /auth/bootstrap/confirm submission with net::ERR_BLOCKED_BY_CLIENT before a server response. Current readback has zero users and an expired unconsumed bootstrap. Genuine user signup/deployments, admin/user management, teams, real email receipt, managed Zero Trust, user queue consumption and Sandbox execution have not passed live acceptance. Prepared runners are not executed tests. The isolated live installation retains its earlier fork migration history; this canonical runtime has not been applied to that fork. Credentials, private recovery files and workstation acceptance logs are excluded.
1 parent 9eab21e commit b993b99

164 files changed

Lines changed: 17745 additions & 749 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/scripts/schema-fixture.mjs‎

Lines changed: 97 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -4,62 +4,106 @@ import { readFileSync, writeFileSync } from 'node:fs';
44
import { createRequire } from 'node:module';
55
import { pathToFileURL } from 'node:url';
66

7-
// Resolve the driver from the checkout being tested, even when this script was
8-
// saved in RUNNER_TEMP before Actions checked out another immutable revision.
9-
const { Pool } = createRequire(
10-
pathToFileURL(`${process.cwd()}/platform/packages/api/package.json`),
11-
)('pg');
12-
const pool = new Pool({ connectionString: process.env.COMPAT_DATABASE_URL });
13-
const snapshot =
14-
process.env.COMPAT_FIXTURE_PATH ??
15-
(process.env.RUNNER_TEMP ? `${process.env.RUNNER_TEMP}/void-schema-fixture.json` : undefined);
16-
if (!snapshot || !process.env.COMPAT_DATABASE_URL) {
17-
throw new Error('A disposable compatibility database and fixture path are required.');
7+
const PROJECT_MODE_MIGRATION = {
8+
hash: '8f4452ea39f4fb6504fd6384cdc73634b1f38b4abfde604a366f83a38163d252',
9+
timestamp: 1790841384175,
10+
};
11+
12+
export function isProjectModeMigrationApplied(rows) {
13+
return (
14+
rows.length === 1 &&
15+
rows[0].hash === PROJECT_MODE_MIGRATION.hash &&
16+
String(rows[0].created_at) === String(PROJECT_MODE_MIGRATION.timestamp)
17+
);
1818
}
19-
try {
20-
if (process.argv[2] === 'seed') {
21-
await pool.query(
22-
"INSERT INTO users (id, provider, provider_id, login, email, role, plan) VALUES ('user_compat', 'github', 'compat', 'compat', 'compat@example.invalid', 'admin', 'custom')",
23-
);
24-
await pool.query(
25-
"INSERT INTO projects (id, user_id, slug) VALUES ('proj_compat', 'user_compat', 'schema-compat-fixture')",
26-
);
27-
// Test-only key and nonce; match the platform's AES-GCM storage format.
28-
const iv = Buffer.alloc(12, 1);
29-
const cipher = createCipheriv('aes-256-gcm', Buffer.alloc(32, 7), iv);
30-
const ciphertext = Buffer.concat([
31-
cipher.update('schema compatibility fixture'),
32-
cipher.final(),
33-
cipher.getAuthTag(),
34-
]).toString('base64');
35-
await pool.query(
36-
"INSERT INTO project_secrets (id, project_id, name, ciphertext, iv, key_version) VALUES ('psc_compat', 'proj_compat', 'COMPAT_SECRET', $1, $2, '1')",
37-
[ciphertext, iv.toString('base64')],
38-
);
39-
} else if (process.argv[2] !== 'verify') {
40-
throw new Error('Use seed or verify.');
19+
20+
export function assertSchemaFixturePreserved(original, rows, projectModeMigrationApplied = false) {
21+
for (const [table, row] of Object.entries(original)) {
22+
for (const [key, value] of Object.entries(row)) {
23+
// 0054 deliberately normalizes the obsolete Worker mode to the public
24+
// Void mode. Permit only this exact conversion after its immutable SQL
25+
// was applied; every other pre-existing value must remain identical.
26+
const expected =
27+
projectModeMigrationApplied &&
28+
table === 'projects' &&
29+
row.id === 'proj_compat' &&
30+
key === 'mode' &&
31+
value === 'worker'
32+
? 'void'
33+
: value;
34+
assert.deepEqual(
35+
rows[table]?.[key],
36+
expected,
37+
`${table}.${key}: upgrade changed existing data`,
38+
);
39+
}
40+
}
41+
}
42+
43+
export async function runSchemaFixture(command, env = process.env) {
44+
const snapshot =
45+
env.COMPAT_FIXTURE_PATH ??
46+
(env.RUNNER_TEMP ? `${env.RUNNER_TEMP}/void-schema-fixture.json` : undefined);
47+
if (!snapshot || !env.COMPAT_DATABASE_URL) {
48+
throw new Error('A disposable compatibility database and fixture path are required.');
4149
}
42-
const rows = {};
43-
for (const [table, id] of [
44-
['users', 'user_compat'],
45-
['projects', 'proj_compat'],
46-
['project_secrets', 'psc_compat'],
47-
]) {
48-
const result = await pool.query(`SELECT * FROM ${table} WHERE id = $1`, [id]);
49-
assert.equal(result.rows.length, 1, `${table}: upgrade lost the compatibility fixture`);
50-
rows[table] = JSON.parse(JSON.stringify(result.rows[0]));
50+
if (command !== 'seed' && command !== 'verify') {
51+
throw new Error('Use seed or verify.');
5152
}
52-
if (process.argv[2] === 'seed') {
53-
writeFileSync(snapshot, JSON.stringify(rows), { mode: 0o600 });
54-
} else {
55-
const original = JSON.parse(readFileSync(snapshot, 'utf8'));
56-
for (const [table, row] of Object.entries(original)) {
57-
for (const [key, value] of Object.entries(row)) {
58-
assert.deepEqual(rows[table][key], value, `${table}.${key}: upgrade changed existing data`);
59-
}
53+
// Resolve the driver from the checkout being tested, even when this script
54+
// was saved in RUNNER_TEMP before checking out another immutable revision.
55+
const { Pool } = createRequire(
56+
pathToFileURL(`${process.cwd()}/platform/packages/api/package.json`),
57+
)('pg');
58+
const pool = new Pool({ connectionString: env.COMPAT_DATABASE_URL });
59+
try {
60+
if (command === 'seed') {
61+
await pool.query(
62+
"INSERT INTO users (id, provider, provider_id, login, email, role, plan) VALUES ('user_compat', 'github', 'compat', 'compat', 'compat@example.invalid', 'admin', 'custom')",
63+
);
64+
await pool.query(
65+
"INSERT INTO projects (id, user_id, slug) VALUES ('proj_compat', 'user_compat', 'schema-compat-fixture')",
66+
);
67+
// Test-only key and nonce; match the platform's AES-GCM storage format.
68+
const iv = Buffer.alloc(12, 1);
69+
const cipher = createCipheriv('aes-256-gcm', Buffer.alloc(32, 7), iv);
70+
const ciphertext = Buffer.concat([
71+
cipher.update('schema compatibility fixture'),
72+
cipher.final(),
73+
cipher.getAuthTag(),
74+
]).toString('base64');
75+
await pool.query(
76+
"INSERT INTO project_secrets (id, project_id, name, ciphertext, iv, key_version) VALUES ('psc_compat', 'proj_compat', 'COMPAT_SECRET', $1, $2, '1')",
77+
[ciphertext, iv.toString('base64')],
78+
);
79+
}
80+
const rows = {};
81+
for (const [table, id] of [
82+
['users', 'user_compat'],
83+
['projects', 'proj_compat'],
84+
['project_secrets', 'psc_compat'],
85+
]) {
86+
const result = await pool.query(`SELECT * FROM ${table} WHERE id = $1`, [id]);
87+
assert.equal(result.rows.length, 1, `${table}: upgrade lost the compatibility fixture`);
88+
rows[table] = JSON.parse(JSON.stringify(result.rows[0]));
6089
}
61-
console.log('Upgrade preserved the previous runtime user, project, and encrypted secret.');
90+
if (command === 'seed') {
91+
writeFileSync(snapshot, JSON.stringify(rows), { mode: 0o600 });
92+
} else {
93+
const original = JSON.parse(readFileSync(snapshot, 'utf8'));
94+
const migration = await pool.query(
95+
'SELECT "hash", "created_at" FROM "drizzle"."__drizzle_migrations" WHERE "created_at" = $1',
96+
[PROJECT_MODE_MIGRATION.timestamp],
97+
);
98+
const projectModeMigrationApplied = isProjectModeMigrationApplied(migration.rows);
99+
assertSchemaFixturePreserved(original, rows, projectModeMigrationApplied);
100+
console.log('Upgrade preserved the previous runtime user, project, and encrypted secret.');
101+
}
102+
} finally {
103+
await pool.end();
62104
}
63-
} finally {
64-
await pool.end();
105+
}
106+
107+
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
108+
await runSchemaFixture(process.argv[2]);
65109
}

‎.github/workflows/platform-ci.yml‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,12 @@ jobs:
7878
with:
7979
cache: true
8080
node-version-file: .node-version
81+
- name: Install Chromium for native API forms
82+
if: ${{ startsWith(matrix.project, 'platform-api-integration') && github.repository == 'voidzero-dev/void' }}
83+
run: vp exec playwright install chromium
84+
- name: Install Chromium and system dependencies for native API forms
85+
if: ${{ startsWith(matrix.project, 'platform-api-integration') && github.repository != 'voidzero-dev/void' }}
86+
run: vp exec playwright install --with-deps chromium
8187
- run: vp run build
8288
- run: vp run --filter './platform/packages/**' build
8389
- run: vp test run --project '${{ matrix.project }}*'
@@ -168,6 +174,12 @@ jobs:
168174
with:
169175
cache: true
170176
node-version-file: .node-version
177+
- name: Install Chromium for deployed runtime API forms
178+
if: ${{ github.repository == 'voidzero-dev/void' }}
179+
run: vp exec playwright install chromium
180+
- name: Install Chromium and system dependencies for deployed runtime API forms
181+
if: ${{ github.repository != 'voidzero-dev/void' }}
182+
run: vp exec playwright install --with-deps chromium
171183
- name: Run the deployed runtime against the upgraded schema
172184
run: |
173185
vp run build

‎docs/guide/email.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ void email domain add acme.com
7777

7878
`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.
7979

80-
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.
80+
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.
8181

8282
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.
8383

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

397397
On an email-enabled platform, `<slug>+anything@<mail domain>` reaches your
398-
deployed `email/` handlers without per-project DNS setup. A
398+
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
399399
[registered custom domain](#your-own-domain-on-the-platform) reaches those
400400
handlers once its inbound readiness is `ready`. Use `void email domain status`
401401
to check current provider routing and any remaining setup steps.

‎docs/guide/platform/administration/email.md‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ outline: deep
66

77
## Managing Email
88

9-
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.
9+
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.
10+
11+
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.
1012

1113
### Deciding who mail may reach
1214

@@ -75,16 +77,16 @@ void platform email settings-set --domains admin
7577
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:
7678

7779
```sh
78-
void platform email domain-add mail.example.com --project hr-portal
79-
void platform email domain-status mail.example.com
80-
void platform email domain-rotate-secret mail.example.com
80+
void platform email domain-add example.com --project hr-portal
81+
void platform email domain-status example.com
82+
void platform email domain-rotate-secret example.com
8183
void platform email domains
8284
```
8385

84-
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:
86+
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:
8587

8688
```sh
87-
void platform email domain-add mail.other.example --project hr-portal --token-stdin --yes < token.txt
89+
void platform email domain-add other.example --project hr-portal --token-stdin --yes < token.txt
8890
```
8991

9092
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.
@@ -97,7 +99,7 @@ to finish the retained cleanup. If its token has expired or been revoked, provid
9799
a replacement scoped to the same account and zone:
98100

99101
```sh
100-
void platform email domain-remove mail.other.example --token-stdin --yes < token.txt
102+
void platform email domain-remove other.example --token-stdin --yes < token.txt
101103
```
102104

103105
This recovery is available only after project deletion has begun and no other

‎docs/guide/platform/installation/credentials.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,11 @@ void platform upgrade your-installation-id
5757

5858
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.
5959

60+
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.
61+
6062
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.
6163

62-
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>`.
64+
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.
6365

6466
## R2 Upload Credentials
6567

‎docs/guide/platform/installation/domains.md‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ If nameservers, certificates, or project Zero Trust protection are pending, foll
1919

2020
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.
2121

22+
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.
23+
2224
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.
2325

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

5052
:::
5153

54+
## Optional Dashboard
55+
56+
The API's `/admin/` pages are included in the core installation. You can deploy
57+
the optional user dashboard separately and configure its HTTPS origin during
58+
installation with `--dashboard-url https://dash.example.com`.
59+
60+
For an existing installation, preview and apply the configuration with:
61+
62+
```sh
63+
void platform repair <installation-id> --dashboard-url https://dash.example.com --plan
64+
void platform repair <installation-id> --dashboard-url https://dash.example.com --yes
65+
```
66+
67+
The origin must contain no credentials, path, query, or fragment. Void saves it
68+
for login callbacks and keeps it across upgrades and repairs. The command does
69+
not create a dashboard Worker or DNS records; deploy that app separately. Omit
70+
the option to keep the saved origin. The dashboard provides sign-in, linked
71+
login methods, and sign-out; use the CLI for user project and team management.
72+
73+
If Access protects the platform, its application must cover this dashboard
74+
origin too. Configure the origin when installing protection. To change it on an
75+
already protected platform, deliberately remove protection through authentication
76+
configuration, apply the origin, then enable protection again. Choose a separate
77+
admission rule first if signup depends on the Access gate. Maintenance stops if
78+
the configured origin is outside the active coverage.
79+
5280
## Cloudflare footprint
5381

5482
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.

0 commit comments

Comments
 (0)