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
6 changes: 5 additions & 1 deletion docs/guide/email/domains.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ Use a mail subdomain to keep existing mail on `acme.com` with its current provid

Sign in with `void cloudflare login`, or use a `CLOUDFLARE_API_TOKEN` with **Email Routing Edit** and **Email Sending Edit** in addition to deploy permissions. If your browser session lacks email permissions, log out and sign in again. Global API Keys aren't supported.

Run `void deploy --platform cloudflare`. Void shows the domain, routing, sending, DNS, and handler addresses it would configure. Accept to set up email and deploy. Later deploys reuse the setup. Without `email.from`, or if you decline the initial setup, the app deploys without email.
Run `void deploy --platform cloudflare`. Void shows the domain, routing, sending, DNS, and handler addresses it would configure. Accept to set up email and deploy. Later deploys reuse the setup. If you decline the initial setup, the app deploys without email.

Without `email.from`, Void skips automatic setup and preserves authored `cloudflare.send_email` bindings and `cloudflare.addresses`; check their readiness yourself. `--require-email` still refuses deployment when automatic setup cannot be verified.

If subdomain setup needs a dashboard step, follow the checklist under **Email → Settings → Subdomains**, then retry. After DNS changes, check readiness with:

Expand Down Expand Up @@ -78,6 +80,8 @@ Setup enables the zone's plus addressing so `support+T-42@mail.acme.com` reaches

On Workers Free, `sendEmail()` can send to verified destinations listed under **Email Routing → Destination addresses** in Cloudflare.

Configure your sender domain's SPF, DKIM, and DMARC records using [Cloudflare's email authentication guidance](https://developers.cloudflare.com/email-service/concepts/email-authentication/), then check the authentication results in a received message's original headers. A successful `sendEmail()` result confirms provider acceptance; it does not confirm delivery or authentication readiness.

Sending to arbitrary recipients requires [Workers Paid](https://dash.cloudflare.com/?to=/:account/workers/plans) and Email Sending onboarding. After upgrading, run `void email setup --platform cloudflare`. Void doesn't retry onboarding during ordinary deploys.

Existing DMARC or bounce-domain records can block onboarding; the checklist identifies conflicts to resolve. Inbound mail remains available.
Expand Down
11 changes: 8 additions & 3 deletions docs/guide/platform/installation/domains.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,14 @@ void platform repair <installation-id> --dashboard-url https://dash.example.com

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.
not create a dashboard Worker or DNS records. Follow the source app’s
[standalone dashboard preparation](https://github.com/voidzero-dev/void/tree/main/platform/packages/dashboard#standalone-deployment)
to deploy your own copy with an unused Worker name, your dashboard hostname,
and the installed API Worker’s service binding. Set its `API_URL` to the public
API origin, `DASHBOARD_URL` to the dashboard origin, and `SITE_DOMAIN` to the
installation’s application domain. 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
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ Configuration for the [email integration](../guide/email.md).

During development, `email.from` is the default sender in the local inbox. On a Void platform, use your project's shared sender or registered domain.

For direct Cloudflare deployment, this address selects the mail domain and default sender. Use a zone or subdomain in your account; an existing mail provider's MX records are preserved. See [Email setup](../guide/email/domains.md#your-own-cloudflare-account).
For direct Cloudflare deployment, this address selects the mail domain and default sender. Use a zone or subdomain in your account; an existing mail provider's MX records are preserved. Without `email.from`, Void skips automatic setup and preserves authored `cloudflare.send_email` bindings and `cloudflare.addresses`; check their readiness yourself. `--require-email` still refuses deployment when automatic setup cannot be verified. See [Email setup](../guide/email/domains.md#your-own-cloudflare-account).

### `head`

Expand Down
22 changes: 18 additions & 4 deletions packages/deploy-cloudflare/src/existing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { isDeepStrictEqual } from 'node:util';
import { parseRootWranglerConfig } from './config.ts';
import type { CloudflareCommandRunner } from './execute.ts';
import type { CloudflareOperation } from './operations.ts';
import { isWranglerSecretOnlyDraft } from './secret-draft.ts';
import { isVoidSecretOnlyDraft, isWranglerSecretOnlyDraft } from './secret-draft.ts';

export type ExistingCloudflareWorker = {
versionId: string;
Expand Down Expand Up @@ -55,6 +55,7 @@ export async function readExistingCloudflareWorker(options: {
async function readCloudflareWorker(
options: ExistingCloudflareWorkerOptions,
allowMissing: boolean,
proveSecretDraft = false,
): Promise<CloudflareWorkerInspection | null> {
const target = { configPath: options.configPath, workerName: options.workerName };
const read = async (
Expand Down Expand Up @@ -124,6 +125,19 @@ async function readCloudflareWorker(
) {
throw new Error('Cloudflare did not return the existing Worker configuration.');
}
let isSecretOnlyDraft = isWranglerSecretOnlyDraft(version);
if (
proveSecretDraft &&
!isSecretOnlyDraft &&
record(version.annotations) &&
Object.keys(version.annotations).length === 1 &&
version.annotations['workers/triggered_by'] === 'secret'
) {
const source = await read({ type: 'versions.view', versionId, includeSource: true, ...target });
if (source?.id === versionId) {
isSecretOnlyDraft = isVoidSecretOnlyDraft(source);
}
}
const after = options.versionId
? deployment
: await read({ type: 'deployments.status', ...target });
Expand All @@ -139,7 +153,7 @@ async function readCloudflareWorker(
runtime: resources.script_runtime,
script: resources.script,
},
isSecretOnlyDraft: isWranglerSecretOnlyDraft(version),
isSecretOnlyDraft,
};
}

Expand Down Expand Up @@ -265,7 +279,7 @@ export async function assertCloudflareDurableObjectLifecycleSupported(options: {
if (!migrations.some((migration) => migration.changesClasses)) {
return;
}
const existing = await readCloudflareWorker(options, true);
const existing = await readCloudflareWorker(options, true, true);
if (!existing) {
return;
}
Expand All @@ -277,7 +291,7 @@ export async function assertCloudflareDurableObjectLifecycleSupported(options: {
workerName: options.workerName,
};
const activeTag = await readMigrationTag(migrationOptions);
const after = await readCloudflareWorker(options, false);
const after = await readCloudflareWorker(options, false, true);
const afterTag = await readMigrationTag(migrationOptions);
if (
!after ||
Expand Down
1 change: 1 addition & 0 deletions packages/deploy-cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export {
type CloudflareVersionMetadata,
} from './version.ts';
export { createCloudflareMetadataRunner } from './metadata.ts';
export { VOID_SECRET_DRAFT_SOURCE } from './secret-draft.ts';

export type {
CloudflareOperation,
Expand Down
6 changes: 5 additions & 1 deletion packages/deploy-cloudflare/src/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,11 @@ export function createCloudflareMetadataRunner(options: {
} else if (operation.type === 'versions.list') {
kind = 'versions';
resource = '/versions?deployable=true';
} else if (operation.type === 'versions.view' && /^[0-9a-f-]{36}$/i.test(operation.versionId)) {
} else if (
operation.type === 'versions.view' &&
!operation.includeSource &&
/^[0-9a-f-]{36}$/i.test(operation.versionId)
) {
kind = 'version';
resource = '/versions/' + encodeURIComponent(operation.versionId);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/deploy-cloudflare/src/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export type CloudflareOperation =
| ({ type: 'secret.list' | 'secret.bulk' } & WorkerTarget)
| ({ type: 'secret.put' | 'secret.delete'; name: string } & WorkerTarget)
| ({ type: 'deployments.status' | 'deployments.list' | 'versions.list' } & WorkerTarget)
| ({ type: 'versions.view'; versionId: string } & WorkerTarget)
| ({ type: 'versions.view'; versionId: string; includeSource?: boolean } & WorkerTarget)
| ({
type: 'versions.deploy';
versions: Array<{ versionId: string; percentage: number }>;
Expand Down
97 changes: 97 additions & 0 deletions packages/deploy-cloudflare/src/secret-draft.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { isDeepStrictEqual } from 'node:util';

/**
* `wrangler secret put` creates a fetch-only draft before attaching a first secret to a Worker.
* Accept that draft as first-deployment proof only when its complete deployable shape contains no
Expand Down Expand Up @@ -57,6 +59,101 @@ export function isWranglerSecretOnlyDraft(entry: Record<string, unknown>): boole
);
}

// Keep these bytes aligned with the private placeholder written by Void's
// Cloudflare secret transport. Unlike the legacy Wrangler placeholder, cf
// uploads include a compatibility date, so metadata alone cannot distinguish
// them from an existing fetch-only application after a secret update.
export const VOID_SECRET_DRAFT_SOURCE =
'export default { fetch() { return new Response(""); } };\n';

/** Prove a cf-created placeholder from the exact immutable version's source. */
export function isVoidSecretOnlyDraft(entry: Record<string, unknown>): boolean {
const metadataFields = new Set([
'id',
'number',
'startup_time_ms',
'created_on',
'source',
'author_id',
'author_email',
'urls',
'annotations',
'compatibility_date',
'usage_model',
'main_module',
'modules',
'bindings',
'env',
// The cf adapter adds this compatibility view to the beta response.
'resources',
]);
if (
Object.keys(entry).some((key) => !metadataFields.has(key)) ||
entry.source !== 'cf_cli' ||
!isRecord(entry.annotations) ||
Object.keys(entry.annotations).length !== 1 ||
entry.annotations['workers/triggered_by'] !== 'secret' ||
entry.main_module !== 'index.js' ||
!Array.isArray(entry.urls) ||
entry.urls.length !== 0 ||
!Array.isArray(entry.modules) ||
entry.modules.length !== 1 ||
!Array.isArray(entry.bindings) ||
entry.bindings.length === 0
) {
return false;
}
const module = entry.modules[0];
if (
!isRecord(module) ||
Object.keys(module).length !== 3 ||
module.name !== 'index.js' ||
module.content_type !== 'application/javascript+module' ||
module.content_base64 !== Buffer.from(VOID_SECRET_DRAFT_SOURCE).toString('base64')
) {
return false;
}
const names = new Set<string>();
for (const binding of entry.bindings) {
if (
!isRecord(binding) ||
Object.keys(binding).length !== 2 ||
typeof binding.name !== 'string' ||
binding.name.length === 0 ||
names.has(binding.name) ||
binding.type !== 'secret_text'
) {
return false;
}
names.add(binding.name);
}
if (
entry.env !== undefined &&
(!isRecord(entry.env) ||
Object.keys(entry.env).length !== names.size ||
Object.entries(entry.env).some(
([name, binding]) =>
!names.has(name) ||
!isRecord(binding) ||
Object.keys(binding).length !== 1 ||
binding.type !== 'secret_text',
))
) {
return false;
}
// Never use contradictory adapter metadata as proof of an empty application.
if (entry.resources !== undefined) {
if (!isRecord(entry.resources) || Object.keys(entry.resources).length !== 1) {
return false;
}
const bindings = entry.resources.bindings;
if (!Array.isArray(bindings) || !isDeepStrictEqual(bindings, entry.bindings)) {
return false;
}
}
return true;
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
Loading
Loading