diff --git a/docs/guide/email.md b/docs/guide/email.md index 8a96622dd..2993f97b5 100644 --- a/docs/guide/email.md +++ b/docs/guide/email.md @@ -100,6 +100,8 @@ Two rules bound registrations: one live email domain per Cloudflare zone (a seco Sends from a registered domain skip the verified-recipient allowlist — the domain's own Email Sending onboarding replaces it — while platform quota and abuse controls still apply. Inbound needs nothing further: once the domain is `active`, mail to any address on it reaches your `email/` handlers. +**On a platform your administrator runs.** Some platforms register email domains for their projects centrally — typically a company installation whose users have no Cloudflare account. There, `void email domain add` opens no consent page; it prints the command to ask your administrator for and stops — unless your project registered that domain itself before the switch, which `add` still renews as before. `void email domain list` and `status` work as usual on such a domain; `status` names the administrator's command for any step you cannot take, and `sync` and `remove` point you at the administrator too. The administrator may also have widened who the shared sender reaches, so `void email allow` can be unnecessary for colleagues; `void email destinations` still lists what your project verified itself. + ## Options | Option | Type | Notes | @@ -406,7 +408,11 @@ So the ticket example above answers only senders you have allowlisted. An auto-responder to arbitrary senders needs [your own Cloudflare account](#your-own-cloudflare-account) (`--platform cloudflare`), where `reply()` is Cloudflare's own reply-to-sender -and Void adds no allowlist. +and Void adds no allowlist — or a platform whose administrator has widened the +recipient policy for the whole installation (see +[On a platform your administrator runs](#your-own-domain-on-the-platform)), +which lifts this gate for the listed domains; addresses on the platform's own +mail domain always stay verified-recipient-only. ::: Note the asymmetry with `sendEmail`: `sendEmail` returns an error result and diff --git a/docs/guide/platform-administration.md b/docs/guide/platform-administration.md index d56aa2a5e..c943cf114 100644 --- a/docs/guide/platform-administration.md +++ b/docs/guide/platform-administration.md @@ -202,6 +202,59 @@ before changing application traffic or deleting resources. If cleanup fails, access stays revoked and the error describes the partial result. A remaining administrator can inspect it and retry cleanup. +## Managing Email + +These commands apply to a platform that enables email. Every project can send from and receive at `+tag@` on the platform's shared mail domain; the [email guide](./email.md) describes what applications do with that. + +### Deciding who mail may reach + +By default, mail from the shared sender reaches only the recipients each project has verified. For a team whose applications mainly mail colleagues, widen that once for the whole installation: + +```sh +void platform email policy +void platform email policy-set domains --domains example.com,corp.example.net +void platform email policy-set any +void platform email policy-set verified +``` + +`domains` lets every project mail any address on the listed domains in addition to its verified recipients; `any` lifts the check. Cloudflare still refuses a recipient it has not verified until your mail domain is onboarded for Email Sending, which needs Workers Paid on the platform's account. Until then such sends come back as `UNVERIFIED_DESTINATION` for that recipient, whatever the policy says. Two things the policy never changes: sends from a project's own custom domain, and mail to any address on the platform's own mail domain — those stay verified-recipient-only, so no project reaches another project's inbox without its consent. A tightening takes effect everywhere within a minute. The same setting is on the admin UI's **Email** page. + +### Project caps + +Each project may send 200 messages a month and 10 a minute by default. Raise or lower a project's caps by id or slug: + +```sh +void platform email limit hr-portal --monthly 2000 --burst 30 +``` + +The project's page in the admin UI shows the current values and clears an override. + +### Registering email domains for projects + +Your users may hold no Cloudflare account. Tell the platform that administrators register email domains, so `void email domain add` prints the command to ask you for instead of opening a Cloudflare consent: + +```sh +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 domains +``` + +Name the exact mail domain: a subdomain such as `mail.example.com` when the apex already receives mail, otherwise the apex itself. Enabling Email Routing on a subdomain is a one-time step in the Cloudflare dashboard; `domain-status` names it and reports the domain active once the MX records appear. 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 +``` + +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](./self-hosted-platform.md#runtime-token-permissions) for this to work. + +Register administrator domains only after a platform upgrade has completed, and remove them before rolling the platform back to a version that predates this feature: an earlier runtime cannot use the platform credential for them and does not distinguish administrator-registered domains from owner-registered ones. + ## Previewing Changes Commands that change the platform show the affected objects before asking for confirmation. To inspect a change without applying it, add `--plan`: diff --git a/docs/guide/self-hosted-platform.md b/docs/guide/self-hosted-platform.md index 73cb434ce..b501640c6 100644 --- a/docs/guide/self-hosted-platform.md +++ b/docs/guide/self-hosted-platform.md @@ -134,6 +134,8 @@ Use the following permissions for the core platform. Cloudflare may label write The management token also needs **Zone Edit** with authority to create zones if you ask Void to create the zone. If it already exists, use the selected zone with Zone Read and DNS Edit. Nested application domains additionally need **SSL and Certificates: Read** on the management token. A custom runtime that enables custom project domains needs **SSL and Certificates: Edit** on the runtime token; the core runtime does not enable that feature. +A custom runtime that enables email lets administrators [register email domains for projects](./platform-administration.md#registering-email-domains-for-projects) on the runtime token. That needs **Zone: Read**, **Zone Settings: Edit** and **Email Routing Rules: Edit** on the zones that will carry mail, and **Email Sending: Edit** on the account; the token link preselects them when the runtime enables email. Email Sending onboarding itself needs Workers Paid on the account. + Void checks access before provisioning. If it reports a missing permission, update the token's permissions for the selected account or zone and retry. ::: diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 6e76688da..3ad71edda 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -606,6 +606,34 @@ void platform invitation revoke Send accepts up to 100 comma-separated addresses. Invitations grant signup access even if email delivery is unavailable or fails; delivery is reported separately. Revoking a pending invitation removes its exact email grant. A broader domain entry can still allow that person to sign up. +#### Email {#operator-email} + +Decide who mail from the shared sender may reach, who registers email domains, and a project's outbound caps: + +```sh +void platform email policy +void platform email policy-set [--domains ] +void platform email settings +void platform email settings-set --domains +void platform email limit [--monthly ] [--burst ] +``` + +`policy` decides which recipients a project's `+tag@` sender reaches: `verified` (the default) means only that project's verified destinations; `domains` adds every address on the listed domains; `any` lifts the check. Neither widens delivery to addresses on the platform's own mail domain: those stay verified-destination-only, so no project reaches another project's inbox without its consent. Cloudflare still refuses a destination it has not verified until the platform mail domain is onboarded for Email Sending, so under `domains` or `any` such refusals arrive as per-recipient `UNVERIFIED_DESTINATION` results. Custom-domain sends are not affected. + +`settings-set --domains admin` tells `void email domain add` to print the administrator's command instead of starting a Cloudflare grant. `limit` overrides the project's monthly and per-minute caps (defaults 200 and 10); a project page in the admin UI shows and clears them. When both caps are given and only one could be written, the result names the applied and failed cap and exits nonzero. + +Register and maintain email domains for projects whose owners hold no Cloudflare credential: + +```sh +void platform email domains [--project ] +void platform email domain-add --project [--token-stdin] +void platform email domain-status +void platform email domain-sync +void platform email domain-remove [--force] +``` + +`domain-add` runs the same setup as `void email domain add` on the platform's own Cloudflare credential, so the zone must be in the platform's account; for a zone elsewhere, pipe a Cloudflare API token for that account on standard input with `--token-stdin --yes`. Name the exact mail domain (`mail.example.com`, or the apex when it receives no mail yet). Enabling Email Routing on a subdomain stays a one-time Cloudflare dashboard step, which `domain-status` reports and self-clears once the MX records appear; after a Workers Paid upgrade, `domain-sync` re-attempts the Email Sending onboarding. A setup or sync that ends with the domain `failed` or its credential revoked or expired is reported as a partial failure and exits nonzero; the result names the failing step, and running `domain-add` again retries it. Rows an administrator adds show `managed_by: admin`; their owners can list and inspect them but are pointed at these commands for `sync` and `remove`. + #### System {#operator-system} Inspect activity, check service health, or review administrative changes: diff --git a/packages/void/src/cli/cli.ts b/packages/void/src/cli/cli.ts index 3a16f816f..f942725a0 100644 --- a/packages/void/src/cli/cli.ts +++ b/packages/void/src/cli/cli.ts @@ -254,7 +254,7 @@ if (helpPath) { }, { name: 'platform', - hint: 'auth | user | project | deployment | signup | system | install', + hint: 'auth | user | project | deployment | signup | email | system | install', description: 'Install platforms and administer users, projects, access, and operations', }, { diff --git a/packages/void/src/cli/client.ts b/packages/void/src/cli/client.ts index 27be5bc0f..3d403f00c 100644 --- a/packages/void/src/cli/client.ts +++ b/packages/void/src/cli/client.ts @@ -270,6 +270,16 @@ export type EmailDestinationResponse = { verificationSent?: boolean; }; +/** GET /projects/:id/email/settings — installation settings the CLI reads before acting. */ +export type EmailSettingsResponse = { + domains: { + /** `admin`: administrators register email domains; `void email domain add` prints their command. */ + management: 'self-serve' | 'admin'; + projectSlug?: string; + }; + recipientPolicy?: { mode: 'verified' | 'domains' | 'any'; domains: Array }; +}; + /** * The public shape of an `email_domains` row (design doc 0078, contract 1) — * metadata only. The api's serializer never carries the credential, the @@ -281,7 +291,10 @@ export type EmailDomainResponse = { domain: string; mode: 'apex' | 'subdomain' | null; status: 'pending' | 'active' | 'token_revoked' | 'token_expired' | 'failed'; - credential_kind: 'oauth_grant' | 'api_token'; + /** `platform` rows run on the platform's own credential — an administrator's row. */ + credential_kind: 'oauth_grant' | 'api_token' | 'platform'; + /** Who registered the row; owners may read an `admin` row but not sync or remove it. */ + managed_by?: 'owner' | 'admin'; relay_worker_name: string | null; relay_version: number | null; /** Metadata from GET /user/tokens/verify (api_token rows) — safe to print. */ @@ -1649,6 +1662,34 @@ export class PlatformClient { return res.json(); } + /** + * Installation email settings the CLI reads before acting. A platform + * predating the route answers a bare 404, which reads as the defaults — + * owners register their own domains there. The route's own 404 (the + * project is gone) is a real error: acting on it would open a browser and + * mint a grant for nothing. + */ + async getEmailSettings(projectId: string): Promise { + const res = await this.request(`${this.baseUrl}/projects/${projectId}/email/settings`, { + headers: { + ...this.authHeaders, + }, + }); + + if (res.status === 404) { + const body = await res.text(); + if (parsePlatformErrorBody(body)?.error === 'project not found') { + throw new PlatformApiError('Failed to read email settings', res.status, body); + } + return { domains: { management: 'self-serve' } }; + } + if (!res.ok) { + await throwPlatformApiError('Failed to read email settings', res); + } + + return res.json(); + } + async listEmailDomains(projectId: string): Promise> { const res = await this.request(`${this.baseUrl}/projects/${projectId}/email/domains`, { headers: { diff --git a/packages/void/src/cli/email.ts b/packages/void/src/cli/email.ts index 7940d3798..89b6e9569 100644 --- a/packages/void/src/cli/email.ts +++ b/packages/void/src/cli/email.ts @@ -534,10 +534,15 @@ const ADD_RECONCILE_INTERVAL_MS = 1000; * the template-link + clipboard fallback (Enter switches to the masked paste * at any point) → POST the credential once → render the resulting row. A * failed add leaves the platform row 'failed' and re-running add replaces - * the credential — the api's replace path, not anything here. + * the credential — the api's replace path, not anything here. On an + * administrator-managed platform, only a row the owner registered before + * the switch continues into that renewal flow. */ export async function emailDomainAdd( - client: Pick, + client: Pick< + PlatformClient, + 'addEmailDomain' | 'getEmailDomain' | 'getEmailSettings' | 'listEmailDomains' + >, projectId: string, rawDomain: string, subdomain: string | undefined, @@ -562,35 +567,83 @@ export async function emailDomainAdd( // The MX decision. Forced (--subdomain) needs no default, only a warning // when the named host already carries MX; unforced reads the apex and - // falls back to mail. — exactly the doc's two render lines. + // falls back to mail.. Shared by the owner flow and the printed + // administrator command, so the handoff names the target setup accepts. const dns = deps.dns ?? (await createNodeEmailDns()); - if (target.forced) { - const mx = await lookupMxForRender(dns, target.emailDomain); - if (mx !== null && mx.length > 0) { - log.warn( - `MX ${target.emailDomain} already carries MX (${mx.join(', ')}) — the platform re-checks over public DNS and may refuse it.`, - ); - } - } else { - const mx = await lookupMxForRender(dns, domain); - if (mx === null) { - log.info( - `MX could not read ${domain}'s MX from here — the platform decides over public DNS.`, - ); - } else if (mx.length > 0) { - target = { emailDomain: `mail.${domain}`, forced: false }; - log.info( - `MX ${domain} carries MX for ${mx[0]} → apex is off limits\n using ${target.emailDomain} [change with --subdomain]`, - ); - const subMx = await lookupMxForRender(dns, target.emailDomain); - if (subMx !== null && subMx.length > 0) { + const decideTarget = async (): Promise => { + if (target.forced) { + const mx = await lookupMxForRender(dns, target.emailDomain); + if (mx !== null && mx.length > 0) { log.warn( - `MX ${target.emailDomain} also carries MX (${subMx.join(', ')}) — the platform may refuse it; pick another with --subdomain.`, + `MX ${target.emailDomain} already carries MX (${mx.join(', ')}) — the platform re-checks over public DNS and may refuse it.`, ); } } else { - log.info(`MX ${domain} has no MX records → using the apex`); + const mx = await lookupMxForRender(dns, domain); + if (mx === null) { + log.info( + `MX could not read ${domain}'s MX from here — the platform decides over public DNS.`, + ); + } else if (mx.length > 0) { + target = { emailDomain: `mail.${domain}`, forced: false }; + log.info( + `MX ${domain} carries MX for ${mx[0]} → apex is off limits\n using ${target.emailDomain} [change with --subdomain]`, + ); + const subMx = await lookupMxForRender(dns, target.emailDomain); + if (subMx !== null && subMx.length > 0) { + log.warn( + `MX ${target.emailDomain} also carries MX (${subMx.join(', ')}) — the platform may refuse it; pick another with --subdomain.`, + ); + } + } else { + log.info(`MX ${domain} has no MX records → using the apex`); + } } + }; + + // On an administrator-managed platform owners normally hold no Cloudflare + // credential, so there is no grant to open a browser for. The handoff does + // DNS and the two platform GETs, never a Cloudflare call or browser flow. A + // row the owner registered before the switch stays theirs to renew + // (ownership table: add on an owner row), so that owner flow continues for + // an existing target. + let renewal: string | undefined; + const settings = await client.getEmailSettings(projectId); + if (settings.domains.management === 'admin') { + const rows = await client.listEmailDomains(projectId); + const candidates = [target.emailDomain, ...(target.forced ? [] : [`mail.${domain}`])]; + const existing = candidates + .map((candidate) => + rows.find((row) => row.domain === candidate && row.managed_by !== 'admin'), + ) + .find((row) => row !== undefined); + if (!existing) { + await decideTarget(); + const project = settings.domains.projectSlug ?? projectId; + log.info( + [ + 'Email domains on this platform are managed by its administrator.', + 'Ask them to run:', + '', + ` void platform email domain-add ${target.emailDomain} --project ${project}`, + '', + '`void email domain list` and `void email domain status` keep working here.', + ].join('\n'), + ); + return exit(0); + } + // The pre-switch row IS the target: Email Routing itself put MX on the + // apex it serves, so the MX decision below would retarget a renewal. + renewal = existing.domain; + target = { emailDomain: existing.domain, forced: true }; + } + + // A renewal keeps its stored target: Email Routing itself put MX on the + // apex it serves, so the MX decision would retarget it. + if (renewal !== undefined) { + log.info(`Renewing ${renewal} — the stored credential is replaced; rules and the relay stay.`); + } else { + await decideTarget(); } // Both grant paths need a human: a browser, a clipboard, or a paste prompt. @@ -896,6 +949,17 @@ export async function emailDomainAdd( printEmailDomainOutcome(row); } +/** An administrator's row runs on the platform's own credential; the owner never held one. */ +function emailCredentialLabel(row: Pick) { + const kind = + row.credential_kind === 'oauth_grant' + ? 'OAuth grant' + : row.credential_kind === 'platform' + ? 'platform credential' + : 'API token'; + return row.managed_by === 'admin' ? `${kind} · managed by the administrator` : kind; +} + function emailDomainStatusText(status: EmailDomainResponse['status']): string { if (status === 'active') { return pc.green('active'); @@ -937,7 +1001,7 @@ function printEmailDomainOutcome(row: EmailDomainResponse): void { `Domain: ${row.domain}`, `Status: ${emailDomainStatusText(row.status)}`, `Mode: ${row.mode ?? '—'}`, - `Credential: ${row.credential_kind === 'oauth_grant' ? 'OAuth grant' : 'API token'}`, + `Credential: ${emailCredentialLabel(row)}`, ]; if (row.relay_worker_name) { lines.push( @@ -981,12 +1045,13 @@ function printEmailDomainOutcome(row: EmailDomainResponse): void { * also the drift report: it never fights the customer's edits, it names them. */ export async function emailDomainStatus( - client: Pick, + client: Pick, projectId: string, rawDomain: string, ): Promise { const domain = normalizeEmailDomainInput(rawDomain); const row: EmailDomainStatusResponse = await client.getEmailDomain(projectId, domain); + const adminRow = row.managed_by === 'admin'; const lines = [ `Domain: ${row.domain}`, @@ -995,8 +1060,8 @@ export async function emailDomainStatus( ]; const credential = row.credential_status === 'probe_failed' - ? `${row.credential_kind === 'oauth_grant' ? 'OAuth grant' : 'API token'} · probe failed (stored view shown)` - : `${row.credential_kind === 'oauth_grant' ? 'OAuth grant' : 'API token'} · ${row.credential_status}`; + ? `${emailCredentialLabel(row)} · probe failed (stored view shown)` + : `${emailCredentialLabel(row)} · ${row.credential_status}`; lines.push(`Credential: ${credential}`); if (row.token_id) { lines.push( @@ -1021,25 +1086,53 @@ export async function emailDomainStatus( if (row.error) { log.error(`email: ${row.error}`); } + const failedAdminRow = adminRow && row.status === 'failed'; + if (failedAdminRow) { + const slug = (await client.getEmailSettings(projectId)).domains.projectSlug ?? projectId; + log.warn( + `Ask your administrator to re-run the setup: \`void platform email domain-add ${row.domain} --project ${slug}${row.credential_kind === 'api_token' ? ' --token-stdin' : ''}\` — adding again retries the failed step; rules stay.`, + ); + } const remaining = row.setup_state?.remaining_steps ?? []; if (remaining.includes('routing_subdomain')) { - log.warn(`One step only you can do: ${routingSubdomainInstruction(row.domain, row.mode)}.`); + log.warn( + adminRow + ? `One step for your administrator, in the platform's Cloudflare account: ${routingSubdomainInstruction(row.domain, row.mode)}.` + : `One step only you can do: ${routingSubdomainInstruction(row.domain, row.mode)}.`, + ); } if (remaining.includes('sending_onboarding')) { log.info( - `Sending to arbitrary recipients needs Workers Paid — inbound works; sendEmail() to verified destinations only. After upgrading, run \`void email domain sync ${row.domain}\`.`, + adminRow + ? `Sending to arbitrary recipients needs Workers Paid on the platform's Cloudflare account — inbound works; sendEmail() to verified destinations only. After the upgrade, ask your administrator to run \`void platform email domain-sync ${row.domain}\`.` + : `Sending to arbitrary recipients needs Workers Paid — inbound works; sendEmail() to verified destinations only. After upgrading, run \`void email domain sync ${row.domain}\`.`, ); } - if (row.relay_missing === true || row.relay_drift) { - log.info(`Run \`void email domain sync ${row.domain}\` to redeploy the relay in place.`); + if (!failedAdminRow && (row.relay_missing === true || row.relay_drift)) { + log.info( + adminRow + ? `Ask your administrator to run \`void platform email domain-sync ${row.domain}\` to redeploy the relay in place.` + : `Run \`void email domain sync ${row.domain}\` to redeploy the relay in place.`, + ); } - if (row.catch_all_drift) { - log.info(`Catch-all drifted — run \`void email domain sync ${row.domain}\` to re-point it.`); + if (!failedAdminRow && row.catch_all_drift) { + log.info( + adminRow + ? `Catch-all drifted — ask your administrator to run \`void platform email domain-sync ${row.domain}\` to re-point it.` + : `Catch-all drifted — run \`void email domain sync ${row.domain}\` to re-point it.`, + ); } if (row.status === 'token_revoked' || row.status === 'token_expired') { - log.warn( - `Re-run \`void email domain add ${row.domain}\` with a fresh grant or token — adding again replaces the credential, rules stay.`, - ); + if (adminRow) { + const slug = (await client.getEmailSettings(projectId)).domains.projectSlug ?? projectId; + log.warn( + `Ask your administrator to re-add it: \`void platform email domain-add ${row.domain} --project ${slug} --token-stdin\` — adding again replaces the credential, rules stay.`, + ); + } else { + log.warn( + `Re-run \`void email domain add ${row.domain}\` with a fresh grant or token — adding again replaces the credential, rules stay.`, + ); + } } } diff --git a/packages/void/src/cli/operator-args.ts b/packages/void/src/cli/operator-args.ts index f35ebd379..ea55844d3 100644 --- a/packages/void/src/cli/operator-args.ts +++ b/packages/void/src/cli/operator-args.ts @@ -267,6 +267,85 @@ export const operatorCommands: Array = [ values: { cursor: 'Opaque cursor returned by --plan' }, method: 'POST', }, + { + group: 'email', + action: 'policy', + description: 'Show the recipient policy for the shared sender', + path: '/email/policy', + }, + { + group: 'email', + action: 'policy-set', + description: 'Set the recipient policy: verified, domains, or any', + path: '/email/policy', + positional: ['mode'], + values: { domains: 'Comma-separated recipient domains for mode=domains' }, + method: 'POST', + }, + { + group: 'email', + action: 'settings', + description: 'Show platform email settings', + path: '/email/settings', + }, + { + group: 'email', + action: 'settings-set', + description: 'Set who manages email domains: self-serve or admin', + path: '/email/settings', + values: { domains: 'self-serve or admin' }, + method: 'POST', + }, + { + group: 'email', + action: 'limit', + description: "Set a project's monthly and burst email caps", + path: '/email/projects/:project/limit', + positional: ['project'], + values: { monthly: 'Messages per month', burst: 'Messages per minute' }, + method: 'POST', + }, + { + group: 'email', + action: 'domains', + description: 'List email domains registered on this platform', + path: '/email/domains', + values: { project: 'Project id or slug' }, + }, + { + group: 'email', + action: 'domain-add', + description: 'Register an email domain for a project with the platform credential', + path: '/email/domains', + positional: ['domain'], + values: { project: 'Project id or slug' }, + booleans: ['token-stdin'], + method: 'POST', + }, + { + group: 'email', + action: 'domain-status', + description: "Inspect an email domain's setup, credential and relay", + path: '/email/domains/:domain', + positional: ['domain'], + }, + { + group: 'email', + action: 'domain-sync', + description: 'Redeploy an email domain relay and rotate its secret', + path: '/email/domains/:domain/sync', + positional: ['domain'], + method: 'POST', + }, + { + group: 'email', + action: 'domain-remove', + description: 'Remove an email domain and its Cloudflare setup', + path: '/email/domains/:domain/remove', + positional: ['domain'], + booleans: ['force'], + method: 'POST', + }, { group: 'worker', action: 'list', @@ -336,7 +415,7 @@ export function parseOperatorArgs(args: Array): OperatorArgs { const positional: Array = []; const values: Record = {}; const flags = new Set(); - const booleanFlags = new Set(['json', 'plan', 'yes', 'follow', 'token-stdin']); + const booleanFlags = new Set(['json', 'plan', 'yes', 'follow', 'token-stdin', 'force']); for (let index = 0; index < args.length; index++) { const arg = args[index]!; if (!arg.startsWith('-')) { @@ -504,5 +583,12 @@ export function parseOperatorArgs(args: Array): OperatorArgs { if (parsed.body.type && !['github', 'email', 'identity'].includes(String(parsed.body.type))) { throw new Error('platform: Signup grant type must be github, email, or identity.'); } + // Command-specific booleans other than the session's own `--token-stdin` + // ride the body, where the server reads them as JSON booleans. + for (const name of command.booleans ?? []) { + if (name !== 'token-stdin' && flags.has(name)) { + parsed.body[name] = true; + } + } return parsed; } diff --git a/packages/void/src/cli/operator-cmd.ts b/packages/void/src/cli/operator-cmd.ts index 230288cb7..3e146f98d 100644 --- a/packages/void/src/cli/operator-cmd.ts +++ b/packages/void/src/cli/operator-cmd.ts @@ -466,6 +466,13 @@ export async function runOperatorCommand(args: OperatorArgs): Promise { 'confirmation_required', ); } + // `email domain-add --token-stdin`: a Cloudflare API token for a zone + // outside the platform's account, read once and carried in the body of + // both the preview (which only classifies it) and the apply. Never + // printed; the server keeps it out of the audit log. + if (args.tokenStdin && args.group === 'email' && args.action === 'domain-add') { + args = { ...args, body: { ...args.body, apiToken: await readOperatorApiTokenStdin() } }; + } const preview = await client.request(args.path, { method: 'POST', body: { ...args.body, dryRun: true }, diff --git a/packages/void/src/cli/platform-args.ts b/packages/void/src/cli/platform-args.ts index a19e3e36d..0e8817927 100644 --- a/packages/void/src/cli/platform-args.ts +++ b/packages/void/src/cli/platform-args.ts @@ -358,6 +358,6 @@ export function parsePlatformArgs(args: Array): PlatformArgs { } throw new Error( - 'platform: Usage: void platform .', + 'platform: Usage: void platform .', ); } diff --git a/packages/void/src/cli/platform-setup.ts b/packages/void/src/cli/platform-setup.ts index dc7c8049b..a5076b857 100644 --- a/packages/void/src/cli/platform-setup.ts +++ b/packages/void/src/cli/platform-setup.ts @@ -88,6 +88,16 @@ export function platformSetupPages(plan: PlatformInstallPlan): { ...(plan.deployment.capabilities.customDomains ? [{ key: 'ssl_and_certificates', type: 'edit' }] : []), + // Administrator-registered email domains run the Email Routing / Email + // Sending setup on this token against zones in this account. + ...(plan.deployment.capabilities.email + ? [ + { key: 'zone', type: 'read' }, + { key: 'zone_settings', type: 'edit' }, + { key: 'email_routing_rule', type: 'edit' }, + { key: 'email_sending', type: 'edit' }, + ] + : []), ]; const query = new URLSearchParams({ to: '/' + plan.account.id + '/api-tokens', @@ -109,6 +119,11 @@ export function platformSetupPages(plan: PlatformInstallPlan): { ...(plan.deployment.capabilities.customDomains ? ['Custom project domains also need SSL and Certificates: Edit on that zone.'] : []), + ...(plan.deployment.capabilities.email + ? [ + 'Administrator-registered email domains need Zone: Read, Zone Settings: Edit and Email Routing Rules: Edit on the mail zones, and Email Sending: Edit.', + ] + : []), 'If the form differs, match the permissions above. Create the token, then paste it here.', ].join('\n'), }, diff --git a/packages/void/test/unit/email-cli-domain.test.ts b/packages/void/test/unit/email-cli-domain.test.ts index 198e7b9bd..4e5933b32 100644 --- a/packages/void/test/unit/email-cli-domain.test.ts +++ b/packages/void/test/unit/email-cli-domain.test.ts @@ -36,6 +36,9 @@ vi.mock('@clack/prompts', async (importOriginal) => { const LEGACY_TOKEN = `Lgcy-${'a1'.repeat(17)}B`; // 40 chars, [A-Za-z0-9_-] const CFUT_TOKEN = `cfut_${'c0'.repeat(20)}${'deed'.repeat(2)}`; // 40-char core + checksum tail const GRANT_SENTINEL = 'grant-refresh-token-placeholder'; +/** The default platform: owners register their own domains. */ +const selfServeSettings = async () => ({ domains: { management: 'self-serve' as const } }); +const noEmailDomains = async (): Promise> => []; type LogSpy = ReturnType; @@ -206,6 +209,33 @@ describe('domain input helpers', () => { }); }); +describe('PlatformClient.getEmailSettings', () => { + function clientWith(status: number, body: string) { + const fetchSpy = vi.fn(async () => new Response(body, { status })); + vi.stubGlobal('fetch', fetchSpy); + return new PlatformClient('jwt-dummy', 'https://api.example.test'); + } + + it('reads the settings the platform reports', async () => { + const client = clientWith( + 200, + JSON.stringify({ domains: { management: 'admin', projectSlug: 'hr-portal' } }), + ); + await expect(client.getEmailSettings('prj_1')).resolves.toMatchObject({ + domains: { management: 'admin', projectSlug: 'hr-portal' }, + }); + }); + + it('treats a platform without the route as self-serve, but a missing project as an error', async () => { + await expect(clientWith(404, '404 Not Found').getEmailSettings('prj_1')).resolves.toEqual({ + domains: { management: 'self-serve' }, + }); + await expect( + clientWith(404, JSON.stringify({ error: 'project not found' })).getEmailSettings('prj_gone'), + ).rejects.toThrow(PlatformApiError); + }); +}); + describe('emailDomainAdd', () => { // The fixed per-invocation attempt id the randomUUID spy returns. const ATTEMPT_ID = '11111111-2222-4333-8444-555555555555'; @@ -235,7 +265,12 @@ describe('emailDomainAdd', () => { const deps = addDeps(); await emailDomainAdd( - { addEmailDomain, getEmailDomain: vi.fn() }, + { + addEmailDomain, + getEmailDomain: vi.fn(), + getEmailSettings: selfServeSettings, + listEmailDomains: noEmailDomains, + }, 'prj_1', 'acme.com', undefined, @@ -257,6 +292,216 @@ describe('emailDomainAdd', () => { expect(printed(infoSpy) + printed(successSpy) + notedBlocks()).not.toContain(GRANT_SENTINEL); }); + it('prints the administrator command and stops on an admin-managed platform', async () => { + const addEmailDomain = vi.fn(); + const runGrant = vi.fn(); + const resolveMx = vi.fn(async () => []); + const exit = exitSpy(); + + await expect( + emailDomainAdd( + { + addEmailDomain, + getEmailDomain: vi.fn(), + getEmailSettings: async () => ({ + domains: { management: 'admin' as const, projectSlug: 'hr-portal' }, + }), + listEmailDomains: noEmailDomains, + }, + 'prj_1', + 'acme.com', + undefined, + addDeps({ runGrant, dns: { resolveMx }, exit }), + ), + ).rejects.toThrow('exit 0'); + + expect(printed(infoSpy)).toContain( + 'Email domains on this platform are managed by its administrator.', + ); + expect(printed(infoSpy)).toContain( + 'void platform email domain-add acme.com --project hr-portal', + ); + // The target was decided, but no browser or POST ran — and exit was clean. + expect(resolveMx).toHaveBeenCalledWith('acme.com'); + expect(runGrant).not.toHaveBeenCalled(); + expect(addEmailDomain).not.toHaveBeenCalled(); + expect(exit).toHaveBeenCalledWith(0); + }); + + it('prints the mail subdomain for an admin-managed apex that already receives mail', async () => { + const addEmailDomain = vi.fn(); + const runGrant = vi.fn(); + const resolveMx = vi.fn(async (name: string) => + name === 'acme.com' ? [{ exchange: 'aspmx.l.google.com.', priority: 1 }] : [], + ); + const exit = exitSpy(); + + await expect( + emailDomainAdd( + { + addEmailDomain, + getEmailDomain: vi.fn(), + getEmailSettings: async () => ({ + domains: { management: 'admin' as const, projectSlug: 'hr-portal' }, + }), + listEmailDomains: noEmailDomains, + }, + 'prj_1', + 'acme.com', + undefined, + addDeps({ runGrant, dns: { resolveMx }, exit }), + ), + ).rejects.toThrow('exit 0'); + + expect(printed(infoSpy)).toContain('acme.com carries MX for aspmx.l.google.com'); + expect(printed(infoSpy)).toContain( + 'void platform email domain-add mail.acme.com --project hr-portal', + ); + expect(runGrant).not.toHaveBeenCalled(); + expect(addEmailDomain).not.toHaveBeenCalled(); + expect(exit).toHaveBeenCalledWith(0); + }); + + it('keeps a forced admin-managed subdomain in the printed command and warns on its MX', async () => { + const addEmailDomain = vi.fn(); + const runGrant = vi.fn(); + const resolveMx = vi.fn(async () => [{ exchange: 'aspmx.l.google.com.', priority: 1 }]); + const exit = exitSpy(); + + await expect( + emailDomainAdd( + { + addEmailDomain, + getEmailDomain: vi.fn(), + getEmailSettings: async () => ({ + domains: { management: 'admin' as const, projectSlug: 'hr-portal' }, + }), + listEmailDomains: noEmailDomains, + }, + 'prj_1', + 'acme.com', + 'mx', + addDeps({ runGrant, dns: { resolveMx }, exit }), + ), + ).rejects.toThrow('exit 0'); + + expect(resolveMx).toHaveBeenCalledWith('mx.acme.com'); + expect(printed(warnSpy)).toContain('mx.acme.com already carries MX'); + expect(printed(warnSpy)).toContain('may refuse it'); + expect(printed(infoSpy)).toContain( + 'void platform email domain-add mx.acme.com --project hr-portal', + ); + expect(runGrant).not.toHaveBeenCalled(); + expect(addEmailDomain).not.toHaveBeenCalled(); + }); + + it('prints the administrator command when the target is already an administrator row', async () => { + const addEmailDomain = vi.fn(); + const runGrant = vi.fn(); + const resolveMx = vi.fn(async () => []); + const exit = exitSpy(); + + await expect( + emailDomainAdd( + { + addEmailDomain, + getEmailDomain: vi.fn(), + getEmailSettings: async () => ({ + domains: { management: 'admin' as const, projectSlug: 'hr-portal' }, + }), + listEmailDomains: async () => [domainRow({ managed_by: 'admin' })], + }, + 'prj_1', + 'acme.com', + undefined, + addDeps({ runGrant, dns: { resolveMx }, exit }), + ), + ).rejects.toThrow('exit 0'); + + expect(printed(infoSpy)).toContain( + 'void platform email domain-add acme.com --project hr-portal', + ); + expect(resolveMx).toHaveBeenCalledWith('acme.com'); + expect(runGrant).not.toHaveBeenCalled(); + expect(addEmailDomain).not.toHaveBeenCalled(); + }); + + it.each([ + ['an explicit owner row', 'owner' as const], + ['a row from an older api without managed_by', undefined], + ])( + 'continues the renewal flow for %s on an admin-managed platform', + async (_label, managedBy) => { + const addEmailDomain = vi.fn(async () => + domainRow({ domain: 'mail.acme.com', mode: 'subdomain', managed_by: managedBy }), + ); + const runGrant = vi.fn(async () => ({ refreshToken: GRANT_SENTINEL })); + const resolveMx = vi.fn(async () => [{ exchange: 'aspmx.l.google.com.', priority: 1 }]); + const exit = exitSpy(); + + await emailDomainAdd( + { + addEmailDomain, + getEmailDomain: vi.fn(), + getEmailSettings: async () => ({ + domains: { management: 'admin' as const, projectSlug: 'hr-portal' }, + }), + listEmailDomains: async () => [ + domainRow({ domain: 'mail.acme.com', managed_by: managedBy }), + ], + }, + 'prj_1', + 'acme.com', + undefined, + addDeps({ runGrant, dns: { resolveMx }, exit }), + ); + + expect(resolveMx).not.toHaveBeenCalled(); + expect(printed(infoSpy)).toContain( + 'Renewing mail.acme.com — the stored credential is replaced; rules and the relay stay.', + ); + expect(runGrant).toHaveBeenCalled(); + expect(addEmailDomain).toHaveBeenCalledWith('prj_1', { + domain: 'mail.acme.com', + refreshToken: GRANT_SENTINEL, + attemptId: expect.any(String), + }); + expect(exit).not.toHaveBeenCalledWith(0); + }, + ); + + it.each([ + ['an apex row', 'acme.com'], + ['an already-prefixed mail row', 'mail.acme.com'], + ])('renews %s without re-running the MX decision', async (_label, existingDomain) => { + const addEmailDomain = vi.fn(async () => domainRow({ domain: existingDomain })); + const runGrant = vi.fn(async () => ({ refreshToken: GRANT_SENTINEL })); + const resolveMx = vi.fn(async () => [{ exchange: 'route1.mx.cloudflare.net.', priority: 10 }]); + + await emailDomainAdd( + { + addEmailDomain, + getEmailDomain: vi.fn(), + getEmailSettings: async () => ({ + domains: { management: 'admin' as const, projectSlug: 'hr-portal' }, + }), + listEmailDomains: async () => [domainRow({ domain: existingDomain, managed_by: 'owner' })], + }, + 'prj_1', + existingDomain, + undefined, + addDeps({ runGrant, dns: { resolveMx } }), + ); + + expect(resolveMx).not.toHaveBeenCalled(); + expect(printed(infoSpy)).toContain(`Renewing ${existingDomain}`); + expect(addEmailDomain).toHaveBeenCalledWith('prj_1', { + domain: existingDomain, + refreshToken: GRANT_SENTINEL, + attemptId: expect.any(String), + }); + }); + it('proposes mail. when the apex carries MX, and says so', async () => { const addEmailDomain = vi.fn(async () => domainRow({ domain: 'mail.acme.com', mode: 'subdomain' }), @@ -266,7 +511,12 @@ describe('emailDomainAdd', () => { ); await emailDomainAdd( - { addEmailDomain, getEmailDomain: vi.fn() }, + { + addEmailDomain, + getEmailDomain: vi.fn(), + getEmailSettings: selfServeSettings, + listEmailDomains: noEmailDomains, + }, 'prj_1', 'acme.com', undefined, @@ -290,7 +540,12 @@ describe('emailDomainAdd', () => { ); await emailDomainAdd( - { addEmailDomain, getEmailDomain: vi.fn() }, + { + addEmailDomain, + getEmailDomain: vi.fn(), + getEmailSettings: selfServeSettings, + listEmailDomains: noEmailDomains, + }, 'prj_1', ' ACME.com. ', 'mx', @@ -310,7 +565,12 @@ describe('emailDomainAdd', () => { await expect( emailDomainAdd( - { addEmailDomain, getEmailDomain: vi.fn() }, + { + addEmailDomain, + getEmailDomain: vi.fn(), + getEmailSettings: selfServeSettings, + listEmailDomains: noEmailDomains, + }, 'prj_1', 'not a domain', undefined, @@ -321,7 +581,12 @@ describe('emailDomainAdd', () => { await expect( emailDomainAdd( - { addEmailDomain, getEmailDomain: vi.fn() }, + { + addEmailDomain, + getEmailDomain: vi.fn(), + getEmailSettings: selfServeSettings, + listEmailDomains: noEmailDomains, + }, 'prj_1', 'acme.com', 'other.com', @@ -337,7 +602,12 @@ describe('emailDomainAdd', () => { const runGrant = vi.fn(); await expect( emailDomainAdd( - { addEmailDomain: vi.fn(), getEmailDomain: vi.fn() }, + { + addEmailDomain: vi.fn(), + getEmailDomain: vi.fn(), + getEmailSettings: selfServeSettings, + listEmailDomains: noEmailDomains, + }, 'prj_1', 'acme.com', undefined, @@ -361,7 +631,12 @@ describe('emailDomainAdd', () => { ); await emailDomainAdd( - { addEmailDomain, getEmailDomain: vi.fn() }, + { + addEmailDomain, + getEmailDomain: vi.fn(), + getEmailSettings: selfServeSettings, + listEmailDomains: noEmailDomains, + }, 'prj_1', 'acme.com', undefined, @@ -401,7 +676,12 @@ describe('emailDomainAdd', () => { const addEmailDomain = vi.fn(async () => domainRow({ credential_kind: 'api_token' })); await emailDomainAdd( - { addEmailDomain, getEmailDomain: vi.fn() }, + { + addEmailDomain, + getEmailDomain: vi.fn(), + getEmailSettings: selfServeSettings, + listEmailDomains: noEmailDomains, + }, 'prj_1', 'acme.com', undefined, @@ -434,7 +714,12 @@ describe('emailDomainAdd', () => { const pendingGrant = vi.fn(() => new Promise(() => {})); await emailDomainAdd( - { addEmailDomain, getEmailDomain: vi.fn() }, + { + addEmailDomain, + getEmailDomain: vi.fn(), + getEmailSettings: selfServeSettings, + listEmailDomains: noEmailDomains, + }, 'prj_1', 'acme.com', undefined, @@ -458,7 +743,12 @@ describe('emailDomainAdd', () => { // Esc at the paste prompt: nothing is POSTed, nothing is stored. const addEmailDomain2 = vi.fn(); await emailDomainAdd( - { addEmailDomain: addEmailDomain2, getEmailDomain: vi.fn() }, + { + addEmailDomain: addEmailDomain2, + getEmailDomain: vi.fn(), + getEmailSettings: selfServeSettings, + listEmailDomains: noEmailDomains, + }, 'prj_1', 'acme.com', undefined, @@ -485,7 +775,12 @@ describe('emailDomainAdd', () => { }); const run = emailDomainAdd( - { addEmailDomain, getEmailDomain: vi.fn() }, + { + addEmailDomain, + getEmailDomain: vi.fn(), + getEmailSettings: selfServeSettings, + listEmailDomains: noEmailDomains, + }, 'prj_1', 'acme.com', undefined, @@ -538,7 +833,12 @@ describe('emailDomainAdd', () => { await expect( emailDomainAdd( - { addEmailDomain, getEmailDomain: vi.fn() }, + { + addEmailDomain, + getEmailDomain: vi.fn(), + getEmailSettings: selfServeSettings, + listEmailDomains: noEmailDomains, + }, 'prj_1', 'acme.com', undefined, @@ -561,7 +861,12 @@ describe('emailDomainAdd', () => { const addEmailDomain = vi.fn(); await expect( emailDomainAdd( - { addEmailDomain, getEmailDomain: vi.fn() }, + { + addEmailDomain, + getEmailDomain: vi.fn(), + getEmailSettings: selfServeSettings, + listEmailDomains: noEmailDomains, + }, 'prj_1', 'acme.com', undefined, @@ -589,7 +894,12 @@ describe('emailDomainAdd', () => { ); await emailDomainAdd( - { addEmailDomain, getEmailDomain: vi.fn() }, + { + addEmailDomain, + getEmailDomain: vi.fn(), + getEmailSettings: selfServeSettings, + listEmailDomains: noEmailDomains, + }, 'prj_1', 'acme.com', undefined, @@ -612,7 +922,12 @@ describe('emailDomainAdd', () => { ); await emailDomainAdd( - { addEmailDomain, getEmailDomain: vi.fn() }, + { + addEmailDomain, + getEmailDomain: vi.fn(), + getEmailSettings: selfServeSettings, + listEmailDomains: noEmailDomains, + }, 'prj_1', 'acme.com', undefined, @@ -633,7 +948,12 @@ describe('emailDomainAdd', () => { ); await emailDomainAdd( - { addEmailDomain, getEmailDomain: vi.fn() }, + { + addEmailDomain, + getEmailDomain: vi.fn(), + getEmailSettings: selfServeSettings, + listEmailDomains: noEmailDomains, + }, 'prj_1', 'acme.com', undefined, @@ -659,7 +979,18 @@ describe('emailDomainAdd', () => { vi.stubGlobal('fetch', fetchSpy); await expect( - emailDomainAdd({ addEmailDomain, getEmailDomain }, 'prj_1', 'acme.com', undefined, addDeps()), + emailDomainAdd( + { + addEmailDomain, + getEmailDomain, + getEmailSettings: selfServeSettings, + listEmailDomains: noEmailDomains, + }, + 'prj_1', + 'acme.com', + undefined, + addDeps(), + ), ).rejects.toThrow(PlatformApiError); // No reconciliation, no CLI-side revoke: the API's rejection owns both. expect(getEmailDomain).not.toHaveBeenCalled(); @@ -678,7 +1009,12 @@ describe('emailDomainAdd', () => { vi.stubGlobal('fetch', fetchSpy); await emailDomainAdd( - { addEmailDomain, getEmailDomain }, + { + addEmailDomain, + getEmailDomain, + getEmailSettings: selfServeSettings, + listEmailDomains: noEmailDomains, + }, 'prj_1', 'acme.com', undefined, @@ -709,7 +1045,18 @@ describe('emailDomainAdd', () => { vi.stubGlobal('fetch', fetchSpy); await expect( - emailDomainAdd({ addEmailDomain, getEmailDomain }, 'prj_1', 'acme.com', undefined, addDeps()), + emailDomainAdd( + { + addEmailDomain, + getEmailDomain, + getEmailSettings: selfServeSettings, + listEmailDomains: noEmailDomains, + }, + 'prj_1', + 'acme.com', + undefined, + addDeps(), + ), ).rejects.toThrow('exit 1'); // The 500 did not prove a server-side revoke, so the CLI kills the grant @@ -746,7 +1093,18 @@ describe('emailDomainAdd', () => { vi.stubGlobal('fetch', fetchSpy); await expect( - emailDomainAdd({ addEmailDomain, getEmailDomain }, 'prj_1', 'acme.com', undefined, addDeps()), + emailDomainAdd( + { + addEmailDomain, + getEmailDomain, + getEmailSettings: selfServeSettings, + listEmailDomains: noEmailDomains, + }, + 'prj_1', + 'acme.com', + undefined, + addDeps(), + ), ).rejects.toThrow('exit 1'); expect(getEmailDomain).toHaveBeenCalledTimes(15); @@ -775,7 +1133,18 @@ describe('emailDomainAdd', () => { vi.stubGlobal('fetch', fetchSpy); await expect( - emailDomainAdd({ addEmailDomain, getEmailDomain }, 'prj_1', 'acme.com', undefined, addDeps()), + emailDomainAdd( + { + addEmailDomain, + getEmailDomain, + getEmailSettings: selfServeSettings, + listEmailDomains: noEmailDomains, + }, + 'prj_1', + 'acme.com', + undefined, + addDeps(), + ), ).rejects.toThrow('exit 1'); // No poll produced a read — the grant may be live, and a blind revoke @@ -807,7 +1176,18 @@ describe('emailDomainAdd', () => { vi.stubGlobal('fetch', fetchSpy); await expect( - emailDomainAdd({ addEmailDomain, getEmailDomain }, 'prj_1', 'acme.com', undefined, addDeps()), + emailDomainAdd( + { + addEmailDomain, + getEmailDomain, + getEmailSettings: selfServeSettings, + listEmailDomains: noEmailDomains, + }, + 'prj_1', + 'acme.com', + undefined, + addDeps(), + ), ).rejects.toThrow('exit 1'); expect(fetchSpy).toHaveBeenCalledTimes(1); @@ -826,7 +1206,12 @@ describe('emailDomainAdd', () => { vi.stubGlobal('fetch', fetchSpy); await emailDomainAdd( - { addEmailDomain, getEmailDomain }, + { + addEmailDomain, + getEmailDomain, + getEmailSettings: selfServeSettings, + listEmailDomains: noEmailDomains, + }, 'prj_1', 'acme.com', undefined, @@ -862,7 +1247,12 @@ describe('emailDomainAdd', () => { vi.stubGlobal('fetch', fetchSpy); await emailDomainAdd( - { addEmailDomain, getEmailDomain }, + { + addEmailDomain, + getEmailDomain, + getEmailSettings: selfServeSettings, + listEmailDomains: noEmailDomains, + }, 'prj_1', 'acme.com', undefined, @@ -891,7 +1281,12 @@ describe('emailDomainAdd', () => { vi.stubGlobal('fetch', fetchSpy); await emailDomainAdd( - { addEmailDomain, getEmailDomain }, + { + addEmailDomain, + getEmailDomain, + getEmailSettings: selfServeSettings, + listEmailDomains: noEmailDomains, + }, 'prj_1', 'acme.com', undefined, @@ -921,7 +1316,18 @@ describe('emailDomainAdd', () => { vi.stubGlobal('fetch', fetchSpy); await expect( - emailDomainAdd({ addEmailDomain, getEmailDomain }, 'prj_1', 'acme.com', undefined, addDeps()), + emailDomainAdd( + { + addEmailDomain, + getEmailDomain, + getEmailSettings: selfServeSettings, + listEmailDomains: noEmailDomains, + }, + 'prj_1', + 'acme.com', + undefined, + addDeps(), + ), ).rejects.toThrow('exit 1'); expect(getEmailDomain).toHaveBeenCalledTimes(15); @@ -950,7 +1356,18 @@ describe('emailDomainAdd', () => { vi.stubGlobal('fetch', fetchSpy); await expect( - emailDomainAdd({ addEmailDomain, getEmailDomain }, 'prj_1', 'acme.com', undefined, addDeps()), + emailDomainAdd( + { + addEmailDomain, + getEmailDomain, + getEmailSettings: selfServeSettings, + listEmailDomains: noEmailDomains, + }, + 'prj_1', + 'acme.com', + undefined, + addDeps(), + ), ).rejects.toThrow('exit 1'); // The key is present (new api) but null — not this attempt, so the @@ -978,7 +1395,18 @@ describe('emailDomainAdd', () => { vi.stubGlobal('fetch', fetchSpy); await expect( - emailDomainAdd({ addEmailDomain, getEmailDomain }, 'prj_1', 'acme.com', undefined, addDeps()), + emailDomainAdd( + { + addEmailDomain, + getEmailDomain, + getEmailSettings: selfServeSettings, + listEmailDomains: noEmailDomains, + }, + 'prj_1', + 'acme.com', + undefined, + addDeps(), + ), ).rejects.toThrow('exit 1'); // 404 at every one of the 15 polls — provably nothing persisted. @@ -1008,7 +1436,18 @@ describe('emailDomainAdd', () => { vi.stubGlobal('fetch', fetchSpy); await expect( - emailDomainAdd({ addEmailDomain, getEmailDomain }, 'prj_1', 'acme.com', undefined, addDeps()), + emailDomainAdd( + { + addEmailDomain, + getEmailDomain, + getEmailSettings: selfServeSettings, + listEmailDomains: noEmailDomains, + }, + 'prj_1', + 'acme.com', + undefined, + addDeps(), + ), ).rejects.toThrow('exit 1'); // No poll produced any read: a blind revoke could brick a committed @@ -1024,7 +1463,8 @@ describe('emailDomainStatus', () => { const getEmailDomain = vi.fn(async () => statusRow({ token_id: 'cf-token-id', token_expires_on: '2026-12-31T00:00:00Z' }), ); - await emailDomainStatus({ getEmailDomain }, 'prj_1', 'acme.com'); + const getEmailSettings = vi.fn(selfServeSettings); + await emailDomainStatus({ getEmailDomain, getEmailSettings }, 'prj_1', 'acme.com'); const block = notedBlocks(); expect(block).toContain('Domain: acme.com'); @@ -1033,6 +1473,7 @@ describe('emailDomainStatus', () => { expect(block).toContain('Token: id cf-token-id · expires 2026-12-31T00:00:00Z'); expect(block).toContain('Relay: void-email-relay v1'); expect(getEmailDomain).toHaveBeenCalledWith('prj_1', 'acme.com'); + expect(getEmailSettings).not.toHaveBeenCalled(); }); it('points at sync when the relay drifted or went missing', async () => { @@ -1041,6 +1482,7 @@ describe('emailDomainStatus', () => { getEmailDomain: vi.fn(async () => statusRow({ relay_drift: true, current_relay_version: 2 }), ), + getEmailSettings: selfServeSettings, }, 'prj_1', 'acme.com', @@ -1049,7 +1491,10 @@ describe('emailDomainStatus', () => { expect(printed(infoSpy)).toContain('void email domain sync acme.com'); await emailDomainStatus( - { getEmailDomain: vi.fn(async () => statusRow({ relay_missing: true })) }, + { + getEmailDomain: vi.fn(async () => statusRow({ relay_missing: true })), + getEmailSettings: selfServeSettings, + }, 'prj_1', 'acme.com', ); @@ -1062,6 +1507,7 @@ describe('emailDomainStatus', () => { getEmailDomain: vi.fn(async () => statusRow({ status: 'token_revoked', credential_status: 'revoked' }), ), + getEmailSettings: selfServeSettings, }, 'prj_1', 'acme.com', @@ -1082,6 +1528,7 @@ describe('emailDomainStatus', () => { setup_state: { remaining_steps: ['routing_subdomain'] }, }), ), + getEmailSettings: selfServeSettings, }, 'prj_1', 'mail.acme.com', @@ -1092,7 +1539,10 @@ describe('emailDomainStatus', () => { it('renders the catch-all state, and the sync hint only when it drifted', async () => { await emailDomainStatus( - { getEmailDomain: vi.fn(async () => statusRow({ catch_all: 'points_here' })) }, + { + getEmailDomain: vi.fn(async () => statusRow({ catch_all: 'points_here' })), + getEmailSettings: selfServeSettings, + }, 'prj_1', 'acme.com', ); @@ -1104,6 +1554,7 @@ describe('emailDomainStatus', () => { getEmailDomain: vi.fn(async () => statusRow({ catch_all: 'points_elsewhere', catch_all_drift: true }), ), + getEmailSettings: selfServeSettings, }, 'prj_1', 'acme.com', @@ -1111,6 +1562,97 @@ describe('emailDomainStatus', () => { expect(notedBlocks()).toContain('Catch-all: points_elsewhere'); expect(printed(infoSpy)).toContain('run `void email domain sync acme.com` to re-point it'); }); + + it('points administrator-managed setup and drift remediation at the administrator', async () => { + await emailDomainStatus( + { + getEmailDomain: vi.fn(async () => + statusRow({ + managed_by: 'admin', + status: 'pending', + mode: 'subdomain', + domain: 'mail.acme.com', + relay_drift: true, + catch_all: 'points_elsewhere', + catch_all_drift: true, + setup_state: { remaining_steps: ['routing_subdomain'] }, + }), + ), + getEmailSettings: selfServeSettings, + }, + 'prj_1', + 'mail.acme.com', + ); + + expect(printed(warnSpy)).toContain( + "One step for your administrator, in the platform's Cloudflare account", + ); + expect(printed(infoSpy)).toContain('void platform email domain-sync mail.acme.com'); + expect(printed(infoSpy)).not.toContain('void email domain sync'); + }); + + it.each([ + ['platform credential', 'platform' as const, false], + ['pasted API token', 'api_token' as const, true], + ])( + 'points failed administrator setup using a %s at domain-add', + async (_label, kind, tokenStdin) => { + const getEmailSettings = vi.fn(async () => ({ + domains: { management: 'admin' as const, projectSlug: 'hr-portal' }, + })); + await emailDomainStatus( + { + getEmailDomain: vi.fn(async () => + statusRow({ + managed_by: 'admin', + credential_kind: kind, + status: 'failed', + relay_worker_name: null, + relay_version: null, + relay_drift: true, + catch_all_drift: true, + setup_state: { failed_step: 'zone_lookup' }, + }), + ), + getEmailSettings, + }, + 'prj_1', + 'acme.com', + ); + + const output = printed(warnSpy) + printed(infoSpy); + expect(output).toContain('void platform email domain-add acme.com --project hr-portal'); + expect(output.includes('--token-stdin')).toBe(tokenStdin); + expect(output).not.toContain('domain-sync'); + expect(getEmailSettings).toHaveBeenCalledTimes(1); + }, + ); + + it('points an expired administrator API token at platform domain-add with the project slug', async () => { + const getEmailSettings = vi.fn(async () => ({ + domains: { management: 'admin' as const, projectSlug: 'hr-portal' }, + })); + await emailDomainStatus( + { + getEmailDomain: vi.fn(async () => + statusRow({ + managed_by: 'admin', + credential_kind: 'api_token', + status: 'token_expired', + credential_status: 'expired', + }), + ), + getEmailSettings, + }, + 'prj_1', + 'acme.com', + ); + + expect(printed(warnSpy)).toContain( + 'void platform email domain-add acme.com --project hr-portal --token-stdin', + ); + expect(getEmailSettings).toHaveBeenCalledWith('prj_1'); + }); }); describe('emailDomainList', () => { diff --git a/packages/void/test/unit/operator-cli.test.ts b/packages/void/test/unit/operator-cli.test.ts index cde456d94..7aa5b6312 100644 --- a/packages/void/test/unit/operator-cli.test.ts +++ b/packages/void/test/unit/operator-cli.test.ts @@ -125,6 +125,76 @@ describe('operator argument and help coverage', () => { body: { cursor: '{"phase":"resources"}' }, }); }); + it('routes the email group to the operator email endpoints', () => { + expect(parseOperatorArgs(['email', 'policy'])).toMatchObject({ + method: 'GET', + path: '/email/policy', + }); + expect( + parseOperatorArgs(['email', 'policy-set', 'domains', '--domains', 'a.example,b.example']), + ).toMatchObject({ + method: 'POST', + path: '/email/policy', + body: { mode: 'domains', domains: 'a.example,b.example' }, + }); + expect(parseOperatorArgs(['email', 'settings-set', '--domains', 'admin'])).toMatchObject({ + method: 'POST', + path: '/email/settings', + body: { domains: 'admin' }, + }); + expect( + parseOperatorArgs(['email', 'limit', 'my/slug', '--monthly', '500', '--burst', '20']), + ).toMatchObject({ + method: 'POST', + path: '/email/projects/my%2Fslug/limit', + body: { monthly: '500', burst: '20' }, + }); + expect(() => parseOperatorArgs(['email', 'policy', '--domains', 'x'])).toThrow(); + expect(() => parseOperatorArgs(['email', 'limit'])).toThrow(); + }); + it('routes the email domain verbs and carries their booleans in the body', () => { + expect(parseOperatorArgs(['email', 'domains', '--project', 'hr-portal'])).toMatchObject({ + method: 'GET', + path: '/email/domains', + query: { project: 'hr-portal' }, + }); + expect( + parseOperatorArgs([ + 'email', + 'domain-add', + 'mail.example.com', + '--project', + 'hr-portal', + '--token-stdin', + '--yes', + ]), + ).toMatchObject({ + method: 'POST', + path: '/email/domains', + body: { domain: 'mail.example.com', project: 'hr-portal' }, + tokenStdin: true, + yes: true, + }); + expect(parseOperatorArgs(['email', 'domain-status', 'mail.example.com'])).toMatchObject({ + method: 'GET', + path: '/email/domains/mail.example.com', + }); + expect(parseOperatorArgs(['email', 'domain-sync', 'mail.example.com'])).toMatchObject({ + method: 'POST', + path: '/email/domains/mail.example.com/sync', + body: {}, + }); + expect( + parseOperatorArgs(['email', 'domain-remove', 'mail.example.com', '--force']), + ).toMatchObject({ + method: 'POST', + path: '/email/domains/mail.example.com/remove', + body: { force: true }, + }); + expect(parseOperatorArgs(['email', 'domain-remove', 'mail.example.com']).body).toEqual({}); + expect(() => parseOperatorArgs(['email', 'domain-sync', 'x', '--force'])).toThrow(); + expect(() => parseOperatorArgs(['email', 'domain-add', 'x', '--force'])).toThrow(); + }); it.each([ ['user', 'list', '--limit', '101'], ['user', 'list', '--page', '1e3'], diff --git a/packages/void/test/unit/platform-setup.test.ts b/packages/void/test/unit/platform-setup.test.ts index cb22e62ab..aedd6806e 100644 --- a/packages/void/test/unit/platform-setup.test.ts +++ b/packages/void/test/unit/platform-setup.test.ts @@ -133,6 +133,29 @@ describe('platform credential setup links', () => { expect(permissions(custom)).toContainEqual({ key: 'ssl_and_certificates', type: 'edit' }); expect(permissions(original)).not.toContainEqual({ key: 'ssl_and_certificates', type: 'edit' }); }); + it('requests the email zone and sending permissions only when email is enabled', () => { + const original = plan('example.com'); + const withEmail = { + ...original, + deployment: { + ...original.deployment, + capabilities: { ...original.deployment.capabilities, email: true }, + }, + }; + const permissions = (input: PlatformInstallPlan) => + JSON.parse( + new URL(platformSetupPages(input).runtime.url).searchParams.get('permissionGroupKeys')!, + ) as Array<{ key: string; type: string }>; + const emailKeys = ['zone', 'zone_settings', 'email_routing_rule', 'email_sending']; + expect(permissions(withEmail).map((permission) => permission.key)).toEqual( + expect.arrayContaining(emailKeys), + ); + expect(permissions(original).some((permission) => emailKeys.includes(permission.key))).toBe( + false, + ); + expect(platformSetupPages(withEmail).runtime.instructions).toContain('Email Sending: Edit'); + expect(platformSetupPages(original).runtime.instructions).not.toContain('Email Sending'); + }); it('shows the final callback in previews as well as interactive setup', () => { const preview = formatPlatformSetupPlan(plan()); expect(preview).toContain('https://github.com/settings/applications/new'); diff --git a/platform/README.md b/platform/README.md index 49191df1e..09946988c 100644 --- a/platform/README.md +++ b/platform/README.md @@ -175,25 +175,26 @@ Void is one repository with an explicit dependency boundary: ### Where Code for X Lives -| Feature | Primary code paths | -| ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Vite plugin and local runtime | `../packages/void/src/index.ts`, `plugin-*`, `runtime/*` | -| CLI commands | `../packages/void/src/cli/*` | -| File-based API routing | `../packages/void/src/router/*`, `runtime/handler.ts` | -| Pages routing, loaders, actions, named actions, deferred props | `../packages/void/src/pages/*`, adapter runtimes in `../packages/{vue,react,svelte,solid}/src/runtime/*` | -| Typed route/page/action codegen | `../packages/void/src/codegen/*`, `routes-stub.ts` | -| Binding inference | `../packages/void/src/plugin-inference.ts`, `../packages/void/src/inference/*` | -| Drizzle integration and migrations | `../packages/void/src/drizzle/*`, `../packages/void/src/migrations/*` | -| Framework detection and deploy presets | `../packages/void/src/plugin-inference.ts`, `../packages/void/src/cli/preset.ts` | -| Platform auth and project APIs | `packages/api/src/routes/*`, `packages/api/src/auth/*`, `packages/api/src/middleware/*` | -| Administrator UI | `packages/api/src/admin/*` | -| Deploy pipeline | `../packages/void/src/cli/deploy.ts`, `packages/api/src/routes/deploy.ts`, `packages/api/src/assets.ts`, `packages/api/src/provisioning/*` | -| Deploy on GitHub (App connect, webhooks, OIDC, build containers) | `packages/api/src/routes/{github,webhooks,auth-oidc}.ts`, `packages/api/src/github/*`, `packages/api/src/build-scheduler.ts`, `packages/api/src/build-manager.ts`, `packages/api/container/*`, `packages/api/src/auth/deploy-token.ts` | -| Hostname routing, static serving, ISR, redirects, rewrites, headers, metering | `packages/dispatch/src/*` | -| AI metering, BYOK AI gateway, remote D1/KV/R2 access, revalidation bridge | `packages/proxy/src/*` | -| Runtime logs and live tailing | `packages/tail/src/index.ts`, `packages/api/src/admin/routes/deployments.tsx` | -| User-facing dashboard | `packages/dashboard/pages/*`, `packages/dashboard/middleware/01.auth.ts`, `packages/dashboard/lib/api.ts` | -| Dashboard styling | `packages/dashboard/ui/styles/base.css`, `packages/dashboard/ui/styles/tokens.css` | +| Feature | Primary code paths | +| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Vite plugin and local runtime | `../packages/void/src/index.ts`, `plugin-*`, `runtime/*` | +| CLI commands | `../packages/void/src/cli/*` | +| File-based API routing | `../packages/void/src/router/*`, `runtime/handler.ts` | +| Pages routing, loaders, actions, named actions, deferred props | `../packages/void/src/pages/*`, adapter runtimes in `../packages/{vue,react,svelte,solid}/src/runtime/*` | +| Typed route/page/action codegen | `../packages/void/src/codegen/*`, `routes-stub.ts` | +| Binding inference | `../packages/void/src/plugin-inference.ts`, `../packages/void/src/inference/*` | +| Drizzle integration and migrations | `../packages/void/src/drizzle/*`, `../packages/void/src/migrations/*` | +| Framework detection and deploy presets | `../packages/void/src/plugin-inference.ts`, `../packages/void/src/cli/preset.ts` | +| Platform auth and project APIs | `packages/api/src/routes/*`, `packages/api/src/auth/*`, `packages/api/src/middleware/*` | +| Administrator UI | `packages/api/src/admin/*` | +| Deploy pipeline | `../packages/void/src/cli/deploy.ts`, `packages/api/src/routes/deploy.ts`, `packages/api/src/assets.ts`, `packages/api/src/provisioning/*` | +| Deploy on GitHub (App connect, webhooks, OIDC, build containers) | `packages/api/src/routes/{github,webhooks,auth-oidc}.ts`, `packages/api/src/github/*`, `packages/api/src/build-scheduler.ts`, `packages/api/src/build-manager.ts`, `packages/api/container/*`, `packages/api/src/auth/deploy-token.ts` | +| Hostname routing, static serving, ISR, redirects, rewrites, headers, metering | `packages/dispatch/src/*` | +| AI metering, BYOK AI gateway, remote D1/KV/R2 access, revalidation bridge | `packages/proxy/src/*` | +| Email (shared sender, custom domains, recipient policy, admin-managed domains) | `packages/proxy/src/email*.ts`, `packages/api/src/routes/email.ts`, `packages/api/src/email/*`, `packages/api/src/operator/email-routes.ts`, `packages/api/src/admin/routes/email.tsx`, `../packages/void/src/cli/email*.ts`, `../packages/void/src/runtime/email*` | +| Runtime logs and live tailing | `packages/tail/src/index.ts`, `packages/api/src/admin/routes/deployments.tsx` | +| User-facing dashboard | `packages/dashboard/pages/*`, `packages/dashboard/middleware/01.auth.ts`, `packages/dashboard/lib/api.ts` | +| Dashboard styling | `packages/dashboard/ui/styles/base.css`, `packages/dashboard/ui/styles/tokens.css` | The dashboard depends on `void` and `@void/react` through normal `workspace:*` dependencies. Those packages export only their `dist/` output, so run `vp run build` before `vp run --filter @voidcloud/platform deploy` on a fresh checkout. The platform workflows do this; the task graph does not. diff --git a/platform/meta/design-docs/0078-email-cf-native.md b/platform/meta/design-docs/0078-email-cf-native.md index 6a8afedd6..a72cb2b8e 100644 --- a/platform/meta/design-docs/0078-email-cf-native.md +++ b/platform/meta/design-docs/0078-email-cf-native.md @@ -1,202 +1,253 @@ --- -title: Cloudflare-Native Email Decisions +title: Cloudflare-Native Email Design status: implemented created: 2026-04-24 -updated: 2026-09-17 +updated: 2026-09-18 --- -# Cloudflare-native email decisions +# Cloudflare-native email design -The [email guide](../../../docs/guide/email.md) describes the supported API and -setup. This record preserves the trust boundaries, lifecycle decisions and -accepted limitations behind that implementation. The earlier Resend/webhook -proposal, re-land transcript, copied source and unimplemented setup recipes -are retired. +The [email guide](../../../docs/guide/email.md) and the +[administration guide](../../../docs/guide/platform-administration.md#managing-email) +describe the supported API and setup. This record holds the trust boundaries, +lifecycle rules and accepted limitations behind them. -## Hosting modes and lifecycle +## Hosting modes ### Mode A — managed platform The platform proxy owns the account-level `send_email` binding. Tenant -Workers receive only the authenticated `__VOID_PROXY` interface; giving -them a raw send binding would bypass destination consent, suspension and -quota policy. Native Cloudflare Workers use their own account binding. +Workers receive only the authenticated `__VOID_PROXY` interface; a raw send +binding would bypass destination consent, suspension and quota policy. -The mail domain is configured per environment as `EMAIL_SENDER_DOMAIN` -and injected as `__VOID_EMAIL_DOMAIN`. Missing configuration fails closed. -There is one project-slug binding, `__VOID_PROJECT_SLUG`. Senders must -belong to that project's namespace on the configured mail domain. +The mail domain is configured per environment as `EMAIL_SENDER_DOMAIN` and +injected as `__VOID_EMAIL_DOMAIN`. Missing configuration fails closed. The +project-slug binding is `__VOID_PROJECT_SLUG`; senders must belong to that +project's namespace on the configured mail domain. #### Verified destinations Cloudflare's account-level verification does not establish consent for every -project on the account. Preserve the distinction recorded in -`email_destinations.verification_source`: - -- **Option C — owner-relaxed reuse:** an already verified address matching - the current project owner's email can inherit that owner's consent. -- **Option B — platform confirmation:** an address verified for another - project remains pending until the recipient follows this project's - signed confirmation link. A Cloudflare refresh cannot bypass this gate. +project on the account. `email_destinations.verification_source` keeps the +distinction: + +- **Owner inheritance** — an already verified address matching the current + project owner's email inherits that owner's consent. The owner email is + rechecked inside the transaction that writes or promotes the destination; + an owner-email change revokes inherited consent and tightens the allowlist + without silently committing a failed tighten. +- **Platform confirmation** — an address verified for another project stays + pending until the recipient follows this project's signed confirmation + link. A Cloudflare refresh cannot bypass this gate. - A newly registered Cloudflare destination is promoted only when the - verified tag matches the project's registration. An unrelated recreation - of the same address does not prove this project's consent. + verified tag matches the project's registration; an unrelated recreation of + the same address proves nothing. -Owner inheritance must recheck the captured owner email inside the transaction -that writes or promotes the destination. Owner-email changes revoke inherited -consent and tighten the allowlist without silently committing a failed -tighten. Explicit revocation and project deletion also update the mirror. +The proxy reads `email-allowlist:` from routing KV. Destination +listing repairs a missing or inconsistent mirror from the authoritative +verified rows; an absent mirror fails closed. A verified platform row is not +revoked by an out-of-band Cloudflare deletion; Cloudflare refuses the actual +send until its destination is verified again. -The proxy reads `email-allowlist:` from routing KV. -Destination listing repairs a missing or inconsistent mirror from the -authoritative verified rows. Preserve fail-closed behavior when a mirror is -absent. A verified platform row is not automatically revoked by an -out-of-band Cloudflare deletion; Cloudflare still refuses actual sends until -its destination is verified again. +#### Recipient policy + +One installation-wide setting decides whether the per-project allowlist gates +shared-sender recipients: + +``` + mode shared-sender recipient passes when … default + ───────── ──────────────────────────────────────────────────── ─────── + verified it is on the project's allowlist yes + domains its domain is in the policy list, OR it is allowlisted + any always +``` + +The policy does not fake delivery: Cloudflare's `send_email` binding still +refuses an unverified destination unless the platform mail domain is onboarded +for Email Sending (Workers Paid on the operator's account). Under `domains` or +`any` that refusal still arrives as a per-recipient `UNVERIFIED_DESTINATION`; +the admin UI and the operator preview say so next to the policy. + +Scope: shared-sender sends and the inbound forward/reply gate +(`recipientAllowed` in the proxy's `email-policy.ts`, one gate for both). +Custom-domain sends are unchanged. Burst cap, monthly cap, the +`email-suspended:` kill switch and the reply-sender pin do not move. The +policy never widens delivery to an address on the platform's own mail domains +— `EMAIL_SENDER_DOMAIN` and, on inbound, the domain Cloudflare accepted the +message on, so an unset binding or a second routed domain opens nothing: +another tenant's `+tag@` inbox is reached only through that project's +verified-destination consent, whatever the mode — so `any` cannot turn two +auto-replying handlers into a loop or one tenant into another's spam source. +A tightening reaches every colo within the mirror's 60-second read cache. + +Storage: authoritative in the `platform_settings` table (key/value jsonb — +the installation's runtime-changeable settings), mirrored to routing KV as +`email-recipient-policy` for the proxy's hot path (`cacheTtl: 60`, like the +allowlist). An absent or unreadable mirror means `verified` — `any` included +when its shape is not the api's. A publish is ONE KV write — Workers KV +refuses a second write to a key within a second, so the mirror is never +cleared and re-put — ordered so the mirror never authorizes a recipient the +database has not committed: a tightening (a lower mode, or a list that only +shrinks) puts the mirror before the database commits — a refused put leaves +the saved policy unchanged, a failed commit leaves the proxy stricter until +the next read republishes; a widening (a higher mode, or a list that gains +any domain, even one that also drops others) commits first and puts after — +a failed put leaves the previous policy in force, the operator told so, +until a retry or the next read republishes. The one residual is that +previous policy: for a list edited both ways, its removed domains stay +reachable until the republish, exactly as they were a moment earlier. +Publisher and +repair serialize on a transaction-scoped advisory lock because KV has no +compare-and-set, and every read surface (operator, owner settings, admin +page) repairs a drifted mirror. Surface: the admin UI's **Email** page and +`void platform email policy` / `policy-set [--domains a,b]`, recorded +in `operator_events`. #### Quotas and visibility -Outbound sends and inbound forward/reply actions share destination, suspension, -burst and monthly checks. The counters use KV read-modify-write and are -best-effort under concurrency, not strict distributed admission limits. -Increasing a time window does not make those updates atomic. +Outbound sends and inbound forward/reply actions share destination, +suspension, burst and monthly checks. Burst and monthly caps apply to every +send through the proxy, custom domain included, and are +administrator-controlled per project. The counters are KV read-modify-write +and best-effort under concurrency, not strict admission limits. -Project destination listing picks up pending Cloudflare verification; -Cloudflare does not push that event into the platform. Persistent project -delivery logs are not implemented: `void email logs` reports pending support. -A handled inbound message can still have a forward or reply refused by its -outbound gate. Do not present a handled receipt as proof of delivery. +Destination listing picks up pending Cloudflare verification; Cloudflare does +not push that event. Persistent project delivery logs are not implemented: +`void email logs` reports pending support. A handled inbound message can still +have a forward or reply refused by its outbound gate; a handled receipt is not +proof of delivery. ### Mode B — the developer's own Cloudflare account -Native setup is implemented in +Native setup lives in [email-cloudflare.ts](../../../packages/void/src/cli/email-cloudflare.ts). -`void deploy --platform cloudflare` and `void email setup|status` -share its read-only preflight and selected Wrangler session. The developer -supplies `email.from`; Void does not choose another mail domain silently. +`void deploy --platform cloudflare` and `void email setup|status` share its +read-only preflight and selected Wrangler session. The developer supplies +`email.from`; Void never chooses a mail domain. #### One keystroke -Interactive deployment displays the selected account/domain and unfinished -account changes before offering setup. Noninteractive deployment reports -unfinished setup; `--require-email` makes it a deployment prerequisite. -Ready configuration is reconciled without repeating account mutations. +Interactive deployment shows the selected account, the domain and the +unfinished account changes before offering setup. Non-interactive deployment +reports unfinished setup and deploys without email; `--require-email` makes +setup a prerequisite. Ready configuration is reconciled without repeating +account mutations. -The checklist distinguishes account work from config updates and routing -rules. `wrangler deploy` owns rule application; a pending rule is not -itself a reason to rerun account onboarding. A previously attempted but -refused Sending onboarding can be retried explicitly with `void email setup`. +The checklist separates account work, config updates and routing rules. +`wrangler deploy` owns rule application; a pending rule is not a reason to +rerun account onboarding. A refused Sending onboarding is retried only by an +explicit `void email setup`. #### The flow, by lane 1. Read domain DNS, account identity, routing settings, existing rules and - Sending state before applying changes. -2. Enable routing only where needed. Preserve unrelated apex mail records - when the configured sender uses a subdomain. -3. Enable the zone's `support_subaddress` setting when needed; literal - address routing alone does not guarantee `support+tag@` delivery. -4. Attempt Sending onboarding and report plan or permission refusals without - treating them as permission to alter unrelated records. -5. Reconcile the owned sender/binding configuration. Write `addresses` + Sending state before applying anything. +2. Enable routing only where needed; leave apex mail records alone when the + sender uses a subdomain. +3. Enable the zone's `support_subaddress` setting when needed; a literal + address rule alone does not deliver `support+tag@`. +4. Attempt Sending onboarding; report plan or permission refusals without + touching unrelated records. +5. Reconcile the owned sender and binding configuration. Write `addresses` only when inbound routing is ready, then let pinned Wrangler apply it. -6. Print the resulting inbound map and remaining manual steps. - -Use pinned Wrangler commands wherever available. For zone classification, -subdomain routing and subaddressing calls without a Wrangler command, borrow -the selected session bearer through `wrangler auth token --json` inside -the narrow request closure. Do not persist, log, include in errors, or forward -that token to a different origin. Disable Wrangler disk logging on the token -handoff. Credentialed preflight runs before evaluating project code. - -The session gate checks the required email scopes for OAuth sessions. -API-token permissions are established by the actual reads; a Global API Key -is refused where the bridge requires a bearer. Prose parsers are pinned to -the bundled Wrangler version and tested against its captured output. A -parser miss yields unknown state and setup guidance, not permission to write. - -Void owns the entire derived top-level `addresses` array in this mode. -Hand-written entries that it cannot derive skip setup without changing that -array. Addresses already routed elsewhere are pruned from the proposed plan. -When routing is unready, omit/delete the config key rather than writing -`[]`, which would ask Wrangler to delete owned rules. Re-read the root -config after edits and preserve the deployment verifier's whole-array check. +6. Print the inbound map and the remaining manual steps. + +#### Credentials + +Use pinned Wrangler commands wherever one exists. For zone classification, +subdomain routing and subaddressing — calls with no Wrangler command — borrow +the session bearer through `wrangler auth token --json` inside the narrow +request closure. Never persist, log, include in errors or forward it to +another origin; disable Wrangler disk logging on the handoff. Credentialed +preflight runs before any project code is evaluated. + +The session gate checks the required email scopes on OAuth sessions. API-token +permissions are established by the actual reads. A Global API Key is refused +where the bridge needs a bearer. Prose parsers are pinned to the bundled +Wrangler version and tested against its captured output; a parser miss yields +unknown state and setup guidance, never permission to write. + +#### The `addresses` array + +Void owns the whole derived top-level `addresses` array. Hand-written entries +it cannot derive skip setup without changing the array. Addresses already +routed elsewhere are pruned from the plan. When routing is unready the key is +deleted, never written as `[]` — that would ask Wrangler to delete every owned +rule. The root config is re-read after edits and the deployment verifier's +whole-array check is preserved. #### Error map and degraded branches -The code classifies known provider errors for routing-not-enabled, -already-onboarded and Sending-entitlement refusals. Unknown failures remain -diagnosable and do not authorize retries of uncertain writes. +Known provider errors are classified for routing-not-enabled, +already-onboarded and Sending-entitlement refusals. Unknown failures stay +diagnosable and never authorize retrying an uncertain write. -If subdomain routing or subaddressing is refused, print the explicit -Cloudflare dashboard step and withhold `addresses` for that run. -Never claim setup is complete merely because the Worker uploaded. -Sender config and an inert send binding can remain useful for verified -destinations when arbitrary-recipient Sending is unavailable. +A refused subdomain-routing or subaddressing call prints the explicit +Cloudflare dashboard step and withholds `addresses` for that run. Setup is +never reported complete merely because the Worker uploaded. Sender config and +an inert send binding remain useful for verified destinations when +arbitrary-recipient Sending is unavailable. -#### Still unverified +#### Unverified against live Cloudflare -Keep credentialed provider acceptance separate from unit-test evidence. -Live checks must establish the subdomain routing POST and subaddressing PATCH -with the selected credential type, rule-plan permissions, conflict behavior -after dashboard edits or Worker renames, and multi-label mail subdomains. -The earlier local source probes are not evidence that these operations -succeeded in an arbitrary account. Follow the -[live release procedure](../release-acceptance.md). +Unit tests are not provider acceptance. Still to establish with the selected +credential type, per the [live release procedure](../release-acceptance.md): +the subdomain routing POST and subaddressing PATCH, rule-plan permissions, +conflict behavior after dashboard edits or Worker renames, and multi-label +mail subdomains. ### Mode C — self-hosted platform -The same proxy and consent rules apply to an operator-owned platform. -Configure its mail domain, sender, proxy binding and Email Routing before -enabling email. Core installation does not make optional email setup -automatic. Use [platform operations](../operations.md) for the supported -operator workflow. +The same proxy and consent rules apply to an operator-owned platform. The +operator configures its mail domain, sender, proxy binding and Email Routing +before enabling email; core installation does not do this automatically. Use +[platform operations](../operations.md) for the operator workflow. + +Users of a self-hosted platform may hold no Cloudflare credential at all. +[Administrator-managed domains](#administrator-managed-domains) covers +that case; the engine below is unchanged by it. ## Cross-worker dispatch -Cloudflare's real `ForwardableEmailMessage` is not serializable over RPC. -The proxy buffers a bounded envelope and calls the tenant's -`WorkerEntrypoint.email()`; the generated handler returns action intents. -The proxy validates and enacts those intents against the original message. +Cloudflare's `ForwardableEmailMessage` is not serializable over RPC. The +proxy buffers a bounded envelope and calls the tenant's +`WorkerEntrypoint.email()`; the generated handler returns action intents, +and the proxy validates and enacts them against the original message. -Keep the wire recipient as `__voidPlatformTo` for dispatch. Strip the -platform slug prefix exactly once for the user-facing message. Case-insensitive -matching must preserve the original case of captured parameters. +The wire recipient travels as `__voidPlatformTo` for dispatch. The platform +slug prefix is stripped exactly once for the user-facing message. Matching is +case-insensitive and preserves the original case of captured parameters. -Pre-buffering bounds the inbound raw body and distinct header count before -constructing the RPC envelope. Forward/reply/reject validation also bounds -action count, address/header size and cumulative reply bytes. These are -independent limits; a raw byte cap alone permits an excessive number of -distinct short headers. Invalid actions cannot bypass the outbound gate. +Pre-buffering bounds the inbound raw body and the distinct header count before +the RPC envelope is built. Forward/reply/reject validation bounds action +count, address and header size and cumulative reply bytes. These limits are +independent — a raw byte cap alone permits an excessive number of short +headers. Invalid actions cannot bypass the outbound gate. ### RPC result materialization — accepted limit The tenant's entire RPC return value is materialized inside the shared proxy before action validation. The framework's action queue constrains cooperative -handlers; an arbitrary tenant entrypoint can return another object graph. - -The 2026-09-15 workerd probe established that the 32 MiB serialized RPC limit -does not bound the receiver's heap: a dense array of nulls near that cap -materialized about 128 MiB, and empty objects about 503 MiB. Those measurements -used local workerd without the production isolate-memory limit; they do not -claim a measured production eviction. The relevant provider contracts are -[RPC limits](https://developers.cloudflare.com/workers/runtime-apis/rpc/#limitations) -and [Worker memory](https://developers.cloudflare.com/workers/platform/limits/#memory). - -This was accepted as an architectural limitation. Callee CPU limits reduce -some inputs but do not establish a hard caller-heap bound. Returning a string, -stream or `RpcTarget` by convention cannot constrain a hostile callee. -Possible future boundaries are a reserved HTTP endpoint with a bounded body, -a dedicated inbound-email dispatch Worker, or a separate email CPU budget. -They change the framework contract or deployment architecture and remain -separate design decisions. +handlers; an arbitrary tenant entrypoint can return any object graph, and the +32 MiB serialized [RPC limit](https://developers.cloudflare.com/workers/runtime-apis/rpc/#limitations) +does not bound the receiver's heap — a dense array near the cap materializes +several times its serialized size against +[Worker memory](https://developers.cloudflare.com/workers/platform/limits/#memory). + +This is an accepted architectural limitation. Callee CPU limits reduce some +inputs but give no hard caller-heap bound, and no return-type convention can +constrain a hostile callee. Possible future boundaries — a reserved HTTP +endpoint with a bounded body, a dedicated inbound-email dispatch Worker, or a +separate email CPU budget — change the framework contract or deployment +architecture and are separate decisions. ## Local development and testing The development inbox captures outbound MIME and requires the project's -dev-trigger token on every route. It is not a production send transport. -Skipping capture with `sendInDev` does not create a binding or guarantee -real delivery. Preserve the user's independently named send bindings when -removing Void's own dev transport. +dev-trigger token on every route. It is not a production transport. Skipping +capture with `sendInDev` creates no binding and guarantees no delivery. When +Void removes its own dev send transport it preserves the user's independently +named send bindings. Test harnesses exercise the same message construction, routing and action semantics as production. Inbound parsing consumes a stream; handlers choose @@ -205,93 +256,80 @@ and fall back safely when threading metadata is malformed. ## Custom domains on Cloudflare -A project can send as and receive at its own domain — `support@acme.com`, -`noreply@mail.acme.com` — when that domain's DNS zone is on Cloudflare. The -zone stays in the customer's own Cloudflare account. The customer grants the -platform a scoped credential once; the platform configures the zone and runs -both directions on the customer's behalf. Inbound crosses accounts in a -**relay worker** deployed into the customer's account; outbound crosses back -as a REST send with the same credential. The relay needs no operator mail -zone, so custom domains work identically on a self-hosted platform, and the -raw MIME arrives unmodified — no `To:` recovery, no BCC loss, no SRS/ARC -hop, no destination verification click. The price is one wider token scope — -`Workers Scripts: Write` — which the customer sees on the token form. +A project can send as and receive at its own domain — `support@example.com`, +`noreply@mail.example.com` — when that domain's DNS zone is on Cloudflare. +The zone stays in the customer's own account. The customer grants the platform +a scoped credential once; the platform configures the zone and runs both +directions on their behalf. Inbound crosses accounts in a **relay worker** +deployed into the customer's account; outbound crosses back as a REST send +with the same credential. The relay needs no operator mail zone, so custom +domains work identically on a self-hosted platform, and the raw MIME arrives +unmodified — no `To:` recovery, no BCC loss, no SRS/ARC hop, no destination +verification click. The price is one wider token scope, `Workers Scripts: +Write`, which the customer sees on the token form. "On Cloudflare" means a zone under Cloudflare DNS in an account the customer -controls. A domain that only CNAMEs to Void through Cloudflare for SaaS is -**not** on Cloudflare in this sense and stays out of scope: Email Routing -needs MX records on a Cloudflare zone, and RFC 2181 forbids MX beside a -CNAME. Those customers keep the provider path in [Future Work](#future-work). +controls. A domain that only CNAMEs to Void through Cloudflare for SaaS is out +of scope: Email Routing needs MX records on a Cloudflare zone, and RFC 2181 +forbids MX beside a CNAME. + +After the one-time grant, a developer sees an address, recipients, a counter +and an error code — never a zone, a rule or a token scope. One domain belongs +to one project; a company uses one subdomain per project. ### Why this model -| Model | What it needs from the customer | Why it is or is not the one | -| ------------------------------------------------ | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | -| **Bring your zone — a scoped token** _(adopted)_ | create one API token, one dashboard step for a subdomain | zone stays theirs; apex MX untouched; works on Free for routing, Paid for sending; the domain signs its own DKIM, so reputation is per customer | -| NS delegation of the whole domain to our account | move all of their DNS to us | we would own and have to manage every record they have; greenfield DNS UI | -| `mail.acme.com` as its own zone in our account | delegate one subdomain | Cloudflare's subdomain setup is **Enterprise-only** | -| Self-hosted SMTP relay | nothing | reinvents what Email Routing gives for free | - -The "users never touch Cloudflare" premise is kept for the managed -platform's shared sender and dropped for a customer's **own domain**: the -customer already touches Cloudflare to own the zone, and the credential is -the same act of trust they give `wrangler login`. The rule that survives is -the one from [Hosting modes and lifecycle](#hosting-modes-and-lifecycle): -after the one-time grant, a developer sees an address, recipients, a counter -and an error code — never a zone, a rule or a token scope. +| Model | Needs from the customer | Verdict | +| ------------------------------------------------ | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| **Bring your zone — a scoped token** _(adopted)_ | one credential, one dashboard step per sub. | zone stays theirs; apex MX untouched; routing on Free, sending on Paid; the domain signs its own DKIM, reputation per customer | +| NS delegation of the whole domain | move all DNS to us | we would own every record they have | +| `mail.example.com` as a zone in our account | delegate one subdomain | Cloudflare subdomain setup is Enterprise-only | +| Self-hosted SMTP relay | nothing | reinvents Email Routing | ### The two paths -A routing rule can only name a worker in its own account — the rule's action -value is a bare worker name, and worker names are unique per account — so -inbound from a customer zone cannot reach `void-proxy` directly. It crosses -accounts inside a **relay worker** the platform deploys into the customer's -account with the stored credential, and outbound crosses back as a **REST -send** with the same credential. +A routing rule can only name a worker in its own account, so inbound from a +customer zone cannot reach `void-proxy` directly. ``` -INBOUND support+T-42@acme.com - │ MX in the customer's account (mail.acme.com, or the apex when its MX is empty) +INBOUND support+T-42@example.com + │ MX in the customer's account (mail.example.com, or the apex when its MX is empty) ▼ catch-all rule → void-email-relay (a worker in the customer's account, deployed by setup) │ envelope recipient exact — To:, Cc: and BCC alike · raw MIME unmodified ▼ - POST proxy.void.cloud/email/relay Authorization: the domain's relay secret + POST /email/relay Authorization: the domain's relay secret │ secret → registered domain → project ▼ - project worker email() to = support+T-42@acme.com viaDomain = acme.com - dispatcher runs in no-prefix mode (Mode B item 4) + project worker email() to = support+T-42@example.com viaDomain = example.com + dispatcher runs in no-prefix mode -OUTBOUND sendEmail({ from: "noreply@acme.com", … }) - │ proxy: from-domain is registered to this project → use that domain's credential +OUTBOUND sendEmail({ from: "noreply@example.com", … }) + │ proxy: from-domain is registered to this project → lease that domain's credential ▼ POST /accounts/{customer}/email/sending/send_raw { from, recipients, mime_message } - DKIM = acme.com (or mail.acme.com) · quota = the customer's · message_id returned + DKIM = the customer's domain · daily quota = the customer's · message_id returned ``` **The relay worker.** One per registered domain, named `void-email-relay` -(suffixed `-2`, `-3`… on collision within the account, up to 50). It is ~30 -lines: the `email()` handler streams `message.raw` straight into a POST with -the domain's relay secret — no parsing, no buffering, so it stays inside -Workers Free CPU limits, and messages over the 25 MiB inbound cap never -reach it (Cloudflare rejects them first). The stream rides through a -`FixedLengthStream(rawSize)`: the runtime derives Content-Length from the -body and ignores a hand-set one, so a plain stream would go out chunked — -and the proxy refuses a length-less body before any crypto. `message.to` is a single address -(the envelope RCPT TO), so a multi-recipient message arrives as one -invocation per recipient. The catch-all is zone-scoped with no domain -parameter, so mail for a second domain on the same zone can land on the -first domain's relay: the relay refuses any message whose recipient domain -is not its own (`setReject` — a loud bounce, not a silent drop), and the -proxy's `/email/relay` branch independently 403s when the declared -`X-Void-Email-Domain` header does not equal the recipient's domain, keying -its mirror lookup on the recipient's domain. Attribution binds to the -envelope recipient, never the relay's claim. +(suffixed `-2`, `-3`… on collision within the account, up to 50). Its +`email()` handler streams `message.raw` straight into a POST with the domain's +relay secret — no parsing, no buffering, so it stays inside Workers Free CPU +limits. The stream rides through a `FixedLengthStream(rawSize)` because the +runtime derives Content-Length from the body and the proxy refuses a +length-less body before any crypto. `message.to` is the single envelope +recipient, so a multi-recipient message is one invocation per recipient. + +The catch-all is zone-scoped, so mail for a second domain on the same zone can +land on the first domain's relay: the relay `setReject`s any message whose +recipient domain is not its own — a loud bounce — and the proxy's `/email/relay` +branch independently 403s when the declared `X-Void-Email-Domain` does not +equal the recipient's domain, keying its mirror lookup on the recipient's +domain. Attribution binds to the envelope recipient, never the relay's claim. Setup deploys the relay with the stored credential (`PUT -/accounts/{a}/workers/scripts/{name}` — one `Workers Scripts: Edit` token -covers both the script PUT and the secret PUT) and points the zone's -catch-all at it: +/accounts/{a}/workers/scripts/{name}` plus the secret PUT — one `Workers +Scripts: Edit` token covers both) and points the zone's catch-all at it: ``` PUT /zones/{z}/email/routing/rules/catch_all @@ -300,484 +338,288 @@ PUT /zones/{z}/email/routing/rules/catch_all "enabled": true } ``` -`value` is an array with exactly one entry, and a catch-all holds exactly -one action. Omitted `source` defaults to `api`, which has one edge: if the -customer later runs `wrangler deploy` with an `addresses` array on the same -zone, wrangler reports our rule as an externally-managed conflict and asks -before taking over. Documented in `status` output, not fought. The name is -stable once chosen: renaming a worker detaches every routing rule pointing -at it, so `sync` redeploys in place and never renames. A walked name is -claimed on the row BEFORE the script upload and secret PUT (see the -concurrency backstops), so a failed write still leaves the row knowing its -relay — a retry redeploys in place and a remove can find the worker; name -exhaustion fails the deploy with no name recorded (nothing was uploaded). The code is ours but the worker is the -customer's: they can read it, edit it or delete it, and `status` reports -drift on the next probe rather than fighting it. When the relay code -changes, `status` shows the deployed version and `void email domain sync` -redeploys with the stored credential. A leaked secret lets a stranger push -mail into one project's inbound, nothing else; `sync` rotates it. Worker -secrets are write-only — not even the account owner can read one back — so -rotation is overwrite with a new value, never read-compare. - -**Inbound identity.** The relay sees the envelope recipient, so the address -the sender wrote arrives exact — a BCC included. The raw MIME passes through -unmodified: no SRS rewrite, no ARC seal, no `To:` recovery. `replyEmail` on -a relayed message defaults `from` to the address that received it, on the -customer domain, and goes out over the REST path. +The rule's omitted `source` defaults to `api`; if the customer later runs +`wrangler deploy` with an `addresses` array on the same zone, Wrangler reports +our rule as an externally-managed conflict and asks before taking over. This +is stated in `status` output, not fought. The relay name is stable once +chosen — renaming a worker detaches every rule pointing at it — so `sync` +redeploys in place. The code is ours but the worker is the customer's: they +can read, edit or delete it, and `status` reports drift rather than fighting +it. A leaked secret lets a stranger push mail into one project's inbound and +nothing else; `sync` rotates it. Worker secrets are write-only, so rotation is +overwrite, never read-compare. **Trust.** The proxy accepts a relayed message only with the domain's relay -secret, generated by the platform at setup and stored on the row; the secret -maps to exactly one registered domain of one project, so `viaDomain` cannot -be forged without it. The proxy's auth branch applies header/length bounds -before any crypto, looks the domain's mirror up in ROUTING KV, compares the -secret's sha256 in constant time, and refuses any mirror whose status is not -`active` — a revoked or removed domain stops receiving at the proxy, not at -the relay. The reply-sender pin gains a second accepted shape: any address -on a domain registered to the receiving project. A reply send then runs -through the FROM domain's own mirror and credential lease — two domains of -one project can live in different customer accounts, and Email Service -rejects a sender outside the sending account's verified zones. - -### The flow — `void email domain add acme.com` +secret, generated by the platform at setup. The secret maps to one registered +domain of one project, so `viaDomain` cannot be forged. The auth branch applies +header and length bounds before any crypto, looks the domain's mirror up in +routing KV, compares the secret's sha256 in constant time, and refuses any +mirror whose status is not `active` — a revoked or removed domain stops +receiving at the proxy, and the relay never learns why. + +The reply-sender pin accepts a second shape: any address on a domain +registered to the receiving project. Such a reply runs through the FROM +domain's own mirror and credential lease — two domains of one project may live +in different customer accounts. + +`replyEmail` on a relayed message defaults `from` to the address that received +it and goes out over the REST path. + +### Setup — `void email domain add example.com` Mirrors `void domain add`: a row that goes `pending → active`, printed instructions for the steps only a human can do, and `status` to poll. -``` -$ void email domain add acme.com - MX acme.com carries MX for google.com → apex is off limits - using mail.acme.com [change with --subdomain] - Grant opening Cloudflare's consent page — it names Wrangler; that is expected [Allow] - ◀ access + refresh token · zone:read · email_routing:write · email_sending:write · workers_scripts:write - (no browser here, or the consent fails? Enter switches to the template-link token path) - Verify grant works · zone acme.com found in account …f721 - Stored refresh token on the platform, encrypted for this project · nothing kept on this machine - Routing mail.acme.com is not on Email Routing yet - → add it: Email Routing → acme.com → Settings → Subdomains (dashboard only) - Sending mail.acme.com onboarded — cf-bounce MX, SPF, DKIM, DMARC added - Relay void-email-relay deployed into account …f721 · catch-all → relay - Status pending · run `void email domain status acme.com` -``` - -Who does what: - -| Step | Who | How | -| ------------------------ | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | -| grant | customer, one click | the CLI runs Cloudflare's OAuth consent on wrangler's public client; no browser or a failed consent falls back to the three-click template link | -| zone lookup, MX check | platform | `GET /zones?name=`, public DNS — no token needed for MX | -| routing on the apex | platform | `POST /zones/{z}/email/routing/dns`, only when MX is empty | -| routing on a subdomain | customer, once | dashboard — no API exists; the CLI prints the exact path and polls | -| Email Sending onboarding | platform | `POST /zones/{z}/email/sending/subdomains { name }` | -| relay worker | platform | `PUT /accounts/{c}/workers/scripts/void-email-relay` + the relay secret as a worker secret | -| catch-all → relay | platform | `GET` then `PUT /zones/{z}/email/routing/rules/catch_all`, action `worker` naming the relay — never written over an enabled foreign rule | -| status → active | platform | every probe green; the map printed | - -The catch-all is never written blind. Setup reads it first (`GET -…/rules/catch_all`): an enabled rule naming anything but this row's relay -fails the row at `catch_all` with a conflict message — one zone routes one -email domain — and a rule already naming it makes the write a no-op. -`remove` resets the rule only when the same read shows it still naming this -row's relay; a foreign or unreadable rule is left alone (the latter reports -`catch_all` in `failed_steps`). The sending-onboard read-back paginates — -CF's list endpoint returns 20 per page, so confirmation walks -`?page=N&per_page=50` until a short page. - -Email Sending is paid-plan-only while Email Routing is not, so an -entitlement refusal (CF error codes 2036/10105 — a Workers Free account) -does not fail setup: the relay and catch-all still deploy, and the row -lands `active` with `remaining_steps: ['sending_onboarding']`. Inbound -works; only the outbound lane (`send_raw`) stays refused at Cloudflare -until a plan upgrade, and the CLI prints that hint. Only the -inbound-blocking step gates the row: `routing_subdomain` holds it at -`pending`, while `sending_onboarding` rides along on an active row. - -`void email domain status`, `list`, `sync`, `remove`. `sync` redeploys the -relay in place, rotates its secret, and runs the catch-all ensure whenever -the row carries a zone: missing, disabled or non-worker rules are repointed -(`ensured`); a rule already naming this row's relay is left alone -(`already_set`); an enabled rule naming a DIFFERENT worker is reported -(`foreign_conflict`) and never overwritten; a probe failure is -`probe_failed`. The status GET is read-only: it reports `catch_all` -(`points_here` / `points_elsewhere` / `missing` / `disabled` / `unknown`) -and `catch_all_drift` on live rows; healing is the sync POST, which the -CLI's drift hint names. A `pending` row whose only remaining step is -`routing_subdomain` self-clears: the status GET probes public DNS for MX -naming Cloudflare's inbound exchanges (`route1..3.mx.cloudflare.net`) — the -dashboard form's observable effect — and on evidence flips the row to -`active` and the mirror follows. The `sending_onboarding` step self-clears -the same way on its own evidence — the customer-credential read-back -showing the subdomain onboarded — and `sync` re-attempts the onboarding -POST outright, so a Workers Paid upgrade completes the row without -re-adding. The self-clear write is fenced on the probed status and setup -state, and is suppressed while a removal failure (`failed_step: 'remove'`) -is pending, so a poll never rewrites a row that a concurrent revocation or -an unfinished removal just changed. The customer can also revoke the credential -at Cloudflare at any time; the next probe sees it and the row reads -`token_revoked` / `token_expired`, with inbound continuing until they delete -the rule or the relay themselves. - -### Setup is re-runnable - -Re-adding a `failed` row re-runs the full engine against the row's recorded -progress, and a re-run never erases what the row already learned: the engine -seeds its returned progress from the row (`zone_id` / `account_id` / `mode` -/ `relay_worker_name`), so a re-run that fails early (a dead credential at -`verify_credential`) still persists them. A recorded mode skips the MX check -(the first attempt's enable_routing put OUR MX on the domain), a recorded -relay name redeploys in place rather than walking to a fresh `-2`, and an -"already done" 400/409 from enable_routing/onboard_sending is believed only -when a read-back of the actual zone state confirms it (`GET email/routing`'s -`enabled`, resp. the sending-subdomain list) — fail-closed otherwise. -Healthy rows (active / pending / credential renewal) take the sync-only -path. - -### Concurrency and durability backstops - -- **Two partial unique indexes backstop the engine's check-then-write.** One - live row per zone (`status IN ('active','pending')`, so a `failed` row can - still record the zone it failed in) and one relay name per account - (`relay_worker_name IS NOT NULL` — a deployed relay really exists). Setup - CLAIMS its walked relay name on its own row against the relay index - BEFORE any upload, so two setups racing one account serialize - first-to-claim: a concurrent claim blocks on the winner's uncommitted - UPDATE, then 23505s past its commit (the walk advances to the next - suffix) or proceeds past its rollback. First-to-claim, never - last-write-wins — the loser's walk never writes a worker the winner may - already be using, and an engine throw after a successful claim restores - the row's locked pre-engine values so the claim does not outlive its - setup. A racing loser that still violates an index (the zone race, or a - row written before the claim existed) gets a 409; the - constraint name classifies the race, read by walking the error's cause - chain (both drivers — PGlite and `pg` — expose it as `constraint` on the - frame that carries the code). The loser's CF-side work is compensated - best-effort from what its engine returned: a zone race fully removes, a - relay-name race never deletes the contested worker — only its zone rule is - conditionally detached, because the destructive misclassification would be - deleting a worker another live row owns. -- **A deploy checkpoint closes the catch-all race structurally.** The engine - fires a `persistProgress` dep once — after the relay deploy + secret PUT, - before the catch-all write — and the route persists it - (`applyEmailDomainDeployCheckpoint`, an UPDATE…RETURNING). With the - zone index, a racing loser 23505s at the checkpoint and never writes its - zone rule, so the last-write-wins window (loser's rule overwritten onto - the zone, winner's row live but unrouted) cannot occur for fresh adds. - (The relay-name race is closed earlier, at the pre-upload claim — on a - fresh setup the checkpoint's name and account writes are same-value - no-ops.) Checkpoints are skipped on in-place redeploys — a row that - already carries `relay_worker_name` already knows. -- **Any checkpoint failure compensates from the captured payload.** A - non-unique checkpoint error, or the row cascade-deleted by teardown - mid-setup, leaves the relay deployed but unrecorded; the route runs - `removeEmailDomain` from the in-memory checkpoint before propagating (the - catch-all was never written, so the conditional reset is a no-op). A gone - row switches the grant refresh to the engine default, which acts on the - held credential — the serialized chain needs the row — and 409s as - `project is being deleted`; other failures rethrow. -- **A re-run that 23505s at the final apply repoints rather than resets.** - The compensation does a winner lookup (`zone_id` + the index's live - statuses + relay non-null, excluding the loser) and hands - `repoint_catch_all_to` to the engine, which REPOINTS an owned rule at the - winner instead of resetting it — the winner's existence is probed before - AND after the PUT, and a lookup failure degrades to the plain reset. One - zone keeps working mail through the conflict. -- **A non-unique apply failure does not orphan the relay.** The apply - wrapper re-reads the row: committed (relay name AND `relay_version` match - — the checkpoint never writes the version, so the pair distinguishes a - committed apply from a checkpoint write) → the row stands as applied; - otherwise the possibly-deployed CF state is compensated from the engine's - result with its own account_id and the original error rethrows. A re-read - failure logs and rethrows; a null relay (setup failed before the deploy) - never compensates. The sync route's live-status flip can also 23505 (a - `failed` row rejoining the live set while a winner holds its zone) — - mapped to the same 409 zone message, deliberately without compensation. -- **Sync serializes the whole rotation under the project and credential - row locks, in teardown's order.** Relay redeploy, secret PUT, row apply - and mirror publication run in one transaction that takes the project row - FOR UPDATE first — refusing when teardown has marked it `deleting`, so a - sync that raced the route's entry guard can never redeploy or republish - state teardown already cleared — and then the domain row FOR UPDATE, the - lease chain's own. The row — credential included — is re-read under the - lock: a credential replace that lands while a sync waits is synced as - the new credential, never verified as the superseded one, and two - overlapping syncs publish the worker secret and its mirror hash in the - same order. A mirror PUT failure rolls the row apply back with it. -- **The add path serializes the same way, in two transactions.** The row - insert (credential + fresh relay secret) commits under the project lock - with the `deleting` verdict re-read inside — so teardown's one-time - cleanup enumerations can never miss an in-flight add — and the engine - then runs in the follow-up serialized section, which re-takes both locks - and decides sync vs full setup on the LOCKED row. A retry that queued - behind a completed setup takes the sync branch, so two concurrent retries - of an incomplete setup cannot write one secret to the worker and record - another on the row and mirror. The engine's checkpoint and apply writes - ride savepoints inside that transaction, keeping it committable when an - expected 23505 lands; the compensations above then run outside it. -- **A lease-time revocation publishes its mirror under the row lock, - conditioned on the revoked credential.** When a lease refresh hits - `invalid_grant` the `token_revoked` flip commits inside the lease - transaction, and the ROUTING mirror write that must follow runs in a - second locked section: the row is re-read FOR UPDATE and the mirror is - published from the LOCKED row — the relay hash is never a stale - snapshot — and only while the row still holds the credential that was - revoked. A credential replace that committed in between published its - own active mirror under the same lock, so the revocation write skips - rather than overwrite it and leave an active row with dead mail. +| Step | Who | How | +| ------------------------ | ------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| grant | customer, one click | Cloudflare's OAuth consent on Wrangler's public client; no browser or a failed consent falls back to the template-link token | +| zone lookup, MX check | platform | `GET /zones?name=`; MX over public DNS, no token needed | +| routing on the apex | platform | `POST /zones/{z}/email/routing/dns`, only when the apex carries no MX | +| routing on a subdomain | customer, once | dashboard — no API exists; the CLI prints the exact path and `status` polls | +| Email Sending onboarding | platform | `POST /zones/{z}/email/sending/subdomains { name }` | +| relay worker | platform | script PUT + relay secret as a worker secret | +| catch-all → relay | platform | `GET` then `PUT …/rules/catch_all` naming the relay — never written over an enabled foreign rule | +| status → active | platform | every probe green; the map printed | + +When the apex already carries MX records, the CLI proposes `mail.` +(`--subdomain` overrides). The catch-all is never written blind: an enabled +rule naming anything but this row's relay fails the row at `catch_all` with a +conflict message; a rule already naming it makes the write a no-op. The +sending-onboard read-back paginates the list endpoint until a short page. + +**Steps that ride along.** Email Sending is paid-plan-only while Email Routing +is not. An entitlement refusal (CF error codes 2036/10105) does not fail +setup: the relay and catch-all still deploy and the row lands `active` with +`remaining_steps: ['sending_onboarding']`; inbound works, outbound stays +refused at Cloudflare until a plan upgrade. Only `routing_subdomain` holds a +row at `pending`. + +**Self-clearing.** The status GET is read-only. A `pending` row whose only +remaining step is `routing_subdomain` probes public DNS for MX naming +Cloudflare's inbound exchanges (`route1..3.mx.cloudflare.net`) and on evidence +flips the row to `active`, mirror following. The `sending_onboarding` step +clears on the customer-credential read-back showing the subdomain onboarded; +**`sync` re-attempts the onboarding POST outright**, so after a Workers Paid +upgrade the answer is `sync`, not `status`. Every self-clear write is fenced on +the probed status and setup state and suppressed while a removal failure is +pending. + +**Verbs.** `status` reports `catch_all` (`points_here` / `points_elsewhere` / +`missing` / `disabled` / `unknown`) and `catch_all_drift`; it re-probes the +credential and the relay on every call. `sync` redeploys the relay in place, +rotates its secret and ensures the catch-all: missing, disabled or non-worker +rules are repointed; a rule already naming this relay is left alone; an enabled +rule naming a different worker is reported and never overwritten. `remove` +deletes the platform row, the credential and the relay secret, with Cloudflare +cleanup best-effort. A credential the customer revokes at Cloudflare shows as +`token_revoked` / `token_expired` on the next probe, inbound continuing until +they delete the rule or the relay themselves. + +**Re-runnable.** Re-adding a `failed` row re-runs the engine against the row's +recorded progress and never erases what the row learned: `zone_id`, +`account_id`, `mode` and `relay_worker_name` are seeded from the row. A +recorded mode skips the MX check (the first attempt put our MX on the domain); +a recorded relay name redeploys in place rather than walking to a fresh `-2`; +an "already done" 400/409 from enable-routing or onboard-sending is believed +only when a read-back of the zone confirms it. Healthy rows take the +sync-only path. + +### Concurrency and durability invariants + +- **Two partial unique indexes backstop check-then-write**: one live row per + zone (`status IN ('active','pending')`, so a `failed` row can still record + its zone) and one relay name per account (`relay_worker_name IS NOT NULL`). + Setup claims its walked relay name on its own row before any upload, so two + setups racing one account serialize first-to-claim; an engine throw after a + successful claim restores the row's pre-engine values. A loser that still + violates an index gets a 409, classified by the constraint name read from the + error's cause chain. The loser's Cloudflare-side work is compensated + best-effort: a zone race fully removes; a relay-name race never deletes the + contested worker, only conditionally detaches its zone rule. +- **A deploy checkpoint closes the catch-all race.** The engine persists + progress once — after the relay deploy and secret PUT, before the catch-all + write. A racing loser fails there and never writes its zone rule. + Checkpoints are skipped on in-place redeploys. +- **Any checkpoint failure compensates from the captured payload**, including + the row being cascade-deleted by teardown mid-setup, which answers 409 + `project is being deleted`. +- **A re-run that hits the zone index at the final apply repoints** its owned + catch-all at the winner instead of resetting it, probing the winner before + and after the PUT; a lookup failure degrades to the plain reset. +- **A non-unique apply failure does not orphan the relay.** The row is + re-read: relay name and `relay_version` both matching means the apply + committed; otherwise the deployed state is compensated and the error + rethrown. +- **Sync, add and revoke serialize under the project row lock (refusing when + teardown marked it `deleting`) and then the domain row lock; remove takes + the domain row lock and re-reads the row's status inside it**, re-reading + the row — credential included — under the lock. A credential replace that + lands while another operation waits is acted on as the new credential, + never the superseded one. Mirror writes ride the same transaction; a KV + failure rolls the row write back. The add path commits the row insert + first, then runs the engine in a follow-up serialized section that decides + sync versus full setup on the locked row; engine writes ride savepoints so + an expected index violation keeps the transaction committable. +- **A lease-time revocation publishes its mirror under the row lock**, + conditioned on the row still holding the credential that was revoked, so it + never overwrites a replace that committed in between. ### Removal and teardown -- **Removal detaches before it deletes.** `removeEmailDomain` resets the - zone catch-all (when the ownership read shows it still naming this row's - relay) BEFORE deleting the relay script; if the read or the reset fails, - the delete is deliberately skipped and both steps report failed — an - enabled catch-all never points at a deleted worker; rule and relay stay a - consistent pair. -- **A failed removal keeps the row.** DELETE's CF cleanup failing on a - destructive step (`catch_all` or `delete_relay`) marks the row - `setup_state.failed_step = 'remove'` + `removal_failed_steps` (both ride - the existing jsonb — no migration) and answers `200 { ok: false, removed: -false, failed_steps }`. The owner retries once the CF-side problem is - fixed, or forces with `?force=1` (`removed: true, forced: true`). Two - exceptions still delete unconditionally: `token_revoked` rows (the grant - is already dead, keeping buys no retry leverage) and a `revoke_grant`-only - failure (a stranded grant breaks no mail). While a row carries the removal - mark the status GET suppresses its mirror repair — the proxy's view must - not be "repaired" against a removal the operator is about to finish. The - CLI mirrors the contract: `--force`, exit 1 with retry guidance on - `removed: false`, and manual-cleanup hints on a forced removal. -- **Removal runs under the same row lock.** The owner DELETE re-reads the - row — credential included — inside the lease chain's FOR UPDATE and - builds its cleanup from what the row holds at lock time: a replace that - commits while a removal waits is removed as the NEW credential (its - grant revoked), never orphaned by a row delete built on the stale - snapshot — the replace never invalidates an old API token, so the stale - cleanup would have succeeded and the new grant's only handle would have - been deleted with the row. Mirror clear and row delete stay inside the - same transaction; a KV failure rolls the row delete back with it. -- **Project teardown cleans the customer side.** After the KV mirrors are - revoked (fail-closed) and before the DB cascade, teardown runs - `removeEmailDomain` for each registered domain with a deployed relay, - best-effort and never throwing: the mirrors being gone makes a stranded - relay inert, and the grant refresh goes through the row's serialized chain - so a crash before the cascade leaves the credential live for the retry. -- **The admin surface follows the same order.** The full-removal route - runs under the same row lock with the row — credential included — - re-read inside it (a credential replace committed mid-removal is cleaned - up as the NEW credential, never orphaned by a stale-snapshot delete), - clears the ROUTING mirror first, runs the same engine removal (with the - grant deps below), then deletes the row; a mirror-clear failure keeps the - row and runs nothing destructive. The operator escape hatch `POST -/admin/email/:projectId/domains/:domain/revoke-grant` kills one domain's - grant without tearing the domain down: the whole operation runs as one - serialized section under the same row lock with the row — credential - included — re-read inside it, so the grant it revokes is the row's live - head at lock time (a credential replace committed before the lock is - revoked as the NEW grant, never stranded live under a `token_revoked` - row). It writes the mirror as `token_revoked` FIRST (the proxy's relay - auth checks `active`, so inbound stops here), then revokes, then flips - the row. A failed revoke answers 502 with the row still active, and the - next status GET repairs the mirror — the same self-heal as any drift. +- **Detach before delete.** The zone catch-all is reset (when the ownership + read shows it still naming this row's relay) before the relay script is + deleted; if the read or reset fails the delete is skipped and both steps + report failed. An enabled catch-all never points at a deleted worker. +- **A failed removal keeps the row.** A destructive step failing marks + `setup_state.failed_step = 'remove'` and `removal_failed_steps`, and answers + `200 { ok: false, removed: false, failed_steps }`. The owner retries or + forces with `?force=1`. Two exceptions delete unconditionally: + `token_revoked` rows and a `revoke_grant`-only failure. While the mark is + present the status GET suppresses mirror repair. +- **Project teardown** revokes the KV mirrors first (fail-closed), then runs + the domain removal for each registered domain with a deployed relay, + best-effort and never throwing, before the DB cascade. +- **The admin surface follows the same order.** Full removal clears the + mirror first, runs the same engine removal, then deletes the row; a + mirror-clear failure keeps the row and runs nothing destructive. The + operator escape hatch `POST /admin/email/:projectId/domains/:domain/revoke-grant` + writes the mirror as `token_revoked` first (inbound stops at the proxy), + then revokes, then flips the row; a failed revoke answers 502 with the row + still active and the next status GET repairs the mirror. ### Data and the credential -- `email_domains` — `project_id`, `domain` (the address domain, apex or - subdomain), `zone_id`, `account_id`, `mode: 'apex' | 'subdomain'`, - `status: 'pending' | 'active' | 'token_revoked' | 'token_expired' | -'failed'`, `relay_worker_name`, `relay_version`, the relay secret - ciphertext triple, `setup_state` (a jsonb carrying `failed_step`, - `remaining_steps` or `removal_failed_steps`), `attempt_id` (the CLI add - attempt that last wrote the credential — null on rows an older CLI - wrote), `token_id` and - `token_expires_on` from `GET /user/tokens/verify` (so `status` can name - the token to revoke and warn before a TTL runs out), timestamps. The two - partial unique indexes from the backstops section ride the same table. -- The credential is stored with `encryptProjectSecret` under the project's - envelope key, per domain, and only the api and proxy ever decrypt it. It - is never written to a manifest or a log — the one worker it deploys is the - relay, and the relay carries only its own domain's secret, never the - credential. The relay secret is encrypted on the same envelope. The AAD is - `${projectId}:${name}` with fixed per-row names — - `email-domain-token:` and `email-domain-relay:`. -- The proxy never reads the row: the api mirrors what the proxy needs to - ROUTING KV as `email-domain:` — `{ projectId, status, -relaySecretSha256, relayWorkerName, accountId, zoneId, mode }` — rewritten - on every status transition and secret rotation, tightened before any - revoke, deleted on removal, and repaired by the status GET when it drifts - (the repair re-reads the row under the lock and re-checks drift against - it, so a rotation committed mid-poll is never overwritten back to the - pre-rotation hash). -- Scopes, minimum: zone-scoped `Zone Read` (to find the zone id), `Email -Routing Rules Edit` (the catch-all) and `Zone Settings Edit` (the routing - POST, both paths); account-scoped `Workers Scripts: Write` (to deploy and - update the relay) and `Email Sending Edit`. `Email Routing Addresses Edit` - is not needed — there is no destination address to manage — and `DNS Read` - is not needed — MX is read over public DNS. Account-level scopes are - necessarily account-wide, and `Workers Scripts: Write` is the wide one: - per-script scoping does not exist (the token resource picker stops at the - account), so it covers every script in the account, and the token summary - page shows that before the customer clicks **Create Token**. That breadth - is the honest price of "connect and we configure it for you" — Mode B - already asks for the same trust through `wrangler login`. - -### The OAuth grant on wrangler's client - -The primary credential path is Cloudflare's OAuth consent on **wrangler's -public OAuth client**: the CLI opens the consent page with wrangler's -`client_id` (a public PKCE client with no secret; endpoints -`dash.cloudflare.com/oauth2/{auth,token,revoke}`), the customer clicks -**Allow**, and the platform stores the refresh token. The CLI uses a -`localhost:8976/oauth/callback` redirect, as wrangler does; every observed -third-party flow on this client id redirects to localhost, so whether -Cloudflare accepts any other redirect URI for it is unverified (probe 8) — -which is why the dashboard ships the template link + paste box either way. - -What it costs, recorded with the decision: - -- **Breadth.** An OAuth grant is user-wide — every account the user belongs - to, every zone in them, for the granted scopes. `wrangler whoami` shows - this today. A template-link token can be narrowed to one zone; a grant - cannot. The template link stays as the narrow option for a company with - many zones; the grant is the default for everyone else. -- **Refresh.** The access token lives about an hour. Refresh tokens are - **single-use and rotate**, so the platform runs its own grant chain — it - never touches the refresh token in the developer's `~/.wrangler`, which - would race wrangler's own refreshes and kill both. Refresh is serialized - per domain row and the rotated token is persisted atomically with it - (the credential lease). A revoked grant surfaces as `token_revoked`. -- **Scopes.** The grant asks for wrangler's own names: `zone:read`, - `email_routing:write` ("settings, rules, and destination addresses"), - `email_sending:write`, `workers_scripts:write`, plus `offline_access`. - There is **no `zone_settings` OAuth scope** — the session reaches the - email zone-settings endpoints under `email_routing:write`'s "settings" - wording. -- **The consent screen names Wrangler.** It is Cloudflare's hosted page for - the wrangler client; it cannot say Void. The CLI prints one line before +`email_domains`: `project_id`, `domain` (globally unique), `zone_id`, +`account_id`, `mode: 'apex' | 'subdomain'`, `status: 'pending' | 'active' | +'token_revoked' | 'token_expired' | 'failed'`, `credential_kind`, +`relay_worker_name`, `relay_version`, the credential and relay-secret +ciphertext triples, `setup_state` (jsonb: `failed_step`, `remaining_steps`, +`removal_failed_steps`), `attempt_id` (the add attempt that last wrote the +credential), `token_id` and `token_expires_on` from `GET /user/tokens/verify`. +The two partial unique indexes above ride the same table. + +The credential is encrypted with `encryptProjectSecret` under the project's +envelope key, per domain; only the api and proxy decrypt it, and it is never +written to a manifest, a log or the relay. The relay carries only its own +domain's secret. AADs are `${projectId}:email-domain-token:` and +`${projectId}:email-domain-relay:`. + +The proxy never reads the row. The api mirrors what it needs to routing KV as +`email-domain:` — `{ projectId, status, relaySecretSha256, +relayWorkerName, accountId, zoneId, mode }` — rewritten on every status +transition and rotation, tightened before any revoke, deleted on removal, and +repaired by the status GET under the row lock when it drifts. + +Minimum scopes: zone-scoped `Zone Read`, `Email Routing Rules Edit` and `Zone +Settings Edit`; account-scoped `Workers Scripts: Write` and `Email Sending +Edit`. `Workers Scripts: Write` is account-wide — per-script scoping does not +exist — and the token summary page shows that before **Create Token**. + +### The OAuth grant on Wrangler's client + +The primary credential path is Cloudflare's OAuth consent on **Wrangler's +public OAuth client** (PKCE, no secret; endpoints +`dash.cloudflare.com/oauth2/{auth,token,revoke}`; redirect +`localhost:8976/oauth/callback`). The customer clicks **Allow** and the +platform stores the refresh token. What this costs: + +- **Breadth.** A grant is user-wide — every account the user belongs to, every + zone in them, for the granted scopes. The template-link token remains the + narrow option for a company with many zones. +- **Refresh.** Access tokens live about an hour; refresh tokens are single-use + and rotate. The platform runs its own grant chain — it never touches the + developer's `~/.wrangler` chain, which would race Wrangler's refreshes. + Refresh is serialized per domain row and the rotated token is persisted + atomically with it (the credential lease). A revoked grant surfaces as + `token_revoked`. +- **Scopes.** `zone:read`, `email_routing:write`, `email_sending:write`, + `workers_scripts:write`, `offline_access`. There is no `zone_settings` OAuth + scope; the email zone-settings endpoints are reached under + `email_routing:write`. +- **The consent screen names Wrangler.** The CLI prints one line before opening the browser so the customer is not surprised. -- **The client id is not ours.** Cloudflare can rotate or revoke it any - day — so the template link + paste is not a legacy path, it is the - permanent fallback the CLI drops to when the consent flow fails. - -**The grant is revoked on every path that should kill it.** -`revokeOauthGrant` POSTs the RFC 7009 form to -`dash.cloudflare.com/oauth2/revoke` (wrangler's client id, -`token_type_hint=refresh_token`, no Authorization header — a public client -authenticates with the token itself, exactly wrangler's `logout`). It throws -on a network failure or non-2xx — CF answers 200 even for a bogus token, so -a failure leaves the grant's state genuinely unknown and callers record -`revoke_grant` in `failed_steps` — and it never logs the token. Removal -revokes LAST, with the rotated chain head (a transient refresh failure -revokes the original stored token instead; `invalid_grant` skips — CF -already killed it). Credential replace revokes the old grant after the -commit — a re-pasted identical grant is skipped (revoking it would brick the -just-stored credential) and a revoke failure is logged, never fatal. -Teardown and admin removal revoke. Conflict compensation deliberately does -NOT — the loser's credential may survive on another row. A REJECTED add -attempt revokes too: the CLI mints a fresh grant per attempt and drops its -only copy on ANY error response, so every rejection — validation 400, -cross-project conflict 409, teardown 409, and the project guards' 404/403 — -revokes the posted grant first. Revocation asks for no ownership: for a -public client the token itself is the only credential the RFC 7009 endpoint -wants, so revoking what was posted grants the caller nothing they did not -already hold. One guard: a re-pasted grant equal to the conflicting row's -stored credential is kept (revoking it would brick the live row). A LOST answer is not a -rejection either: every add attempt carries a random attempt id, stamped -on the row at insert and at credential replace, and always serialized on -the status GET (null on rows an older CLI wrote — an api predating -attempt identity omits the key, and key presence is how a new CLI detects -one). When the POST's response never arrives — or the answer is ambiguous: -a 5xx, or a 401/403/413/426/429 from middleware or the edge; only the -route's deliberate 400/404/409 rejections prove a server-side revoke (a -403 is ambiguous: middleware answers it before the route ever parses the -body — a suspended account, a mis-scoped deploy token — so it may carry -no revoke at all, and the CLI cannot tell the two 403s apart) — the CLI -polls the status, 15 tries, 1s apart, because a lost answer does not stop -the api worker mid-commit. The row carrying THIS attempt's id means the -add committed -(printed, done, never revoked). A row stamped with a DIFFERENT id — or -null — kept its existing credential: a committed add of mine would have -stamped my id, so this attempt's grant was never stored and is revoked, -and the failure says to re-run. A 404 at every poll means nothing -persisted: the held grant is revoked (via the CLI's own best-effort copy -of the same RFC 7009 POST). A row from an api without the key is trusted -like the pre-attempt flow, never revoked — a committed add there is -indistinguishable from a pre-existing row. And no informative read at all -is reported as unknown — a blind revoke could brick a row that did -commit. Where the CLI does revoke, it claims the revocation only when -Cloudflare confirms it (200 — CF answers 200 even for a bogus token); a -failed revoke is reported with manual-revocation steps, never claimed. -The consent flow's own straggler dies the same way: an exchange that -completes after the flow settled (Enter, an abort, the timeout) minted a -grant only the CLI holds, so it is revoked fire-and-forget and the browser -tab gets a cancelled page rather than hanging on the closed server. The -revocation's cascade scope (access tokens minted from the grant) is -undocumented and unprobed — accepted. - -And the path that does **not** work, so nobody designs it again: minting a -long-lived scoped API token from the session (`POST /user/tokens` with the -bearer). Token creation requires the `API Tokens Write` group, which is not -in wrangler's OAuth scope set and does not exist as an OAuth scope at all. -The session bearer alone is no substitute either: it expires in an hour, and -its refresh chain belongs to wrangler — borrowing the **access** token via -`wrangler auth token` is safe (wrangler refreshes it); refreshing it outside -wrangler races the single-use rotation. - -### Creating the token in three clicks - -The fallback grant path, for a browserless terminal, a failed consent flow, -or a company that wants a zone-narrowed token. Cloudflare's dashboard -accepts a template URL that pre-fills the token form — name, permission -groups, resources. The CLI opens it; the customer clicks **Continue to -summary**, **Create Token**, **Copy** — and the CLI takes the token from the -clipboard. Three clicks, nothing typed (plus a login if the browser has -none). +- **The client id is not ours.** Cloudflare can rotate or revoke it, so the + template link + paste is the permanent fallback, not a legacy path. + +**Revocation** POSTs the RFC 7009 form to `dash.cloudflare.com/oauth2/revoke` +(`token_type_hint=refresh_token`, no Authorization header — a public client +authenticates with the token itself). It throws on a network failure or +non-2xx, because Cloudflare answers 200 even for a bogus token, and never logs +the token. Rules: + +- Removal revokes last, with the rotated chain head; teardown and admin + removal revoke. Conflict compensation does not — the loser's credential may + survive on another row. +- Credential replace revokes the old grant after the commit; a re-pasted + identical grant is kept, since revoking it would brick the just-stored row. +- A rejected add revokes the posted grant first — validation 400, + cross-project 409, teardown 409, and the project guards' 404/403 — because + the CLI mints a fresh grant per attempt and drops its only copy on any + error. The one guard: a grant equal to the conflicting row's stored + credential is kept. +- A lost answer is not a rejection. Every add attempt carries a random + `attempt_id`, stamped on the row at insert and at credential replace and + serialized on the status GET. When the POST's answer never arrives, or is + ambiguous (a 5xx, or a 401/403/413/426/429 from middleware or the edge — + only the route's deliberate 400/404/409 prove a server-side revoke), the CLI + polls the status, 15 tries a second apart. The row carrying this attempt's + id means the add committed; a different id means this grant was never + stored and is revoked; a 404 at every poll means nothing persisted and the + held grant is revoked; no informative read at all is reported as unknown — + a blind revoke could brick a row that did commit. The CLI claims a + revocation only on Cloudflare's 200. +- A consent exchange that completes after the flow settled minted a grant only + the CLI holds; it is revoked fire-and-forget and the browser tab gets a + cancelled page. + +**Rejected credential paths**, so nobody designs them again: minting a scoped +API token from the session (`POST /user/tokens`) needs `API Tokens Write`, +which is not an OAuth scope at all; and the session bearer is no substitute — +it expires in an hour and its refresh chain belongs to Wrangler. Borrowing the +**access** token via `wrangler auth token` is safe; refreshing it outside +Wrangler races the single-use rotation. + +### The token fallback — three clicks + +For a browserless terminal, a failed consent, or a company that wants a +zone-narrowed token. Cloudflare's dashboard accepts a template URL that +pre-fills the token form; the CLI opens it, the customer clicks **Continue to +summary**, **Create Token**, **Copy**, and the CLI takes the token from the +clipboard. ``` CLI browser CLI - │ MX check over public DNS │ │ - │ → apex or subdomain decided │ │ - │ build the link (scopes fit the path)│ │ - │ snapshot the clipboard │ │ + │ MX check → apex or subdomain │ │ + │ build the link · snapshot clipboard │ │ │ open ─────────────────────────────▶ │ form is pre-filled │ │ watch the clipboard (≤ 10 min) │ 1 Continue to summary │ │ │ 2 Create Token │ │ ◀──────────────────────── clipboard │ 3 Copy │ │ shape ok → GET /user/tokens/verify → clear the clipboard │ - │ GET /zones?name=acme.com · scope check │ + │ GET /zones?name=… · scope check │ │ send over TLS → platform stores with encryptProjectSecret │ │ drop it from memory · continue with Zone / Routing … │ ``` -| Branch | What the developer sees | -| ------------------------------------- | ------------------------------------------------------------------------------------------------ | -| clipboard unchanged, or wrong shape | nothing; still waiting. Enter switches to the paste prompt | -| verify says not active | "that is not an active Cloudflare token — still waiting" | -| 10 minutes, nothing | the paste prompt; Esc cancels with nothing stored anywhere | -| zone not found with this token | `failed · acme.com is not in an account this token can see` → re-run `add` | -| 403 on a scope probe | `failed · missing Workers Scripts Edit` → re-run `add`; the link carries the key | -| `expires_on` passed, or revoked at CF | `status` reads `token expired` / `token revoked`; `add` again replaces the token, rules stay | -| `remove` | catch-all rule and relay worker deleted with the token, best effort; then the row and ciphertext | - -**The clipboard watch** reads only between opening the link and getting a -token, never otherwise, and says so on screen. It ignores whatever was on -the clipboard before the link opened (the snapshot) and accepts only a _new_ -value shaped like a Cloudflare token: the current `cfut_` format (prefix, 40 -characters, checksum) or a legacy 40-character `[A-Za-z0-9_-]` value; other -vendor prefixes (`ghp_`, `github_pat_`, `sk-`, `xox`, `cfk_`) are refused -without a network call. Only a value that passes the shape check is sent to -`GET /user/tokens/verify`, and only `status: active` is accepted. It clears -the clipboard once it has the token, so the token does not sit there for the -next paste — a clipboard manager's history is outside our reach, and the CLI -says so once. Enter at any time switches to the masked paste prompt; no -clipboard tool (SSH, CI, a Wayland session without `wl-paste`) falls back to -it silently. Nothing on the clipboard is ever printed or logged. +The clipboard watch reads only between opening the link and getting a token, +ignores the pre-link snapshot, and accepts only a new value shaped like a +Cloudflare token (`cfut_` prefix + 40 characters + checksum, or a legacy +40-character `[A-Za-z0-9_-]` value); other vendor prefixes (`ghp_`, +`github_pat_`, `sk-`, `xox`, `cfk_`) are refused without a network call. Only +a shape-valid value reaches `GET /user/tokens/verify`, and only `status: +active` is accepted. The clipboard is cleared once the token is taken. Enter +at any time switches to the masked paste prompt; no clipboard tool (SSH, CI, a +Wayland session without `wl-paste`) falls back to it silently. Nothing on the +clipboard is printed or logged. The link: ``` https://dash.cloudflare.com/profile/api-tokens ?permissionGroupKeys= - &accountId=* # required by the form; "all accounts" — the customer narrows it in the summary if they like - &zoneId=all # required by the form; narrow to acme.com in the summary (undocumented whether honoured) - &name=Void%20email%20%E2%80%94%20acme.com + &accountId=* # required by the form; narrowed in the summary + &zoneId=all # required by the form; narrowed in the summary + &name=Void%20email%20%E2%80%94%20example.com ``` ```json @@ -790,139 +632,293 @@ https://dash.cloudflare.com/profile/api-tokens ] ``` -Every key rides every link — there is no apex-only key, because `Zone -Settings Edit` gates the routing POST on both paths. The documented key -table stops at `workers_*` / `zone_settings`; the two email keys are -community-verified (`email_routing_rule` from the dashboard permission-group -dump, `email_sending` from erxes' shipped config), and unknown keys are -silently dropped — so the docs also show the manual token-creation steps -rather than relying on the pre-fill alone. - -What this path does not do, so nobody expects it: - -- The dashboard never hands the token back. There is no redirect, no - callback, no local listener to receive it — the paste is the transport. -- Creating the token by API (`POST /accounts/{id}/tokens`) needs a token - with `API Tokens: Edit` first — the same chicken, one egg earlier. -- The developer's local `wrangler login` session cannot replace the stored - grant: the bearer expires in an hour and its refresh chain belongs to - wrangler, and the session cannot mint the platform a long-lived token - (above). - -**Where the token lives.** On the platform, encrypted per project with -`encryptProjectSecret` — the proxy sends with it at runtime and the api -re-probes with it, so that copy is the one that must exist. On the -developer's machine: nowhere. The CLI holds it in memory, hands it to the -platform over TLS as its first call, and drops it; every later step -(`status`, `remove`, repair) goes through the platform. The one thing the -platform copy does not solve is a **TTL** the customer set on the form: -sends stop when it expires, `status` reads `token expired`, and `void email -domain add` again is the renewal. The account-token form is the better home -for a company, because the token survives the employee — but it needs an -Administrator and cannot carry `accountId`/`zoneId` parameters, so the user -token is the default. - -### What changes in the existing rules - -- **Reply-sender pin** — accepts the receiving slug on the shared domain - _or_ any address on a domain registered to the project. +Every key rides every link — `Zone Settings Edit` gates the routing POST on +both paths. The two email keys are community-verified rather than documented, +and unknown keys are silently dropped, so the docs also show the manual +token-creation steps. + +The dashboard never hands the token back — no redirect, no callback; the paste +is the transport. On the platform the token lives encrypted per project; on +the developer's machine, nowhere. A TTL the customer set on the form is the +one thing the platform copy cannot solve: sends stop, `status` reads `token +expired`, and `void email domain add` again is the renewal. + +### What changes in the shared-sender rules + +- **Reply-sender pin** — accepts the receiving slug on the shared domain _or_ + any address on a domain registered to the project. - **Recipient allowlist** — does not gate custom-domain sends, relayed - forward/reply actions included: Cloudflare's verified-destination gate is - lifted by the customer's own onboarding, and the reputation at stake is - the customer's own. The burst cap and the kill switch still apply. -- **Quotas and kill switch** — the platform's burst cap and suspend switch - keep applying to every send that passes through the proxy, custom domain - or not; the daily quota is the customer's account's. + forward/reply included: the customer's own onboarding lifts Cloudflare's + verified-destination gate, and the reputation at stake is theirs. The burst + cap, the monthly cap and the kill switch still apply. - **The 200-destination cap is untouched** — a relay consumes no destination address in either account. - **One worker of ours lives in their account** — readable, editable and - deletable by the customer, as everything in their account is. `status` - reports drift; it does not fight it. + deletable by the customer; `status` reports drift, it does not fight it. -### Limits, stated plainly +### Limits - The customer's account must be on **Workers Paid** to send; routing alone works on Free. -- Two steps stay human: the credential grant (one Allow click, or three - clicks and a paste) and the routing-subdomain dashboard form. -- The credential asks for `Workers Scripts: Write` — account-wide, and the - customer sees that on the token form — and one relay worker lives in the - customer's account. +- Two steps stay human: the credential grant and the routing-subdomain + dashboard form. +- The credential asks for account-wide `Workers Scripts: Write`. - Inbound messages cap at 25 MiB (Cloudflare rejects larger ones before the - relay runs), a zone holds at most 30 email domains, and a domain holds at - most 200 routing rules. -- The relay name-walk caps at 50 names per account (`void-email-relay` - through `-50`); exhaustion fails the deploy step with nothing uploaded. + relay runs); a zone holds at most 30 email domains; a domain holds at most + 200 routing rules; the relay name walk caps at 50 names per account. - A domain that is not on Cloudflare DNS is not covered. -### Mode B - -The developer's own zone needs none of this. Their worker holds the -`send_email` binding, the rules target their worker directly, and there is -no relay and no stored credential — [Mode -B](#mode-b--the-developers-own-cloudflare-account) already covers it. - -### Verified against live Cloudflare - -Probed on a real zone (voidcloud.dev, restored byte-identical afterward), -2026-09-17: `POST …/email/routing/dns {name}` enables a subdomain (200, -three MX auto-created); sending onboard with a user API token succeeds (200) -— the schema declares Global-key-only, so the declared auth lags the -endpoint — and also stamps the apex SPF + DKIM; apex enable is an empty-body -POST; ONE zone-scoped catch-all (auto-created drop+disabled on first -enable) covers apex and subdomain — there is no per-email-domain rule; relay -script PUT + secret PUT + catch-all PUT all accept an API token (200/201/200 - -- read-back enabled); `send_raw` takes the JSON envelope `{from, recipients, -mime_message}` (a raw `message/rfc822` body earns `10001 -invalid_request_schema`) and returns `200 queued` with a message_id. The - revocation endpoint is advertised by the OIDC discovery document and answers - 200 even for a bogus token. `routing/dns` is authorized by Zone Settings - Write and rules by Email Routing Rules Write; `10103 bad_token_type` is - Email Sending's credential-type rejection; `send_raw` accepts user and - account tokens (account-owned recommended). CF's cleanup does not remove - `_dmarc.` on subdomain/routing deletion — the engine never touches DNS - by design, and the CLI prints the manual cleanup list. - -Still open: the dashboard-UI probes, the browser PKCE run (what the consent -screen says, whether a non-localhost redirect is accepted, rotation over a -few cycles, whether the grant's `email_routing:write` covers the two calls -wrangler never makes), a message-level inbound E2E (needs observability an -API token cannot reach), and the narrow template-token variants. +### Provider behavior the design relies on + +Established against a live zone with a user API token: + +- `POST …/email/routing/dns { name }` enables a subdomain and auto-creates + three MX records; the apex enable is an empty-body POST. `routing/dns` is + authorized by Zone Settings Write, rules by Email Routing Rules Write. +- Sending onboarding succeeds with a user API token although the schema + declares Global-key-only, and also stamps the apex SPF and DKIM. `10103 +bad_token_type` is Email Sending's credential-type rejection. +- One zone-scoped catch-all, auto-created disabled on first enable, covers the + apex and every subdomain; there is no per-email-domain rule. +- Relay script PUT, secret PUT and catch-all PUT all accept an API token. +- `send_raw` takes the JSON envelope `{ from, recipients, mime_message }` (a + raw `message/rfc822` body earns `10001 invalid_request_schema`) and returns + `200 queued` with a `message_id`. It accepts user and account tokens. +- The revocation endpoint answers 200 even for a bogus token. +- Cloudflare's cleanup does not remove `_dmarc.` on subdomain or routing + deletion; the engine never touches DNS, and the CLI prints the manual + cleanup list. + +Still open: the browser consent run (what the screen says, whether a +non-localhost redirect is accepted, rotation over several cycles, whether the +grant's `email_routing:write` covers the two calls Wrangler never makes), a +message-level inbound end-to-end check, the narrow template-token variants, +and every operation above with an **account-owned** token. ### Where each piece lives -- **CF calls from the api** follow `cfFetch` - (`platform/packages/api/src/provisioning/cloudflare-api.ts`). The relay - deploy is a plain `PUT /accounts/{a}/workers/scripts/{name}` with a - multipart FormData body, plus a non-namespace variant of `putWorkerSecret`. +- **Cloudflare calls from the api** follow `cfFetch` + (`platform/packages/api/src/provisioning/cloudflare-api.ts`); the relay + deploy is a multipart script PUT plus a non-namespace `putWorkerSecret`. - **The relay source is an embedded asset in the api worker** — one constant - module plus a `relay_version`; `sync` redeploys it. -- **`/email/relay` in the proxy** is a branch in the global auth middleware, - modelled on the `/email/internal/` branch: size bounds before any crypto, - secret → mirror → project, then the second half of `routeInbound` - (envelope → `stub.email` → `enactActions`) keyed by the domain's project - instead of a slug parsed from the recipient. The relayed header summary - is bounded on two axes, like the platform lane's pre-buffering: a - distinct-name cap, and a total-pieces cap charged per header piece - (distinct names + repeat occurrences + folds) — one name repeated inside - the MIME cap otherwise allocates unboundedly in the shared isolate. -- **Schema:** `email_domains` follows the `emailDestinations` template - (`status` as a `$type` union, CHECK constraints declared in the schema so - drizzle-kit doesn't diff them away); migration `0039_email_domains`. -- **CLI:** `void email domain …` verbs under the email group; API calls join - `PlatformClient`'s `/projects/:id/email/…` methods; the owner-guard routes - live in `routes/email.ts`; the admin section in `admin/routes/email.tsx`. - -## Deferred decisions - -Custom sender domains on the managed platform ARE implemented — see -[Custom domains on Cloudflare](#custom-domains-on-cloudflare). Third-party -provider wrappers, component templates, typed sender discovery and persistent -delivery logs are not implemented by this record. Users can call a provider -SDK directly where appropriate. - -Native setup currently detects email at the feature level rather than -separately onboarding inbound-only apps. Splitting that detection would also -need to handle sends from dependencies, dynamic imports and direct binding -access; it is not a safe deletion of the existing binding. + module plus `relay_version`; `sync` redeploys it. +- **`/email/relay` in the proxy** is a branch in the global auth middleware: + size bounds before any crypto, secret → mirror → project, then the second + half of `routeInbound` keyed by the domain's project. The relayed header + summary is bounded on two axes — distinct names and total header pieces. +- **Schema:** `email_domains`, with CHECK constraints declared in the schema + so drizzle-kit does not diff them away. +- **CLI:** `void email domain …` under the email group; `PlatformClient`'s + `/projects/:id/email/…` methods; owner-guard routes in `routes/email.ts`; + the admin section in `admin/routes/email.tsx`. + +## Administrator-managed domains + +### The scenario + +One Cloudflare administrator installs a Void platform inside a company. Staff +who do not write code deploy small applications to it through a coding agent. +They have no Cloudflare account, token or dashboard access; the administrator +has all three and owns the company zones. + +`void email domain add` cannot work for these users — its first step is a +consent or a paste. And most of what their applications want is "mail my +colleagues", which the shared sender already provides an address for; the +blocker there is the verified-destination gate, not the lack of a domain. + +``` + operator's Cloudflare account · zone example.com + + Lane 1 shared sender Mode C + the recipient policy above + operator once: EMAIL_SENDER_DOMAIN=mail.example.com, routing on, proxy binding + every project: +tag@mail.example.com, in and out, no per-project work + + Lane 2 per-project domain the administrator runs the engine + admin per project: void platform email domain-add hr.example.com --project hr-portal + gives: hr@hr.example.com-style addresses, the zone's own DKIM + costs: one dashboard form per subdomain, 1 domain → 1 project +``` + +Lane 2 reuses the custom-domain engine unchanged and adds an operator entry +point. + +### Decisions + +| # | Decision | Reason | +| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | One domain → one project stays; a company uses one subdomain per project. | Address→project routing would change the relay lane, the mirror key and the trust model. | +| 2 | Administrators manage domains through `void platform email domain-…` and the admin UI, running the same `setupEmailDomain` / `syncEmailDomain` / `removeEmailDomain` engine. | One engine, two entry points. Owner-guard routes keep their behavior; operator routes act on any project. | +| 3 | Credential kind **`platform`**: the row holds no credential and the engine uses the runtime token (`CF_API_TOKEN`). A pasted token stays available as the fallback. | No per-domain token, nothing expires. Valid only when the zone is in the platform's own account (`CF_ACCOUNT_ID`); the zone lookup already returns the account. | +| 4 | The relay worker is kept even when the zone is in the platform's account. | Engine, probes, mirror and tests assume one; pointing the catch-all at the proxy directly is a second code path to save one hop. | +| 5 | Installation setting `email.domains`: `self-serve` (default) or `admin`. In `admin` mode the user CLI prints the administrator's command instead of starting a grant. | The CLI must know before it opens a browser. The printed command is the whole request flow. | +| 6 | A request inbox in the admin UI is not built. | A table, a page and a notification for what one printed line does. | +| 7 | Rows carry `managed_by: 'owner' \| 'admin'`. Owners can list and read an admin row; `sync`, `remove` and a re-`add` on it are refused with the administrator's command named. | An admin row runs on the platform's credential; a user must not tear it down, rotate it, or swap in their own grant. | +| 8 | No OAuth on the operator path. | A grant is user-wide across the administrator's accounts, and the operator CLI is `--plan` / `--yes` first. | +| 9 | Operator verbs are single tokens (`domain-add`, `policy-set`, …), and the administrator names the exact mail domain — no `--subdomain`. | The operator table is ` `; nested verbs would need parser and help-tree nesting for no functional gain. The engine's MX rule still applies. | +| 10 | The user CLI reads the mode from the authenticated `GET /projects/:id/email/settings`, not the discovery document. | Discovery is env-only and unauthenticated; the setting lives in the database. A platform predating the route answers 404, which reads as `self-serve`. | + +### Command surface + +Entries in `operatorCommands` (`packages/void/src/cli/operator-args.ts`), +served under `/operator/v1/email/…`, with the group's `--plan`, `--yes` and +`--json`, recorded in `operator_events`: + +``` +void platform email domains [--project ] +void platform email domain-add --project [--token-stdin] +void platform email domain-status +void platform email domain-sync +void platform email domain-remove [--force] +void platform email settings | settings-set --domains self-serve|admin +void platform email policy | policy-set [--domains a,b] +void platform email limit [--monthly N] [--burst N] +``` + +`domain-add` without `--token-stdin` uses the `platform` credential; with it +the token is read from standard input once and rides both the preview (which +only classifies it) and the apply, never the audit target. Every POST previews +first: the add preview resolves the project, the existing row and the public +MX decision without a single Cloudflare call, and on a re-run names the +stored mode rather than re-deciding it. A kept row after a failed +destructive step (the owner contract's `200 { ok: false }`) and a setup or +sync that ends with the row `failed` / `token_revoked` / `token_expired` +both surface as a top-level `ok: false` on the operator result, so the audit +records a failed mutation and the CLI exits nonzero with the row as the +partial result. The policy preview and the admin label state the +platform-domain exception next to `any` and `domains`. The admin UI's project page has +the add form (blank token = platform credential) and shows `managed_by`; its +`revoke` and `revoke-grant` actions stay. + +The owner verbs keep their paths and guard. Both surfaces call one service +module, `email/domain-service.ts`, that owns the serialized sections above +(row insert under the project lock, engine run under the row lock, +checkpoint, compensation); the orchestration moved out of `routes/email.ts` +unchanged, and the operator layer re-shapes its JSON responses. + +### The `platform` credential + +``` + kind stored on the row engine / lease resolve to revoke on remove + ─────────── ──────────────────────────── ──────────────────────────── ──────────────── + oauth_grant refresh token, encrypted refreshed access token RFC 7009 POST + api_token token, encrypted the token nothing + platform nothing env.CF_API_TOKEN nothing +``` + +- Schema: `credential_kind` includes `'platform'`; the `token_*` columns are + nullable with a CHECK that all three are null when the kind is `platform` + and all three present otherwise — the NOT NULL guarantee every other kind + had before, kept; `managed_by` defaults to `owner`. +- `decryptEmailDomainToken` is the one seam: a `platform` row resolves to + `CF_API_TOKEN` there, so the engine, the lease route (which hands it to the + proxy as an `api_token` lease), teardown and the admin surface all follow. + It is never written to the row, a mirror, a manifest or a log. +- The engine skips the user-token verify for a `platform` row (the runtime + token is not a user token) and refuses at `zone_lookup`, before any write, + when the zone's account is not `CF_ACCOUNT_ID`, naming `--token-stdin` for + a zone in another account. On the managed platform this is always the case, + so the platform's own token never touches a customer zone. The same pin + guards every later path: a pasted-token row whose recorded account is + foreign cannot be re-added or synced on the platform credential (409, the + stored token untouched), checked on the locked row. +- Ownership is authorized on the LOCKED row for add, sync and remove alike — + an owner's request and an administrator's add can interleave, and the + unlocked read only chooses the message. An administrator never takes over + an owner's row (409, "ask them to remove it first"); an owner never rotates + or removes an administrator's (403, the operator command named). The + `admin` mode's refusal of a NEW owner domain is read inside the insert's + locked section too, so an owner row removed between the owner's read and + their insert cannot slip a new row past the mode. +- A row of the `platform` kind is readable only by a runtime that knows the + kind: an api or proxy still serving from before the upgrade would fail its + lease, and that api authorizes the owner routes on project ownership alone + — it does not read `managed_by`. Register administrator domains after an + upgrade has finished, and remove them before rolling the api back below + this version; the migration itself is compatible with the previous + Workers. +- The status probe for a `platform` row is a zone-scoped read + (`GET /zones/{z}/email/routing`, or — before a zone is known — the same + suffix walk setup uses, so `mail.acme.com` finds the `acme.com` zone). + A refusal reads `probe_failed`, never `token_revoked` / `token_expired` — + those describe a stored credential's lifecycle. The probe's fenced update + compares `token_ciphertext IS NULL` for such rows; `= NULL` would never + match. +- The runtime token needs, beyond the self-hosting guide's table: **Zone + Read**, **Email Routing Rules Edit** and **Zone Settings Edit** on the mail + zones; **Email Sending Edit** on the account. The installer's token link + preselects them when the runtime enables email; a missing one fails `add` + at the first Cloudflare step with Cloudflare's own message. + +### Relay, limits and the human steps + +The relay is deployed into the platform's own account, so its name walk caps +admin-managed domains at 50 per platform account, and Cloudflare caps Email +Routing subdomains per zone. The two human steps are unchanged, now done by +the administrator: the Email Routing **Subdomains** form (the status poll +self-clears the row) and the Workers Paid upgrade for Email Sending, after +which `domain-sync` re-attempts the onboarding. + +### Ownership + +| Actor | On an `owner` row | On an `admin` row | +| ---------------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| project owner | list · status · sync · remove · add | list · status; `sync` / `remove` / re-`add` → 403 naming the admin command; `add` only in `self-serve` mode | +| administrator | list · status · sync · remove · revoke | list · status · sync · remove · revoke · add | +| project teardown | removes relay + catch-all with the row's credential | same, with the runtime token | + +An administrator never takes over an owner's row: `domain-add` on one answers +409 and asks for the owner to remove it first; `domain-sync` and +`domain-remove` on it run with the owner's stored credential. The `admin` +mode refuses an owner's add of a NEW domain only — a row the owner registered +before the switch stays theirs to renew, sync and remove, on the credential it +already stores. The CLI reads the settings on the authenticated +`GET /projects/:id/email/settings`; a platform predating that route answers a +bare 404 and reads as `self-serve`, while the route's own +`project not found` is an error, so a stale project link never opens a +browser. + +### The mode switch + +`email.domains` lives in `platform_settings` and is served to the CLI on the +authenticated `GET /projects/:id/email/settings` (with the project slug for +the printed command), so `void email domain add` reads it before any DNS, +browser or Cloudflare work. In `admin` mode the CLI then lists the project's +rows once: a row the owner registered before the switch for the domain this +run targets (or its `mail.` fallback) keeps the owner flow — that is the +renewal path, and the matched row IS the target, skipping the MX decision +that would otherwise retarget an apex Email Routing has since put MX on. +Any other input goes through the owner flow's own MX decision over public +DNS first, so the printed command names the target setup will accept +(`mail.example.com` when the apex already receives mail), then prints: + +``` +$ void email domain add example.com + MX example.com carries MX for aspmx.l.google.com → apex is off limits + using mail.example.com [change with --subdomain] + Email domains on this platform are managed by its administrator. + Ask them to run: + + void platform email domain-add mail.example.com --project hr-portal + + `void email domain list` and `void email domain status` keep working here. +``` + +Exit 0, Cloudflare never contacted, nothing stored. The mode is enforced +server-side — the add route answers 403 with the same text and revokes a +posted grant — so the CLI check only spares the browser round trip. `status` +on an administrator's row names the operator command for each step the owner +cannot take (`domain-sync` for drift, `domain-add` for a failed setup, +`domain-add … --token-stdin` for an expired pasted token). The default policy and mode +leave the managed platform's behavior unchanged: no new probe, binding or +account call for a platform that sets neither. + +## Out of scope + +Third-party provider wrappers, component templates, typed sender discovery +and persistent delivery logs are not part of this design; users call a +provider SDK directly where appropriate. + +Native setup detects email at the feature level rather than onboarding +inbound-only apps separately. Splitting that detection would need to handle +sends from dependencies, dynamic imports and direct binding access; it is not +a safe deletion of the existing binding. diff --git a/platform/packages/api/drizzle/0045_platform_settings.sql b/platform/packages/api/drizzle/0045_platform_settings.sql new file mode 100644 index 000000000..6f8ecdad6 --- /dev/null +++ b/platform/packages/api/drizzle/0045_platform_settings.sql @@ -0,0 +1,5 @@ +CREATE TABLE "platform_settings" ( + "key" text PRIMARY KEY NOT NULL, + "value" jsonb NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); diff --git a/platform/packages/api/drizzle/0046_email_domains_platform_credential.sql b/platform/packages/api/drizzle/0046_email_domains_platform_credential.sql new file mode 100644 index 000000000..54e36e22c --- /dev/null +++ b/platform/packages/api/drizzle/0046_email_domains_platform_credential.sql @@ -0,0 +1,8 @@ +ALTER TABLE "email_domains" DROP CONSTRAINT "email_domains_credential_kind_check";--> statement-breakpoint +ALTER TABLE "email_domains" ALTER COLUMN "token_ciphertext" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "email_domains" ALTER COLUMN "token_iv" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "email_domains" ALTER COLUMN "token_key_version" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "email_domains" ADD COLUMN "managed_by" text DEFAULT 'owner' NOT NULL;--> statement-breakpoint +ALTER TABLE "email_domains" ADD CONSTRAINT "email_domains_platform_token_check" CHECK (("email_domains"."credential_kind" = 'platform' AND "email_domains"."token_ciphertext" IS NULL AND "email_domains"."token_iv" IS NULL AND "email_domains"."token_key_version" IS NULL) OR ("email_domains"."credential_kind" <> 'platform' AND "email_domains"."token_ciphertext" IS NOT NULL AND "email_domains"."token_iv" IS NOT NULL AND "email_domains"."token_key_version" IS NOT NULL));--> statement-breakpoint +ALTER TABLE "email_domains" ADD CONSTRAINT "email_domains_managed_by_check" CHECK ("email_domains"."managed_by" IN ('owner', 'admin'));--> statement-breakpoint +ALTER TABLE "email_domains" ADD CONSTRAINT "email_domains_credential_kind_check" CHECK ("email_domains"."credential_kind" IN ('oauth_grant', 'api_token', 'platform')); \ No newline at end of file diff --git a/platform/packages/api/drizzle/meta/0045_snapshot.json b/platform/packages/api/drizzle/meta/0045_snapshot.json new file mode 100644 index 000000000..7a51e08e9 --- /dev/null +++ b/platform/packages/api/drizzle/meta/0045_snapshot.json @@ -0,0 +1,3705 @@ +{ + "id": "a20e1ddd-cbdd-4837-b089-fd5d385794ab", + "prevId": "f7884f49-37e0-437d-9e1a-128480e98a94", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.auth_codes": { + "name": "auth_codes", + "schema": "", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "jwt": { + "name": "jwt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cli_state": { + "name": "cli_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_challenge": { + "name": "code_challenge", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_challenge_method": { + "name": "code_challenge_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.builds": { + "name": "builds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "executor": { + "name": "executor", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'container'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "commit_sha": { + "name": "commit_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_id": { + "name": "deployment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_run_id": { + "name": "github_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_run_attempt": { + "name": "github_run_attempt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logs_url": { + "name": "logs_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "check_run_id": { + "name": "check_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "instance_type": { + "name": "instance_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_builds_project_created": { + "name": "idx_builds_project_created", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_builds_status": { + "name": "idx_builds_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_builds_user_status": { + "name": "idx_builds_user_status", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_builds_project_github_run_id": { + "name": "idx_builds_project_github_run_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "github_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "github_run_attempt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"builds\".\"github_run_id\" IS NOT NULL AND \"builds\".\"github_run_attempt\" IS NOT NULL AND \"builds\".\"status\" IN ('starting', 'building', 'deploying')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "builds_project_id_projects_id_fk": { + "name": "builds_project_id_projects_id_fk", + "tableFrom": "builds", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "builds_user_id_users_id_fk": { + "name": "builds_user_id_users_id_fk", + "tableFrom": "builds", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "builds_deployment_id_deployments_id_fk": { + "name": "builds_deployment_id_deployments_id_fk", + "tableFrom": "builds", + "tableTo": "deployments", + "columnsFrom": ["deployment_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployments": { + "name": "deployments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "manifest": { + "name": "manifest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commit": { + "name": "commit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_url": { + "name": "repository_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commit_url": { + "name": "commit_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "git_ref": { + "name": "git_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cli_version": { + "name": "cli_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "admission_protocol": { + "name": "admission_protocol", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "asset_hashes": { + "name": "asset_hashes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "retained": { + "name": "retained", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "applied_migrations": { + "name": "applied_migrations", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bundle_stored": { + "name": "bundle_stored", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "idx_deployments_project_id": { + "name": "idx_deployments_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployments_project_status": { + "name": "idx_deployments_project_status", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployments_created_id": { + "name": "idx_deployments_created_id", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployments_project_id_projects_id_fk": { + "name": "deployments_project_id_projects_id_fk", + "tableFrom": "deployments", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.domains": { + "name": "domains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cf_hostname_id": { + "name": "cf_hostname_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_data": { + "name": "verification_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_domains_project_id": { + "name": "idx_domains_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "domains_project_id_projects_id_fk": { + "name": "domains_project_id_projects_id_fk", + "tableFrom": "domains", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "domains_hostname_unique": { + "name": "domains_hostname_unique", + "nullsNotDistinct": false, + "columns": ["hostname"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_destinations": { + "name": "email_destinations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cf_destination_id": { + "name": "cf_destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "verification_source": { + "name": "verification_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'cf'" + }, + "verified_by_user_id": { + "name": "verified_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_by_email_snapshot": { + "name": "verified_by_email_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_email_destinations_project_id": { + "name": "idx_email_destinations_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_email_destinations_cf_destination_id": { + "name": "idx_email_destinations_cf_destination_id", + "columns": [ + { + "expression": "cf_destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_email_destinations_verified_by_user_id": { + "name": "idx_email_destinations_verified_by_user_id", + "columns": [ + { + "expression": "verified_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "verified_by_email_snapshot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_destinations_project_id_projects_id_fk": { + "name": "email_destinations_project_id_projects_id_fk", + "tableFrom": "email_destinations", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_destinations_verified_by_user_id_users_id_fk": { + "name": "email_destinations_verified_by_user_id_users_id_fk", + "tableFrom": "email_destinations", + "tableTo": "users", + "columnsFrom": ["verified_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_destinations_project_address_uq": { + "name": "email_destinations_project_address_uq", + "nullsNotDistinct": false, + "columns": ["project_id", "address"] + } + }, + "policies": {}, + "checkConstraints": { + "email_destinations_status_check": { + "name": "email_destinations_status_check", + "value": "\"email_destinations\".\"status\" IN ('pending', 'verified')" + }, + "email_destinations_verification_source_check": { + "name": "email_destinations_verification_source_check", + "value": "\"email_destinations\".\"verification_source\" IN ('cf', 'platform')" + } + }, + "isRLSEnabled": false + }, + "public.email_domains": { + "name": "email_domains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "zone_id": { + "name": "zone_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "credential_kind": { + "name": "credential_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_ciphertext": { + "name": "token_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_iv": { + "name": "token_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_key_version": { + "name": "token_key_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "relay_ciphertext": { + "name": "relay_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "relay_iv": { + "name": "relay_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "relay_key_version": { + "name": "relay_key_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "relay_worker_name": { + "name": "relay_worker_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "relay_version": { + "name": "relay_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_id": { + "name": "token_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_on": { + "name": "token_expires_on", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempt_id": { + "name": "attempt_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "setup_state": { + "name": "setup_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_email_domains_project_id": { + "name": "idx_email_domains_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_domains_zone_uq": { + "name": "email_domains_zone_uq", + "columns": [ + { + "expression": "zone_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"email_domains\".\"zone_id\" IS NOT NULL AND \"email_domains\".\"status\" IN ('active', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_domains_relay_uq": { + "name": "email_domains_relay_uq", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "relay_worker_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"email_domains\".\"relay_worker_name\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_domains_project_id_projects_id_fk": { + "name": "email_domains_project_id_projects_id_fk", + "tableFrom": "email_domains", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_domains_domain_uq": { + "name": "email_domains_domain_uq", + "nullsNotDistinct": false, + "columns": ["domain"] + } + }, + "policies": {}, + "checkConstraints": { + "email_domains_mode_check": { + "name": "email_domains_mode_check", + "value": "\"email_domains\".\"mode\" IN ('apex', 'subdomain')" + }, + "email_domains_status_check": { + "name": "email_domains_status_check", + "value": "\"email_domains\".\"status\" IN ('pending', 'active', 'token_revoked', 'token_expired', 'failed')" + }, + "email_domains_credential_kind_check": { + "name": "email_domains_credential_kind_check", + "value": "\"email_domains\".\"credential_kind\" IN ('oauth_grant', 'api_token')" + } + }, + "isRLSEnabled": false + }, + "public.github_connections": { + "name": "github_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorized_by_user_id": { + "name": "authorized_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "build_executor": { + "name": "build_executor", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'container'" + }, + "workflow_path": { + "name": "workflow_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'.github/workflows/void-deploy.yml'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_github_connections_installation_id": { + "name": "idx_github_connections_installation_id", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_github_connections_repo_full_name": { + "name": "idx_github_connections_repo_full_name", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_github_connections_installation_repo_id": { + "name": "idx_github_connections_installation_repo_id", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_connections_project_id_projects_id_fk": { + "name": "github_connections_project_id_projects_id_fk", + "tableFrom": "github_connections", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_connections_authorized_by_user_id_users_id_fk": { + "name": "github_connections_authorized_by_user_id_users_id_fk", + "tableFrom": "github_connections", + "tableTo": "users", + "columnsFrom": ["authorized_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_connections_project_id_unique": { + "name": "github_connections_project_id_unique", + "nullsNotDistinct": false, + "columns": ["project_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_installation_members": { + "name": "github_installation_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_account_id": { + "name": "github_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_github_installation_members_install_user": { + "name": "idx_github_installation_members_install_user", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_github_installation_members_user_id": { + "name": "idx_github_installation_members_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_installation_members_user_id_users_id_fk": { + "name": "github_installation_members_user_id_users_id_fk", + "tableFrom": "github_installation_members", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_installation_revocations": { + "name": "github_installation_revocations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_account_id": { + "name": "github_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_github_installation_revocations_install_account": { + "name": "idx_github_installation_revocations_install_account", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "github_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_installations": { + "name": "github_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repositories": { + "name": "repositories", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "repository_selection": { + "name": "repository_selection", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'selected'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_github_installations_user_id": { + "name": "idx_github_installations_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_github_installations_account_id": { + "name": "idx_github_installations_account_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_installations_user_id_users_id_fk": { + "name": "github_installations_user_id_users_id_fk", + "tableFrom": "github_installations", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_installations_installation_id_unique": { + "name": "github_installations_installation_id_unique", + "nullsNotDistinct": false, + "columns": ["installation_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_webhook_redeliveries": { + "name": "github_webhook_redeliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "guid": { + "name": "guid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "delivery_id": { + "name": "delivery_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "redelivered_at": { + "name": "redelivered_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_github_webhook_redeliveries_guid": { + "name": "idx_github_webhook_redeliveries_guid", + "columns": [ + { + "expression": "guid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitations": { + "name": "invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accepted_by": { + "name": "accepted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_invitations_email": { + "name": "idx_invitations_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_invitations_status": { + "name": "idx_invitations_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_renames": { + "name": "migration_renames", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "to_name": { + "name": "to_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_migration_renames_project_id": { + "name": "idx_migration_renames_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "migration_renames_project_id_projects_id_fk": { + "name": "migration_renames_project_id_projects_id_fk", + "tableFrom": "migration_renames", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "migration_renames_project_id_from_name_to_name_pk": { + "name": "migration_renames_project_id_from_name_to_name_pk", + "columns": ["project_id", "from_name", "to_name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_accounts": { + "name": "oauth_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_oauth_accounts_provider_id": { + "name": "idx_oauth_accounts_provider_id", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_oauth_accounts_user_id": { + "name": "idx_oauth_accounts_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_accounts_user_id_users_id_fk": { + "name": "oauth_accounts_user_id_users_id_fk", + "tableFrom": "oauth_accounts", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_nonces": { + "name": "oauth_nonces", + "schema": "", + "columns": { + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.operator_events": { + "name": "operator_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "login": { + "name": "login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "http_status": { + "name": "http_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_operator_events_created": { + "name": "idx_operator_events_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.operator_sessions": { + "name": "operator_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "human_session_id": { + "name": "human_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_operator_sessions_expiry": { + "name": "idx_operator_sessions_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "operator_sessions_user_id_users_id_fk": { + "name": "operator_sessions_user_id_users_id_fk", + "tableFrom": "operator_sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "operator_sessions_human_session_id_platform_human_sessions_id_fk": { + "name": "operator_sessions_human_session_id_platform_human_sessions_id_fk", + "tableFrom": "operator_sessions", + "tableTo": "platform_human_sessions", + "columnsFrom": ["human_session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_auth_attempts": { + "name": "platform_auth_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "browser_hash": { + "name": "browser_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context": { + "name": "context", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "platform_auth_attempts_expiry": { + "name": "platform_auth_attempts_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_auth_attempts_connection_id_platform_login_connections_id_fk": { + "name": "platform_auth_attempts_connection_id_platform_login_connections_id_fk", + "tableFrom": "platform_auth_attempts", + "tableTo": "platform_login_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_auth_bootstrap": { + "name": "platform_auth_bootstrap", + "schema": "", + "columns": { + "singleton": { + "name": "singleton", + "type": "boolean", + "primaryKey": true, + "notNull": true, + "default": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_hash": { + "name": "credential_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "claim_hash": { + "name": "claim_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pending_identity": { + "name": "pending_identity", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "platform_auth_bootstrap_connection_id_platform_login_connections_id_fk": { + "name": "platform_auth_bootstrap_connection_id_platform_login_connections_id_fk", + "tableFrom": "platform_auth_bootstrap", + "tableTo": "platform_login_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "platform_auth_bootstrap_singleton": { + "name": "platform_auth_bootstrap_singleton", + "value": "\"platform_auth_bootstrap\".\"singleton\" = true" + } + }, + "isRLSEnabled": false + }, + "public.platform_auth_settings": { + "name": "platform_auth_settings", + "schema": "", + "columns": { + "singleton": { + "name": "singleton", + "type": "boolean", + "primaryKey": true, + "notNull": true, + "default": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "admission": { + "name": "admission", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "protection": { + "name": "protection", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "session_seconds": { + "name": "session_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2592000 + }, + "sessions_required": { + "name": "sessions_required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "platform_auth_settings_singleton": { + "name": "platform_auth_settings_singleton", + "value": "\"platform_auth_settings\".\"singleton\" = true" + }, + "platform_auth_settings_revision": { + "name": "platform_auth_settings_revision", + "value": "\"platform_auth_settings\".\"revision\" > 0" + }, + "platform_auth_settings_session_seconds": { + "name": "platform_auth_settings_session_seconds", + "value": "\"platform_auth_settings\".\"session_seconds\" BETWEEN 300 AND 2592000" + } + }, + "isRLSEnabled": false + }, + "public.platform_company_identities": { + "name": "platform_company_identities", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "platform_company_identity_user": { + "name": "platform_company_identity_user", + "columns": [ + { + "expression": "issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_company_identities_user_id_users_id_fk": { + "name": "platform_company_identities_user_id_users_id_fk", + "tableFrom": "platform_company_identities", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "platform_company_identities_issuer_subject_pk": { + "name": "platform_company_identities_issuer_subject_pk", + "columns": ["issuer", "subject"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_events": { + "name": "platform_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "batch_id": { + "name": "batch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "worker_name": { + "name": "worker_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "script_name": { + "name": "script_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "admin_user_id": { + "name": "admin_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "admin_login": { + "name": "admin_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_platform_events_batch_id": { + "name": "idx_platform_events_batch_id", + "columns": [ + { + "expression": "batch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_platform_events_env_created": { + "name": "idx_platform_events_env_created", + "columns": [ + { + "expression": "environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_human_sessions": { + "name": "platform_human_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_issuer": { + "name": "access_issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_subject": { + "name": "access_subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "platform_human_sessions_connection": { + "name": "platform_human_sessions_connection", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "platform_human_sessions_user": { + "name": "platform_human_sessions_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "platform_human_sessions_expiry": { + "name": "platform_human_sessions_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_human_sessions_user_id_users_id_fk": { + "name": "platform_human_sessions_user_id_users_id_fk", + "tableFrom": "platform_human_sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "platform_human_sessions_connection_id_platform_login_connections_id_fk": { + "name": "platform_human_sessions_connection_id_platform_login_connections_id_fk", + "tableFrom": "platform_human_sessions", + "tableTo": "platform_login_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "platform_human_sessions_audience": { + "name": "platform_human_sessions_audience", + "value": "\"platform_human_sessions\".\"audience\" IN ('api', 'admin')" + }, + "platform_human_sessions_access_identity": { + "name": "platform_human_sessions_access_identity", + "value": "(\"platform_human_sessions\".\"access_issuer\" IS NULL) = (\"platform_human_sessions\".\"access_subject\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.platform_login_connections": { + "name": "platform_login_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "active_revision": { + "name": "active_revision", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "platform_login_connections_kind": { + "name": "platform_login_connections_kind", + "value": "\"platform_login_connections\".\"kind\" IN ('github', 'google', 'oidc', 'cloudflare-access')" + }, + "platform_login_connections_revision": { + "name": "platform_login_connections_revision", + "value": "\"platform_login_connections\".\"revision\" > 0 AND \"platform_login_connections\".\"generation\" > 0" + }, + "platform_login_connections_active_revision": { + "name": "platform_login_connections_active_revision", + "value": "\"platform_login_connections\".\"active_revision\" IS NULL OR \"platform_login_connections\".\"active_revision\" BETWEEN 1 AND \"platform_login_connections\".\"revision\"" + }, + "platform_login_connections_enabled": { + "name": "platform_login_connections_enabled", + "value": "NOT \"platform_login_connections\".\"enabled\" OR \"platform_login_connections\".\"active_revision\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.platform_login_identities": { + "name": "platform_login_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_authenticated_at": { + "name": "last_authenticated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "platform_login_identity_subject": { + "name": "platform_login_identity_subject", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "platform_login_identity_user": { + "name": "platform_login_identity_user", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_login_identities_user_id_users_id_fk": { + "name": "platform_login_identities_user_id_users_id_fk", + "tableFrom": "platform_login_identities", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "platform_login_identities_connection_id_platform_login_connections_id_fk": { + "name": "platform_login_identities_connection_id_platform_login_connections_id_fk", + "tableFrom": "platform_login_identities", + "tableTo": "platform_login_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_login_revisions": { + "name": "platform_login_revisions", + "schema": "", + "columns": { + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "configuration": { + "name": "configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "platform_login_revisions_connection_id_platform_login_connections_id_fk": { + "name": "platform_login_revisions_connection_id_platform_login_connections_id_fk", + "tableFrom": "platform_login_revisions", + "tableTo": "platform_login_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "platform_login_revisions_connection_id_revision_pk": { + "name": "platform_login_revisions_connection_id_revision_pk", + "columns": ["connection_id", "revision"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_login_tests": { + "name": "platform_login_tests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "login": { + "name": "login", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "platform_login_tests_expiry": { + "name": "platform_login_tests_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_login_tests_user_id_users_id_fk": { + "name": "platform_login_tests_user_id_users_id_fk", + "tableFrom": "platform_login_tests", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "platform_login_tests_connection_id_platform_login_connections_id_fk": { + "name": "platform_login_tests_connection_id_platform_login_connections_id_fk", + "tableFrom": "platform_login_tests", + "tableTo": "platform_login_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_settings": { + "name": "platform_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_database_credentials": { + "name": "project_database_credentials", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "iv": { + "name": "iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_version": { + "name": "key_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "project_database_credentials_project_id_projects_id_fk": { + "name": "project_database_credentials_project_id_projects_id_fk", + "tableFrom": "project_database_credentials", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_deploy_credentials": { + "name": "project_deploy_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issued_by": { + "name": "issued_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_deploy_credentials_project": { + "name": "project_deploy_credentials_project", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_deploy_credentials_expiry": { + "name": "project_deploy_credentials_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_deploy_credentials_project_id_projects_id_fk": { + "name": "project_deploy_credentials_project_id_projects_id_fk", + "tableFrom": "project_deploy_credentials", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_deploy_credentials_issued_by_users_id_fk": { + "name": "project_deploy_credentials_issued_by_users_id_fk", + "tableFrom": "project_deploy_credentials", + "tableTo": "users", + "columnsFrom": ["issued_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "project_deploy_credentials_name": { + "name": "project_deploy_credentials_name", + "value": "length(\"project_deploy_credentials\".\"name\") BETWEEN 1 AND 80" + } + }, + "isRLSEnabled": false + }, + "public.project_migrations": { + "name": "project_migrations", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "source_deployment_id": { + "name": "source_deployment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_project_migrations_project_id": { + "name": "idx_project_migrations_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "applied_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_migrations_project_id_projects_id_fk": { + "name": "project_migrations_project_id_projects_id_fk", + "tableFrom": "project_migrations", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_migrations_source_deployment_id_deployments_id_fk": { + "name": "project_migrations_source_deployment_id_deployments_id_fk", + "tableFrom": "project_migrations", + "tableTo": "deployments", + "columnsFrom": ["source_deployment_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_migrations_project_id_name_pk": { + "name": "project_migrations_project_id_name_pk", + "columns": ["project_id", "name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_secrets": { + "name": "project_secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "iv": { + "name": "iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_version": { + "name": "key_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "managed_reason": { + "name": "managed_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_project_secrets_project_id": { + "name": "idx_project_secrets_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_secrets_project_id_projects_id_fk": { + "name": "project_secrets_project_id_projects_id_fk", + "tableFrom": "project_secrets", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_secrets_project_id_name_unique": { + "name": "project_secrets_project_id_name_unique", + "nullsNotDistinct": false, + "columns": ["project_id", "name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'worker'" + }, + "active_deployment_id": { + "name": "active_deployment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "websocket_version": { + "name": "websocket_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_projects_slug": { + "name": "idx_projects_slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_projects_user_id": { + "name": "idx_projects_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_user_id_users_id_fk": { + "name": "projects_user_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "projects_user_id_slug_unique": { + "name": "projects_user_id_slug_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resources": { + "name": "resources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cf_id": { + "name": "cf_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_name": { + "name": "binding_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "resources_project_id_projects_id_fk": { + "name": "resources_project_id_projects_id_fk", + "tableFrom": "resources", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "resources_project_id_type_binding_name_unique": { + "name": "resources_project_id_type_binding_name_unique", + "nullsNotDistinct": false, + "columns": ["project_id", "type", "binding_name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandbox_drain_checkpoints": { + "name": "sandbox_drain_checkpoints", + "schema": "", + "columns": { + "singleton": { + "name": "singleton", + "type": "boolean", + "primaryKey": true, + "notNull": true + }, + "admission_protocol": { + "name": "admission_protocol", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "generation": { + "name": "generation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cursor": { + "name": "cursor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "login": { + "name": "login", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "billing_cycle_start": { + "name": "billing_cycle_start", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "suspended_reason": { + "name": "suspended_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_users_provider_id": { + "name": "idx_users_provider_id", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_email": { + "name": "idx_users_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/platform/packages/api/drizzle/meta/0046_snapshot.json b/platform/packages/api/drizzle/meta/0046_snapshot.json new file mode 100644 index 000000000..ed97c62d9 --- /dev/null +++ b/platform/packages/api/drizzle/meta/0046_snapshot.json @@ -0,0 +1,3720 @@ +{ + "id": "7f4f0bae-80ca-4c61-890f-84d00a663065", + "prevId": "a20e1ddd-cbdd-4837-b089-fd5d385794ab", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.auth_codes": { + "name": "auth_codes", + "schema": "", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "jwt": { + "name": "jwt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cli_state": { + "name": "cli_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_challenge": { + "name": "code_challenge", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_challenge_method": { + "name": "code_challenge_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.builds": { + "name": "builds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "executor": { + "name": "executor", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'container'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "commit_sha": { + "name": "commit_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_id": { + "name": "deployment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_run_id": { + "name": "github_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_run_attempt": { + "name": "github_run_attempt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logs_url": { + "name": "logs_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "check_run_id": { + "name": "check_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "instance_type": { + "name": "instance_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_builds_project_created": { + "name": "idx_builds_project_created", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_builds_status": { + "name": "idx_builds_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_builds_user_status": { + "name": "idx_builds_user_status", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_builds_project_github_run_id": { + "name": "idx_builds_project_github_run_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "github_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "github_run_attempt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"builds\".\"github_run_id\" IS NOT NULL AND \"builds\".\"github_run_attempt\" IS NOT NULL AND \"builds\".\"status\" IN ('starting', 'building', 'deploying')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "builds_project_id_projects_id_fk": { + "name": "builds_project_id_projects_id_fk", + "tableFrom": "builds", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "builds_user_id_users_id_fk": { + "name": "builds_user_id_users_id_fk", + "tableFrom": "builds", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "builds_deployment_id_deployments_id_fk": { + "name": "builds_deployment_id_deployments_id_fk", + "tableFrom": "builds", + "tableTo": "deployments", + "columnsFrom": ["deployment_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployments": { + "name": "deployments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "manifest": { + "name": "manifest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commit": { + "name": "commit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_url": { + "name": "repository_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commit_url": { + "name": "commit_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "git_ref": { + "name": "git_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cli_version": { + "name": "cli_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "admission_protocol": { + "name": "admission_protocol", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "asset_hashes": { + "name": "asset_hashes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "retained": { + "name": "retained", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "applied_migrations": { + "name": "applied_migrations", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bundle_stored": { + "name": "bundle_stored", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "idx_deployments_project_id": { + "name": "idx_deployments_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployments_project_status": { + "name": "idx_deployments_project_status", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployments_created_id": { + "name": "idx_deployments_created_id", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployments_project_id_projects_id_fk": { + "name": "deployments_project_id_projects_id_fk", + "tableFrom": "deployments", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.domains": { + "name": "domains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cf_hostname_id": { + "name": "cf_hostname_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_data": { + "name": "verification_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_domains_project_id": { + "name": "idx_domains_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "domains_project_id_projects_id_fk": { + "name": "domains_project_id_projects_id_fk", + "tableFrom": "domains", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "domains_hostname_unique": { + "name": "domains_hostname_unique", + "nullsNotDistinct": false, + "columns": ["hostname"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_destinations": { + "name": "email_destinations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cf_destination_id": { + "name": "cf_destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "verification_source": { + "name": "verification_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'cf'" + }, + "verified_by_user_id": { + "name": "verified_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_by_email_snapshot": { + "name": "verified_by_email_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_email_destinations_project_id": { + "name": "idx_email_destinations_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_email_destinations_cf_destination_id": { + "name": "idx_email_destinations_cf_destination_id", + "columns": [ + { + "expression": "cf_destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_email_destinations_verified_by_user_id": { + "name": "idx_email_destinations_verified_by_user_id", + "columns": [ + { + "expression": "verified_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "verified_by_email_snapshot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_destinations_project_id_projects_id_fk": { + "name": "email_destinations_project_id_projects_id_fk", + "tableFrom": "email_destinations", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_destinations_verified_by_user_id_users_id_fk": { + "name": "email_destinations_verified_by_user_id_users_id_fk", + "tableFrom": "email_destinations", + "tableTo": "users", + "columnsFrom": ["verified_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_destinations_project_address_uq": { + "name": "email_destinations_project_address_uq", + "nullsNotDistinct": false, + "columns": ["project_id", "address"] + } + }, + "policies": {}, + "checkConstraints": { + "email_destinations_status_check": { + "name": "email_destinations_status_check", + "value": "\"email_destinations\".\"status\" IN ('pending', 'verified')" + }, + "email_destinations_verification_source_check": { + "name": "email_destinations_verification_source_check", + "value": "\"email_destinations\".\"verification_source\" IN ('cf', 'platform')" + } + }, + "isRLSEnabled": false + }, + "public.email_domains": { + "name": "email_domains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "zone_id": { + "name": "zone_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "credential_kind": { + "name": "credential_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_ciphertext": { + "name": "token_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_iv": { + "name": "token_iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_key_version": { + "name": "token_key_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_by": { + "name": "managed_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'owner'" + }, + "relay_ciphertext": { + "name": "relay_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "relay_iv": { + "name": "relay_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "relay_key_version": { + "name": "relay_key_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "relay_worker_name": { + "name": "relay_worker_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "relay_version": { + "name": "relay_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_id": { + "name": "token_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_on": { + "name": "token_expires_on", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempt_id": { + "name": "attempt_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "setup_state": { + "name": "setup_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_email_domains_project_id": { + "name": "idx_email_domains_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_domains_zone_uq": { + "name": "email_domains_zone_uq", + "columns": [ + { + "expression": "zone_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"email_domains\".\"zone_id\" IS NOT NULL AND \"email_domains\".\"status\" IN ('active', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_domains_relay_uq": { + "name": "email_domains_relay_uq", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "relay_worker_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"email_domains\".\"relay_worker_name\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_domains_project_id_projects_id_fk": { + "name": "email_domains_project_id_projects_id_fk", + "tableFrom": "email_domains", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_domains_domain_uq": { + "name": "email_domains_domain_uq", + "nullsNotDistinct": false, + "columns": ["domain"] + } + }, + "policies": {}, + "checkConstraints": { + "email_domains_mode_check": { + "name": "email_domains_mode_check", + "value": "\"email_domains\".\"mode\" IN ('apex', 'subdomain')" + }, + "email_domains_status_check": { + "name": "email_domains_status_check", + "value": "\"email_domains\".\"status\" IN ('pending', 'active', 'token_revoked', 'token_expired', 'failed')" + }, + "email_domains_credential_kind_check": { + "name": "email_domains_credential_kind_check", + "value": "\"email_domains\".\"credential_kind\" IN ('oauth_grant', 'api_token', 'platform')" + }, + "email_domains_platform_token_check": { + "name": "email_domains_platform_token_check", + "value": "(\"email_domains\".\"credential_kind\" = 'platform' AND \"email_domains\".\"token_ciphertext\" IS NULL AND \"email_domains\".\"token_iv\" IS NULL AND \"email_domains\".\"token_key_version\" IS NULL) OR (\"email_domains\".\"credential_kind\" <> 'platform' AND \"email_domains\".\"token_ciphertext\" IS NOT NULL AND \"email_domains\".\"token_iv\" IS NOT NULL AND \"email_domains\".\"token_key_version\" IS NOT NULL)" + }, + "email_domains_managed_by_check": { + "name": "email_domains_managed_by_check", + "value": "\"email_domains\".\"managed_by\" IN ('owner', 'admin')" + } + }, + "isRLSEnabled": false + }, + "public.github_connections": { + "name": "github_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorized_by_user_id": { + "name": "authorized_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "build_executor": { + "name": "build_executor", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'container'" + }, + "workflow_path": { + "name": "workflow_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'.github/workflows/void-deploy.yml'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_github_connections_installation_id": { + "name": "idx_github_connections_installation_id", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_github_connections_repo_full_name": { + "name": "idx_github_connections_repo_full_name", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_github_connections_installation_repo_id": { + "name": "idx_github_connections_installation_repo_id", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_connections_project_id_projects_id_fk": { + "name": "github_connections_project_id_projects_id_fk", + "tableFrom": "github_connections", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_connections_authorized_by_user_id_users_id_fk": { + "name": "github_connections_authorized_by_user_id_users_id_fk", + "tableFrom": "github_connections", + "tableTo": "users", + "columnsFrom": ["authorized_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_connections_project_id_unique": { + "name": "github_connections_project_id_unique", + "nullsNotDistinct": false, + "columns": ["project_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_installation_members": { + "name": "github_installation_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_account_id": { + "name": "github_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_github_installation_members_install_user": { + "name": "idx_github_installation_members_install_user", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_github_installation_members_user_id": { + "name": "idx_github_installation_members_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_installation_members_user_id_users_id_fk": { + "name": "github_installation_members_user_id_users_id_fk", + "tableFrom": "github_installation_members", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_installation_revocations": { + "name": "github_installation_revocations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_account_id": { + "name": "github_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_github_installation_revocations_install_account": { + "name": "idx_github_installation_revocations_install_account", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "github_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_installations": { + "name": "github_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repositories": { + "name": "repositories", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "repository_selection": { + "name": "repository_selection", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'selected'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_github_installations_user_id": { + "name": "idx_github_installations_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_github_installations_account_id": { + "name": "idx_github_installations_account_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_installations_user_id_users_id_fk": { + "name": "github_installations_user_id_users_id_fk", + "tableFrom": "github_installations", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_installations_installation_id_unique": { + "name": "github_installations_installation_id_unique", + "nullsNotDistinct": false, + "columns": ["installation_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_webhook_redeliveries": { + "name": "github_webhook_redeliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "guid": { + "name": "guid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "delivery_id": { + "name": "delivery_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "redelivered_at": { + "name": "redelivered_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_github_webhook_redeliveries_guid": { + "name": "idx_github_webhook_redeliveries_guid", + "columns": [ + { + "expression": "guid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitations": { + "name": "invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accepted_by": { + "name": "accepted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_invitations_email": { + "name": "idx_invitations_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_invitations_status": { + "name": "idx_invitations_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_renames": { + "name": "migration_renames", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "to_name": { + "name": "to_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_migration_renames_project_id": { + "name": "idx_migration_renames_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "migration_renames_project_id_projects_id_fk": { + "name": "migration_renames_project_id_projects_id_fk", + "tableFrom": "migration_renames", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "migration_renames_project_id_from_name_to_name_pk": { + "name": "migration_renames_project_id_from_name_to_name_pk", + "columns": ["project_id", "from_name", "to_name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_accounts": { + "name": "oauth_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_oauth_accounts_provider_id": { + "name": "idx_oauth_accounts_provider_id", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_oauth_accounts_user_id": { + "name": "idx_oauth_accounts_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_accounts_user_id_users_id_fk": { + "name": "oauth_accounts_user_id_users_id_fk", + "tableFrom": "oauth_accounts", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_nonces": { + "name": "oauth_nonces", + "schema": "", + "columns": { + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.operator_events": { + "name": "operator_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "login": { + "name": "login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "http_status": { + "name": "http_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_operator_events_created": { + "name": "idx_operator_events_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.operator_sessions": { + "name": "operator_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "human_session_id": { + "name": "human_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_operator_sessions_expiry": { + "name": "idx_operator_sessions_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "operator_sessions_user_id_users_id_fk": { + "name": "operator_sessions_user_id_users_id_fk", + "tableFrom": "operator_sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "operator_sessions_human_session_id_platform_human_sessions_id_fk": { + "name": "operator_sessions_human_session_id_platform_human_sessions_id_fk", + "tableFrom": "operator_sessions", + "tableTo": "platform_human_sessions", + "columnsFrom": ["human_session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_auth_attempts": { + "name": "platform_auth_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "browser_hash": { + "name": "browser_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context": { + "name": "context", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "platform_auth_attempts_expiry": { + "name": "platform_auth_attempts_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_auth_attempts_connection_id_platform_login_connections_id_fk": { + "name": "platform_auth_attempts_connection_id_platform_login_connections_id_fk", + "tableFrom": "platform_auth_attempts", + "tableTo": "platform_login_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_auth_bootstrap": { + "name": "platform_auth_bootstrap", + "schema": "", + "columns": { + "singleton": { + "name": "singleton", + "type": "boolean", + "primaryKey": true, + "notNull": true, + "default": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_hash": { + "name": "credential_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "claim_hash": { + "name": "claim_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pending_identity": { + "name": "pending_identity", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "platform_auth_bootstrap_connection_id_platform_login_connections_id_fk": { + "name": "platform_auth_bootstrap_connection_id_platform_login_connections_id_fk", + "tableFrom": "platform_auth_bootstrap", + "tableTo": "platform_login_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "platform_auth_bootstrap_singleton": { + "name": "platform_auth_bootstrap_singleton", + "value": "\"platform_auth_bootstrap\".\"singleton\" = true" + } + }, + "isRLSEnabled": false + }, + "public.platform_auth_settings": { + "name": "platform_auth_settings", + "schema": "", + "columns": { + "singleton": { + "name": "singleton", + "type": "boolean", + "primaryKey": true, + "notNull": true, + "default": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "admission": { + "name": "admission", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "protection": { + "name": "protection", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "session_seconds": { + "name": "session_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2592000 + }, + "sessions_required": { + "name": "sessions_required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "platform_auth_settings_singleton": { + "name": "platform_auth_settings_singleton", + "value": "\"platform_auth_settings\".\"singleton\" = true" + }, + "platform_auth_settings_revision": { + "name": "platform_auth_settings_revision", + "value": "\"platform_auth_settings\".\"revision\" > 0" + }, + "platform_auth_settings_session_seconds": { + "name": "platform_auth_settings_session_seconds", + "value": "\"platform_auth_settings\".\"session_seconds\" BETWEEN 300 AND 2592000" + } + }, + "isRLSEnabled": false + }, + "public.platform_company_identities": { + "name": "platform_company_identities", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "platform_company_identity_user": { + "name": "platform_company_identity_user", + "columns": [ + { + "expression": "issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_company_identities_user_id_users_id_fk": { + "name": "platform_company_identities_user_id_users_id_fk", + "tableFrom": "platform_company_identities", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "platform_company_identities_issuer_subject_pk": { + "name": "platform_company_identities_issuer_subject_pk", + "columns": ["issuer", "subject"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_events": { + "name": "platform_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "batch_id": { + "name": "batch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "worker_name": { + "name": "worker_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "script_name": { + "name": "script_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "admin_user_id": { + "name": "admin_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "admin_login": { + "name": "admin_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_platform_events_batch_id": { + "name": "idx_platform_events_batch_id", + "columns": [ + { + "expression": "batch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_platform_events_env_created": { + "name": "idx_platform_events_env_created", + "columns": [ + { + "expression": "environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_human_sessions": { + "name": "platform_human_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_issuer": { + "name": "access_issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_subject": { + "name": "access_subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "platform_human_sessions_connection": { + "name": "platform_human_sessions_connection", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "platform_human_sessions_user": { + "name": "platform_human_sessions_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "platform_human_sessions_expiry": { + "name": "platform_human_sessions_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_human_sessions_user_id_users_id_fk": { + "name": "platform_human_sessions_user_id_users_id_fk", + "tableFrom": "platform_human_sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "platform_human_sessions_connection_id_platform_login_connections_id_fk": { + "name": "platform_human_sessions_connection_id_platform_login_connections_id_fk", + "tableFrom": "platform_human_sessions", + "tableTo": "platform_login_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "platform_human_sessions_audience": { + "name": "platform_human_sessions_audience", + "value": "\"platform_human_sessions\".\"audience\" IN ('api', 'admin')" + }, + "platform_human_sessions_access_identity": { + "name": "platform_human_sessions_access_identity", + "value": "(\"platform_human_sessions\".\"access_issuer\" IS NULL) = (\"platform_human_sessions\".\"access_subject\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.platform_login_connections": { + "name": "platform_login_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "active_revision": { + "name": "active_revision", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "platform_login_connections_kind": { + "name": "platform_login_connections_kind", + "value": "\"platform_login_connections\".\"kind\" IN ('github', 'google', 'oidc', 'cloudflare-access')" + }, + "platform_login_connections_revision": { + "name": "platform_login_connections_revision", + "value": "\"platform_login_connections\".\"revision\" > 0 AND \"platform_login_connections\".\"generation\" > 0" + }, + "platform_login_connections_active_revision": { + "name": "platform_login_connections_active_revision", + "value": "\"platform_login_connections\".\"active_revision\" IS NULL OR \"platform_login_connections\".\"active_revision\" BETWEEN 1 AND \"platform_login_connections\".\"revision\"" + }, + "platform_login_connections_enabled": { + "name": "platform_login_connections_enabled", + "value": "NOT \"platform_login_connections\".\"enabled\" OR \"platform_login_connections\".\"active_revision\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.platform_login_identities": { + "name": "platform_login_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_authenticated_at": { + "name": "last_authenticated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "platform_login_identity_subject": { + "name": "platform_login_identity_subject", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "platform_login_identity_user": { + "name": "platform_login_identity_user", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_login_identities_user_id_users_id_fk": { + "name": "platform_login_identities_user_id_users_id_fk", + "tableFrom": "platform_login_identities", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "platform_login_identities_connection_id_platform_login_connections_id_fk": { + "name": "platform_login_identities_connection_id_platform_login_connections_id_fk", + "tableFrom": "platform_login_identities", + "tableTo": "platform_login_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_login_revisions": { + "name": "platform_login_revisions", + "schema": "", + "columns": { + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "configuration": { + "name": "configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "platform_login_revisions_connection_id_platform_login_connections_id_fk": { + "name": "platform_login_revisions_connection_id_platform_login_connections_id_fk", + "tableFrom": "platform_login_revisions", + "tableTo": "platform_login_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "platform_login_revisions_connection_id_revision_pk": { + "name": "platform_login_revisions_connection_id_revision_pk", + "columns": ["connection_id", "revision"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_login_tests": { + "name": "platform_login_tests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "login": { + "name": "login", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "platform_login_tests_expiry": { + "name": "platform_login_tests_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_login_tests_user_id_users_id_fk": { + "name": "platform_login_tests_user_id_users_id_fk", + "tableFrom": "platform_login_tests", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "platform_login_tests_connection_id_platform_login_connections_id_fk": { + "name": "platform_login_tests_connection_id_platform_login_connections_id_fk", + "tableFrom": "platform_login_tests", + "tableTo": "platform_login_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_settings": { + "name": "platform_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_database_credentials": { + "name": "project_database_credentials", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "iv": { + "name": "iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_version": { + "name": "key_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "project_database_credentials_project_id_projects_id_fk": { + "name": "project_database_credentials_project_id_projects_id_fk", + "tableFrom": "project_database_credentials", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_deploy_credentials": { + "name": "project_deploy_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issued_by": { + "name": "issued_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_deploy_credentials_project": { + "name": "project_deploy_credentials_project", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_deploy_credentials_expiry": { + "name": "project_deploy_credentials_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_deploy_credentials_project_id_projects_id_fk": { + "name": "project_deploy_credentials_project_id_projects_id_fk", + "tableFrom": "project_deploy_credentials", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_deploy_credentials_issued_by_users_id_fk": { + "name": "project_deploy_credentials_issued_by_users_id_fk", + "tableFrom": "project_deploy_credentials", + "tableTo": "users", + "columnsFrom": ["issued_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "project_deploy_credentials_name": { + "name": "project_deploy_credentials_name", + "value": "length(\"project_deploy_credentials\".\"name\") BETWEEN 1 AND 80" + } + }, + "isRLSEnabled": false + }, + "public.project_migrations": { + "name": "project_migrations", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "source_deployment_id": { + "name": "source_deployment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_project_migrations_project_id": { + "name": "idx_project_migrations_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "applied_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_migrations_project_id_projects_id_fk": { + "name": "project_migrations_project_id_projects_id_fk", + "tableFrom": "project_migrations", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_migrations_source_deployment_id_deployments_id_fk": { + "name": "project_migrations_source_deployment_id_deployments_id_fk", + "tableFrom": "project_migrations", + "tableTo": "deployments", + "columnsFrom": ["source_deployment_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_migrations_project_id_name_pk": { + "name": "project_migrations_project_id_name_pk", + "columns": ["project_id", "name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_secrets": { + "name": "project_secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "iv": { + "name": "iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_version": { + "name": "key_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "managed_reason": { + "name": "managed_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_project_secrets_project_id": { + "name": "idx_project_secrets_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_secrets_project_id_projects_id_fk": { + "name": "project_secrets_project_id_projects_id_fk", + "tableFrom": "project_secrets", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_secrets_project_id_name_unique": { + "name": "project_secrets_project_id_name_unique", + "nullsNotDistinct": false, + "columns": ["project_id", "name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'worker'" + }, + "active_deployment_id": { + "name": "active_deployment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "websocket_version": { + "name": "websocket_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_projects_slug": { + "name": "idx_projects_slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_projects_user_id": { + "name": "idx_projects_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_user_id_users_id_fk": { + "name": "projects_user_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "projects_user_id_slug_unique": { + "name": "projects_user_id_slug_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resources": { + "name": "resources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cf_id": { + "name": "cf_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_name": { + "name": "binding_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "resources_project_id_projects_id_fk": { + "name": "resources_project_id_projects_id_fk", + "tableFrom": "resources", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "resources_project_id_type_binding_name_unique": { + "name": "resources_project_id_type_binding_name_unique", + "nullsNotDistinct": false, + "columns": ["project_id", "type", "binding_name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandbox_drain_checkpoints": { + "name": "sandbox_drain_checkpoints", + "schema": "", + "columns": { + "singleton": { + "name": "singleton", + "type": "boolean", + "primaryKey": true, + "notNull": true + }, + "admission_protocol": { + "name": "admission_protocol", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "generation": { + "name": "generation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cursor": { + "name": "cursor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "login": { + "name": "login", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "billing_cycle_start": { + "name": "billing_cycle_start", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "suspended_reason": { + "name": "suspended_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_users_provider_id": { + "name": "idx_users_provider_id", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_email": { + "name": "idx_users_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/platform/packages/api/drizzle/meta/_journal.json b/platform/packages/api/drizzle/meta/_journal.json index 1d9893887..6ab553c3f 100644 --- a/platform/packages/api/drizzle/meta/_journal.json +++ b/platform/packages/api/drizzle/meta/_journal.json @@ -267,6 +267,20 @@ "when": 1789719174762, "tag": "0044_smart_mentor", "breakpoints": true + }, + { + "idx": 45, + "version": "7", + "when": 1789733718999, + "tag": "0045_platform_settings", + "breakpoints": true + }, + { + "idx": 46, + "version": "7", + "when": 1789733775150, + "tag": "0046_email_domains_platform_credential", + "breakpoints": true } ] } diff --git a/platform/packages/api/src/admin/routes/email.tsx b/platform/packages/api/src/admin/routes/email.tsx index 6c36ef927..8ee3b54b5 100644 --- a/platform/packages/api/src/admin/routes/email.tsx +++ b/platform/packages/api/src/admin/routes/email.tsx @@ -1,5 +1,6 @@ import { asc, eq, sql } from 'drizzle-orm'; import { Hono, type Context } from 'hono'; +import { getPlatformCapabilities } from '../../capabilities.ts'; import { countProjectsUsingDestination, deleteEmailDestination, @@ -11,6 +12,7 @@ import { revokeOauthGrantSerialized, withEmailDomainRowLock, } from '../../email/credential-lease.ts'; +import { addEmailDomainForProject } from '../../email/domain-service.ts'; import { clearEmailDomainMirror, decryptEmailDomainRelaySecret, @@ -18,46 +20,118 @@ import { hashEmailRelaySecret, writeEmailDomainMirror, } from '../../email/domains.ts'; +import { + DEFAULT_EMAIL_BURST_LIMIT as DEFAULT_BURST_LIMIT, + DEFAULT_EMAIL_MONTHLY_LIMIT as DEFAULT_MONTHLY_LIMIT, + emailBurstLimitKey as burstLimitKey, + emailMonthlyLimitKey as monthlyLimitKey, + emailSuspendKey as suspendKey, + parseEmailLimit as parseLimit, +} from '../../email/limits.ts'; import { removeEmailDomain } from '../../email/relay-engine.ts'; +import { + getEmailDomainManagement, + getEmailRecipientPolicy, + repairEmailRecipientPolicyMirror, +} from '../../email/settings.ts'; import type { ApiEnv } from '../../env.ts'; +import { executeAuditedMutation, operatorAuditIntent } from '../../operator/audit.ts'; +import { OperatorError } from '../../operator/common.ts'; +import { setOperatorEmailPolicy, setOperatorEmailSettings } from '../../operator/email-routes.ts'; import { syncProjectAllowlistKv, syncProjectAllowlistKvBestEffort, } from '../../provisioning/email-allowlist.ts'; import { deleteDestination } from '../../provisioning/email-routing.ts'; import { emailDomains } from '../../schema.ts'; +import { renderOperationError } from '../operation-error.tsx'; +import { EmailSettingsPage } from '../views/email-settings.tsx'; import { STATUS_COLORS, formatDateTime } from '../views/format.ts'; import { Layout } from '../views/layout.tsx'; const app = new Hono(); +app.onError(renderOperationError); +// The same capability boundary the owner and operator email routes sit +// behind: a platform that does not enable email must not have its policy, +// settings or domains changed from the browser either. +app.use('*', async (context, next) => { + if (!getPlatformCapabilities(context.env).email) { + return context.html('

Email is not enabled on this platform

', 501); + } + await next(); +}); /** - * Per-project email control plane for operators. + * Email control plane for operators: installation-wide settings at `/`, + * one project's usage, caps, kill switch, destinations and domains at + * `/:projectId`. * - * Every number on this page is read straight out of `ROUTING` KV, which is - * where the proxy worker (`packages/proxy/src/email.ts`) keeps the counters - * and reads the caps from. The keys and the defaults below MUST stay in - * lockstep with that worker — they are the same contract written from both - * ends, and nothing at build time catches a drift. + * Every number on the project page is read straight out of `ROUTING` KV, + * which is where the proxy worker (`packages/proxy/src/email.ts`) keeps the + * counters and reads the caps from. The keys and defaults come from + * `email/limits.ts`, the one spelling the api's writers share with the + * proxy's readers — nothing at build time catches a drift. * * The destination and custom-domain rows are Postgres. The proxy's only * view of a custom domain is the `email-domain:` ROUTING mirror, so the * revoke below clears that key before the row. */ -const suspendKey = (projectId: string) => `email-suspended:${projectId}`; -const monthlyLimitKey = (projectId: string) => `email-limit:${projectId}`; -const burstLimitKey = (projectId: string) => `email-burst-limit:${projectId}`; const sentKey = (projectId: string, month: string) => `email-sent:${projectId}:${month}`; const recvKey = (projectId: string, month: string) => `email-recv:${projectId}:${month}`; -/** Mirrors `getMonthlyLimit` in `packages/proxy/src/email.ts`. */ -const DEFAULT_MONTHLY_LIMIT = 200; -/** Mirrors `getBurstLimit` in `packages/proxy/src/email.ts`. */ -const DEFAULT_BURST_LIMIT = 10; - /** The admin layout has no utility classes, so secondary text is inline. */ const META = 'font-size: 0.8rem; color: #666'; +// GET /email/ — installation-wide recipient policy and domain management +app.get('/', async (context) => { + const db = context.get('db'); + // Every read surface republishes a drifted mirror, this page included: + // an administrator who sees `verified` here must be seeing what the + // proxy enforces. + await repairEmailRecipientPolicyMirror(db, context.env.ROUTING); + const [policy, management] = await Promise.all([ + getEmailRecipientPolicy(db), + getEmailDomainManagement(db), + ]); + return context.html( + , + ); +}); + +// POST /email/policy — the shared-sender recipient policy +app.post('/policy', async (context) => { + const body = await context.req.parseBody(); + const mode = String(body.mode ?? ''); + const domains = typeof body.domains === 'string' ? body.domains : ''; + return executeAuditedMutation( + context, + operatorAuditIntent('email/policy', { mode, domains }), + async () => { + await setOperatorEmailPolicy(context, { mode, domains: domains || undefined }); + return { response: context.redirect('/admin/email'), succeeded: true }; + }, + ); +}); + +// POST /email/settings — who manages email domains +app.post('/settings', async (context) => { + const body = await context.req.parseBody(); + const domains = String(body.domains ?? ''); + return executeAuditedMutation( + context, + operatorAuditIntent('email/settings', { domains }), + async () => { + await setOperatorEmailSettings(context, domains); + return { response: context.redirect('/admin/email'), succeeded: true }; + }, + ); +}); + // GET /email/:projectId — usage, caps, kill switch, destinations app.get('/:projectId', async (context) => { const projectId = context.req.param('projectId'); @@ -266,16 +340,40 @@ app.get('/:projectId', async (context) => {

Custom domains ({domains.length})

- Sender/recipient domains on the customer's own Cloudflare zone, registered by the project - owner through POST /projects/:id/email/domains. Revoke clears the{' '} - email-domain: ROUTING mirror FIRST — the proxy trusts only that key, and a - failed clear keeps the row so the retry can finish — then deletes the relay worker and - catch-all rule in the customer's account best-effort (a stranded relay is inert without the - mirror entry) and drops the row. For oauth_grant rows, revoke grant is the - lighter escape hatch: it kills just the customer's OAuth grant at Cloudflare and flips the - row to token_revoked, leaving the row and mirror in place. The customer's - credential and the relay secret are never shown here. + Sender/recipient domains on a Cloudflare zone, registered by the project owner through{' '} + void email domain add or by an administrator below (the row then runs on the + platform's own credential and the zone must be in the platform's account — paste a token for + a zone elsewhere). Revoke clears the email-domain: ROUTING mirror FIRST — the + proxy trusts only that key, and a failed clear keeps the row so the retry can finish — then + deletes the relay worker and catch-all rule in the customer's account best-effort (a + stranded relay is inert without the mirror entry) and drops the row. For{' '} + oauth_grant rows, revoke grant is the lighter escape hatch: it kills just the + customer's OAuth grant at Cloudflare and flips the row to token_revoked, + leaving the row and mirror in place. The customer's credential and the relay secret are + never shown here.

+
+ + + +
{domains.length === 0 ? (

No custom domains. @@ -286,6 +384,7 @@ app.get('/:projectId', async (context) => { Domain Status + Managed by Mode Relay worker v @@ -301,6 +400,10 @@ app.get('/:projectId', async (context) => { {domain.status} + + {domain.managed_by} + {domain.credential_kind === 'platform' ? ' (platform credential)' : ''} + {domain.mode ?? '-'} {domain.relay_worker_name ?? '-'} {domain.relay_version ?? '-'} @@ -355,6 +458,76 @@ app.get('/:projectId', async (context) => { ); }); +// POST /email/:projectId/domains/add — register a domain for the project as +// an administrator (design doc 0078, "Administrator-managed domains"). The +// same operation the operator CLI runs; a blank token means the platform's +// own credential. The token is never echoed or audited. +app.post('/:projectId/domains/add', async (context) => { + const projectId = context.req.param('projectId'); + const project = await getProject(context.get('db'), projectId); + if (!project) { + return context.html('

Project not found

', 404); + } + const body = await context.req.parseBody(); + const domain = typeof body.domain === 'string' ? body.domain.trim() : ''; + const token = typeof body.token === 'string' ? body.token.trim() : ''; + if (domain === '' || domain.length > 253) { + throw new OperatorError(400, 'Enter the mail domain to register.'); + } + return executeAuditedMutation( + context, + operatorAuditIntent('email/domains', { domain, project: projectId }), + async () => { + const res = await addEmailDomainForProject(context, project, { + domain, + apiToken: token || undefined, + managedBy: 'admin', + }); + if (!res.ok) { + const json = (await res.json()) as { error?: unknown }; + throw new OperatorError( + res.status, + typeof json.error === 'string' ? json.error : 'Adding the email domain failed.', + ); + } + const row = (await res.json()) as { + status?: string; + error?: string; + setup_state?: { failed_step?: string }; + }; + if ( + row.status === 'failed' || + row.status === 'token_revoked' || + row.status === 'token_expired' + ) { + const message = + row.error ?? + (row.setup_state?.failed_step + ? `Setup failed at step "${row.setup_state.failed_step}".` + : 'The email domain setup did not finish.'); + return { + response: await context.html( + +

Adding {domain} did not finish

+

{message}

+

+ Fix the step named above, then add the domain again — adding again retries it; rules + stay. +

+

+ ← Back to project email +

+
, + 502, + ), + succeeded: false, + }; + } + return { response: context.redirect(`/admin/email/${projectId}`), succeeded: true }; + }, + ); +}); + // POST /email/:projectId/suspend — set the kill switch to a desired terminal state app.post('/:projectId/suspend', async (context) => { const projectId = context.req.param('projectId'); @@ -798,17 +971,4 @@ function parseCounter(raw: string | null): number { return Number.isFinite(n) && n >= 0 ? n : 0; } -/** - * Undefined means "no usable override" — either the key is absent or it holds - * a value the proxy would itself reject, in which case the proxy is already - * serving the default and this page must say so too. - */ -function parseLimit(raw: string | null): number | undefined { - if (!raw) { - return undefined; - } - const n = parseInt(raw, 10); - return Number.isFinite(n) && n > 0 ? n : undefined; -} - export default app; diff --git a/platform/packages/api/src/admin/views/email-settings.tsx b/platform/packages/api/src/admin/views/email-settings.tsx new file mode 100644 index 000000000..277ff5548 --- /dev/null +++ b/platform/packages/api/src/admin/views/email-settings.tsx @@ -0,0 +1,94 @@ +import type { EmailDomainManagement, EmailRecipientPolicy } from '../../email/settings.ts'; +import { Layout } from './layout.tsx'; + +const META = 'font-size: 0.8rem; color: #666'; + +export function EmailSettingsPage({ + policy, + management, + senderDomain, + username, +}: { + policy: EmailRecipientPolicy; + management: EmailDomainManagement; + senderDomain: string | undefined; + username?: string; +}) { + return ( + +

Email

+

+ Installation-wide settings. Per-project usage, caps and domains live on each project's email + page (Projects → project → Email). +

+ +

Recipient policy

+

+ Who mail from the shared sender + {senderDomain ? ( + <> + {' '} + ({`+tag@${senderDomain}`}) + + ) : null}{' '} + may reach. Custom-domain sends are not affected. +

+
+ + + + + +
+

+ With domains or any, Cloudflare still refuses a destination it has + not verified unless the platform mail domain is onboarded for Email Sending (Workers Paid). + Those refusals arrive as per-recipient UNVERIFIED_DESTINATION results. +

+ +

Domain management

+

Who registers custom email domains for projects.

+
+ + + +
+
+ ); +} diff --git a/platform/packages/api/src/admin/views/layout.tsx b/platform/packages/api/src/admin/views/layout.tsx index 801a2a34c..a29619310 100644 --- a/platform/packages/api/src/admin/views/layout.tsx +++ b/platform/packages/api/src/admin/views/layout.tsx @@ -42,7 +42,7 @@ export function Layout({ Deployments |{' '} CLI Versions |{' '} Signup | Settings{' '} - | Invitations |{' '} + | Email | Invitations |{' '} Platform diff --git a/platform/packages/api/src/email/credential-lease.ts b/platform/packages/api/src/email/credential-lease.ts index 6018980bd..613cec98f 100644 --- a/platform/packages/api/src/email/credential-lease.ts +++ b/platform/packages/api/src/email/credential-lease.ts @@ -30,7 +30,7 @@ import { emailDomains } from '../schema.ts'; import { OAuthGrantError, refreshOauthGrant, revokeOauthGrant } from './cf-customer.ts'; import type { OauthGrantTokens } from './cf-customer.ts'; import { decryptEmailDomainToken, encryptEmailDomainToken } from './domains.ts'; -import type { EmailDomain } from './domains.ts'; +import type { EmailDomain, PlatformCredentialBindings } from './domains.ts'; export type EmailDomainLease = | Readonly<{ kind: 'api_token'; token: string }> @@ -127,6 +127,7 @@ export async function refreshOauthGrantSerialized( } const refreshToken = await decryptEmailDomainToken(env, row.project_id, row.domain, { + credential_kind: row.credential_kind, token_ciphertext: row.token_ciphertext, token_iv: row.token_iv, token_key_version: row.token_key_version, @@ -218,6 +219,7 @@ export async function revokeOauthGrantSerialized( throw new EmailDomainCredentialChangedError(rowId); } const head = await decryptEmailDomainToken(env, row.project_id, row.domain, { + credential_kind: row.credential_kind, token_ciphertext: row.token_ciphertext, token_iv: row.token_iv, token_key_version: row.token_key_version, @@ -260,7 +262,7 @@ export async function withEmailDomainRowLock( */ export async function leaseEmailDomainCredential( db: Db, - env: SecretBindings, + env: SecretBindings & Partial, row: EmailDomain, ): Promise { // The caller's row snapshot is unlocked; a replace can commit between @@ -268,8 +270,16 @@ export async function leaseEmailDomainCredential( // row NOW holds — a second mid-lease change fails as transient. let current = row; for (let attempt = 0; ; attempt++) { - if (current.credential_kind === 'api_token') { - const token = await decryptEmailDomainToken(env, current.project_id, current.domain, current); + // An api_token row leases its stored token; a platform row leases the + // runtime token the same seam resolves. Both are long-lived and travel + // to the proxy as `api_token` — it never needs to tell them apart. + if (current.credential_kind === 'api_token' || current.credential_kind === 'platform') { + let token: string; + try { + token = await decryptEmailDomainToken(env, current.project_id, current.domain, current); + } catch (err) { + return { ok: false, status: 'failed', error: errorMessage(err) }; + } return { ok: true, lease: { kind: 'api_token', token } }; } try { diff --git a/platform/packages/api/src/email/domain-name.ts b/platform/packages/api/src/email/domain-name.ts new file mode 100644 index 000000000..cba252e2e --- /dev/null +++ b/platform/packages/api/src/email/domain-name.ts @@ -0,0 +1,15 @@ +/** Longest hostname RFC 1035 allows; checked on the raw input before any normalization. */ +export const MAX_DOMAIN_LENGTH = 253; + +export function normalizeEmailDomain(raw: string): string { + return raw.trim().toLowerCase().replace(/\.$/, ''); +} + +/** + * DNS hostname shape: two or more labels, each 1–63 chars, alphanumeric + + * interior hyphens. Real validation is the engine's zone lookup — this + * keeps junk out of the encrypt and CF calls. + */ +export function isValidEmailDomain(domain: string): boolean { + return /^(?!-)[a-z0-9-]{1,63}(?; + +/** The administrator command an owner is pointed at when a row is not theirs to change. */ +export function adminEmailDomainCommand( + verb: 'add' | 'sync' | 'remove', + domain: string, + slug?: string, +): string { + const base = `void platform email domain-${verb} ${domain}`; + return verb === 'add' && slug ? `${base} --project ${slug}` : base; +} + +/** + * Who may change a row. An owner touching an administrator's row is pointed + * at the operator command (403) for every verb — the row runs on the + * platform's credential. An administrator may sync or remove an owner's + * row (the ownership table's maintenance powers) but never re-`add` it: a + * takeover would revoke the owner's grant and flip the row (409). `null` + * when allowed. Callers run this on the unlocked read for the message and + * AGAIN on the locked row, because either side can commit in between. + */ +function refuseCrossOwnership( + context: Context, + row: Pick, + actor: EmailDomain['managed_by'], + slug: string, + verb: 'add' | 'sync' | 'remove' = 'add', +): Response | null { + if (row.managed_by === actor) { + return null; + } + if (actor === 'owner') { + return context.json( + { + error: 'this email domain is managed by the platform administrator', + adminCommand: adminEmailDomainCommand(verb, row.domain, slug), + }, + 403, + ); + } + if (verb !== 'add') { + return null; + } + return context.json( + { error: 'this email domain was registered by its project owner; ask them to remove it first' }, + 409, + ); +} + +/** + * A `platform` row may only act on a zone in the platform's own account. + * A row that has not learned its account yet passes — the engine's zone + * lookup pins it before any write. + */ +function platformAccountAllows( + context: Context, + row: Pick, +): boolean { + return row.account_id === null || row.account_id === context.env.CF_ACCOUNT_ID; +} + +/** + * The proxy origin baked into the relay worker as VOID_PROXY_ORIGIN — the + * same resolution the platform discovery document uses. + */ +export function proxyOriginForEmail(env: ApiEnv['Bindings']): string { + const origin = + env.PROXY_URL ?? + (env.PLATFORM_ENV === 'staging' + ? 'https://proxy.staging.void.cloud' + : 'https://proxy.void.cloud'); + return origin.replace(/\/+$/, ''); +} + +/** + * The mirror key spelling is owned by `src/email/domains.ts` (the writer — + * frozen for this phase); the status route's read-compare repair reads the + * same key. A rename there must move this spelling with it. + */ +export function emailDomainMirrorKey(domain: string): string { + return `email-domain:${domain}`; +} + +export async function getEmailDomainRow( + db: Db, + projectId: string, + domain: string, +): Promise { + const rows = await db + .select() + .from(emailDomains) + .where(and(eq(emailDomains.project_id, projectId), eq(emailDomains.domain, domain))) + .limit(1); + return rows[0] ?? null; +} + +export async function getEmailDomainRowById(db: DbOrTx, id: string): Promise { + const rows = await db.select().from(emailDomains).where(eq(emailDomains.id, id)).limit(1); + return rows[0] ?? null; +} + +/** + * Cross-project lookup: a domain is globally unique across projects + * (`email_domains_domain_uq`), because one domain produces exactly one + * `email-domain:` mirror key and one relay secret. + */ +export async function getEmailDomainRowByDomain( + db: Db, + domain: string, +): Promise { + const rows = await db.select().from(emailDomains).where(eq(emailDomains.domain, domain)).limit(1); + return rows[0] ?? null; +} + +/** + * The public shape of an email_domains row — metadata only. Neither + * ciphertext triple nor any plaintext secret ever appears here. + */ +export function serializeEmailDomain(row: EmailDomain, error?: string) { + return { + id: row.id, + domain: row.domain, + mode: row.mode, + status: row.status, + credential_kind: row.credential_kind, + managed_by: row.managed_by, + relay_worker_name: row.relay_worker_name, + relay_version: row.relay_version, + token_id: row.token_id, + token_expires_on: row.token_expires_on, + // Always present (null when the row predates attempt identity): key + // presence is how a reconciling CLI detects an api that stamps it. + attemptId: row.attempt_id ?? null, + setup_state: row.setup_state, + // The engine's failure text names the failing step / CF error — never + // credential material — so the CLI can print it verbatim. + ...(error === undefined ? {} : { error }), + created_at: row.created_at, + updated_at: row.updated_at, + }; +} + +/** + * Persist the engine's setup outcome onto the row and return the final + * row. Never touches the token_* triple: the caller wrote it at insert, + * and any rotation since was persisted by the serialized refresh under the + * row lock. + */ +async function applyEmailDomainSetupResult( + db: DbOrTx, + id: string, + result: EmailDomainSetupResult, + relay?: { ciphertext: string; iv: string; keyVersion: string }, +): Promise { + await db + .update(emailDomains) + .set({ + status: result.status, + zone_id: result.zone_id, + account_id: result.account_id, + mode: result.mode, + relay_worker_name: result.relay_worker_name, + relay_version: result.relay_version, + token_id: result.token_id, + token_expires_on: result.token_expires_on, + setup_state: result.setup_state, + ...(relay === undefined + ? {} + : { + relay_ciphertext: relay.ciphertext, + relay_iv: relay.iv, + relay_key_version: relay.keyVersion, + }), + updated_at: sql`now()`, + }) + .where(eq(emailDomains.id, id)); + const row = await getEmailDomainRowById(db, id); + if (!row) { + throw new Error('email_domains row vanished mid-setup'); + } + return row; +} + +/** + * The 23505 loser's best-effort CF compensation, shared by the apply-time + * backstop (applyEmailDomainSetupResultOrConflict) and the deploy-time + * checkpoint (persistProgress): both lose a zone (or one account's relay + * name) to a setup that already passed the engine's CF-side reads — + * first-to-persist wins. + * + * The relay-NAME branch is a safety net, not a live path: fresh setups + * CLAIM the name on their own row (the claimRelayName dep) BEFORE any + * upload, so a checkpoint-time relay-name 23505 is no longer reachable + * from the setup flow — the claim itself is the only relay-name 23505 + * left, and the walk absorbs it as 'taken'. The branch stays for rows + * written before the claim existed and as defense in depth. + * + * Works from what the engine RETURNED or checkpointed (the loser's row + * still holds nulls — a claimed name is undone before this runs). A + * relay-NAME conflict never deletes the worker — + * the name is contested, the winner is using it — so the compensation + * runs with account_id nulled: only the loser's zone rule is detached. On + * a ZONE conflict the winner is looked up and an owned catch-all is + * REPOINTED at its relay: the loser's conditional reset only detaches a + * rule naming its OWN relay, so without a repoint target the zone's rule + * dies with the loser; repointing hands it to the winner instead. (At + * checkpoint-time conflicts the loser never wrote its rule, so the + * repoint probe is a harmless no-op — one code path.) A failed winner + * lookup degrades to the plain reset. Deliberately NO revokeGrant dep: + * the loser's row/credential may survive — conflict compensation is not + * removal. + */ +async function compensateEmailDomainRace( + context: Context, + loserRowId: string, + race: { + credentialKind: EmailDomain['credential_kind']; + credential: string; + }, + loser: { + zone_id: string | null; + account_id: string | null; + relay_worker_name: string | null; + }, + err: unknown, +): Promise { + const relayRace = uniqueViolationConstraintName(err).includes('relay'); + let repointTo: string | null = null; + if (!relayRace && loser.zone_id !== null) { + try { + const winners = await context + .get('db') + .select() + .from(emailDomains) + .where( + and( + eq(emailDomains.zone_id, loser.zone_id), + // The exact live-status set of the schema's + // email_domains_zone_uq partial index — the rows this loser + // lost to. + inArray(emailDomains.status, ['active', 'pending']), + isNotNull(emailDomains.relay_worker_name), + ne(emailDomains.id, loserRowId), + ), + ) + .limit(1); + repointTo = winners[0]?.relay_worker_name ?? null; + } catch (lookupErr) { + console.error('[email] race winner lookup failed; falling back to reset:', lookupErr); + } + } + try { + await removeEmailDomain( + { + row: { + credential_kind: race.credentialKind, + // A contested relay name is never deleted (see the header). + account_id: relayRace ? null : loser.account_id, + relay_worker_name: loser.relay_worker_name, + zone_id: loser.zone_id, + }, + credential: race.credential, + ...(repointTo === null ? {} : { repoint_catch_all_to: repointTo }), + }, + { + // Grant refresh tokens are single-use: the compensation's refresh + // must persist its rotation through the row's serialized chain, + // or the cleanup would brick the credential it leaves behind. + refreshGrant: () => refreshOauthGrantSerialized(context.get('db'), context.env, loserRowId), + }, + ); + } catch (compensationErr) { + // removeEmailDomain never throws; this is paranoia for the log only. + console.error('[email] race compensation failed:', compensationErr); + } + return relayRace + ? 'another email domain is deploying the same relay worker — retry' + : 'another active email domain already uses this zone — remove it first'; +} + +/** + * Run one fallible statement of the serialized engine section behind a + * savepoint, so an expected constraint violation — the first-to-persist + * races the partial unique indexes exist to catch — aborts only that + * statement. The transaction stays committable, keeping the row and any + * grant rotations the engine already persisted through the row's + * serialized chain: without this a 23505 would poison the whole + * transaction, and the rollback would strand the rotated chain head (the + * only copy CF issued) with no database handle, breaking the race + * compensation's refresh-through-the-row. + */ +async function tryEmailDomainSavepoint( + tx: Tx, + name: string, + fn: () => Promise, +): Promise<{ ok: true; value: T } | { ok: false; err: unknown }> { + await tx.execute(sql.raw(`SAVEPOINT ${name}`)); + try { + const value = await fn(); + await tx.execute(sql.raw(`RELEASE SAVEPOINT ${name}`)); + return { ok: true, value }; + } catch (err) { + await tx.execute(sql.raw(`ROLLBACK TO SAVEPOINT ${name}`)); + return { ok: false, err }; + } +} + +/** + * Best-effort revocation of a POSTed OAuth grant the request is rejecting + * before persistence. The CLI mints a fresh user-wide grant (zone, email + * and workers scopes) per add attempt and drops its only copy on an error + * response, so a rejection without a revoke strands a live broad grant in + * the customer's account per retry. api_token credentials are out of + * scope: user-created out-of-band, and no token-delete primitive exists. + * + * `holder` is the conflicting row when one is in hand: a re-pasted grant + * equal to the row's stored credential must NOT be revoked — that would + * brick the live row (the same guard as the replace route's + * identical-grant skip). A holder whose credential can't be decrypted + * fails safe toward keeping. + */ +export async function revokeRejectedGrant( + context: Context, + refreshToken: string | undefined, + holder?: EmailDomain | null, +): Promise { + if (refreshToken === undefined) { + return; + } + // A holder on the platform's credential stored no grant; nothing to + // compare against, so the posted grant is revoked like any rejection. + if (holder && holder.credential_kind !== 'platform') { + let held: string | null = null; + try { + held = await decryptEmailDomainToken(context.env, holder.project_id, holder.domain, holder); + } catch (err) { + console.error('[email] rejected-grant holder decrypt failed; skipping revoke:', err); + return; + } + if (held === refreshToken) { + return; + } + } + try { + await revokeOauthGrant(refreshToken); + } catch (err) { + // Best-effort: the rejection stands either way, and this log line is + // the trace an operator gets. + console.error('[email] rejected-grant revoke failed:', err); + } +} + +/** + * Best-effort compensation for a checkpoint write that failed for any + * non-race reason (a DB error, or the row cascade-deleted by teardown + * mid-setup): the relay and its secret are deployed but the row carries + * no reference to them, so remove them from the captured checkpoint + * payload before the error propagates. The catch-all was never written + * (the checkpoint precedes it), so the conditional reset is a no-op. + * `rowGone` switches the refresh to the engine default, which acts on the + * held credential directly — the serialized chain needs the row. + */ +async function compensateCheckpointDeploy( + context: Context, + rowId: string, + race: { credentialKind: EmailDomain['credential_kind']; credential: string }, + checkpoint: { zone_id: string | null; account_id: string | null; relay_worker_name: string }, + opts: { rowGone: boolean }, +): Promise { + try { + await removeEmailDomain( + { + row: { + credential_kind: race.credentialKind, + account_id: checkpoint.account_id, + relay_worker_name: checkpoint.relay_worker_name, + zone_id: checkpoint.zone_id, + }, + credential: race.credential, + }, + opts.rowGone + ? {} + : { + // Same single-use refresh doctrine as the race compensation. + refreshGrant: () => refreshOauthGrantSerialized(context.get('db'), context.env, rowId), + }, + ); + } catch (compErr) { + console.error('[email] checkpoint-failure compensation failed:', compErr); + } +} + +/** + * The sync sequence — relay redeploy, secret rotation, row apply, mirror + * publication — under one transaction holding the project lock and then + * the credential chain's row lock, shared by the sync route and the + * replace path's healthy-row branch. The row lock must span the worker + * secret PUT, the row persist, and the mirror PUT: two overlapping syncs + * would otherwise interleave them (A-put, B-put, B-apply, A-apply) and + * leave the worker or the mirror holding a secret the other side no + * longer names — inbound authentication fails until the next sync. The + * row FOR UPDATE is the lease chain's own, held across the CF calls + * exactly like the refresh holds it across the token round-trip; + * concurrent sends on an oauth_grant domain wait out the sync, which is + * rare and manual. + * + * The row — credential included — is RE-READ inside the lock: a replace + * that committed while this request waited for the lock is synced as its + * new credential, never verified as the superseded one (whose expired or + * revoked verdict would otherwise be stamped onto the replacement). The + * mirror PUT runs inside the serialized section even though KV is not + * transactional: a commit failure after it leaves the mirror briefly + * ahead of the row, which the status GET's drift repair converges — the + * inverse (publishing outside the lock) would let an older sync's hash + * land last and break inbound auth until someone polls. + */ +export async function syncEmailDomainAndApply( + context: Context, + projectId: string, + rowId: string, + proxyOrigin: string, + /** Who asked; authorization runs on the locked row, where it cannot be raced. */ + actor: Readonly<{ managedBy: EmailDomain['managed_by']; projectSlug: string }>, +): Promise { + const db = context.get('db'); + let outcome: + | Readonly<{ + kind: 'ok'; + result: Awaited>; + applied: EmailDomain; + }> + | Readonly<{ kind: 'project_gone' }> + | Readonly<{ kind: 'project_deleting' }> + | Readonly<{ kind: 'refused'; response: Response }>; + try { + outcome = await db.transaction(async (tx) => { + // The project lock comes FIRST — teardown's own order + // (lockAndMarkDeleting, deleteProjectCascade and the status route's + // mirror-repair fence all go project → row) — so a sync serializes + // with teardown instead of racing it. Without this recheck a sync + // that passed the route's entry guard could redeploy the relay and + // republish the mirror AFTER teardown cleared them, and the cascade + // would then delete the row, leaving a recreated mirror, worker and + // catch-all with no database handle. Holding the project lock + // across the CF calls blocks a teardown for the duration — the same + // tradeoff the mirror-repair fence makes, and safe: when the sync + // commits first, teardown's later clear/remove/cascade sees + // everything it created. + const projectRows = await tx + .select({ status: projects.status }) + .from(projects) + .where(eq(projects.id, projectId)) + .for('update') + .limit(1); + const project = projectRows[0]; + if (!project) { + return { kind: 'project_gone' } as const; + } + if (project.status === 'deleting') { + return { kind: 'project_deleting' } as const; + } + // The row lock is the lease chain's own FOR UPDATE — refreshes, + // revokes, replaces and removals serialize on it exactly as before. + const locked = await tx + .select() + .from(emailDomains) + .where(eq(emailDomains.id, rowId)) + .for('update') + .limit(1); + const current = locked[0]; + if (!current) { + // Teardown's cascade can take the row while this request waited + // on the lock. + throw new Error('email_domains row vanished mid-sync'); + } + const refused = + refuseCrossOwnership(context, current, actor.managedBy, actor.projectSlug, 'sync') ?? + refusePlatformCredentialOffAccount(context, current); + if (refused) { + return { kind: 'refused', response: refused } as const; + } + const { result, applied } = await syncEmailDomainLocked( + context, + tx, + projectId, + current, + proxyOrigin, + ); + return { kind: 'ok', result, applied } as const; + }); + } catch (err) { + if (isUniqueViolation(err)) { + // NO compensation here — sync deployed in place on the row's OWN + // relay, so the 409 just refuses the live-status flip: a 'failed' + // row carrying its zone can collide with the winner when sync + // flips it back to a live status. + return context.json( + { + error: uniqueViolationConstraintName(err).includes('relay') + ? 'another email domain is deploying the same relay worker — retry' + : 'another active email domain already uses this zone — remove it first', + }, + 409, + ); + } + throw err; + } + + if (outcome.kind === 'project_gone') { + return context.json({ error: 'project not found' }, 404); + } + if (outcome.kind === 'project_deleting') { + return context.json({ error: 'Project is being deleted' }, 409); + } + if (outcome.kind === 'refused') { + return outcome.response; + } + return context.json( + { + ...serializeEmailDomain(outcome.applied, outcome.result.error), + ...(outcome.result.catch_all === undefined ? {} : { catch_all: outcome.result.catch_all }), + }, + 200, + ); +} + +/** + * A `platform` row whose recorded account is not the platform's must not be + * synced or re-run: that would send the runtime token to a customer account. + * Such a row can only come from a pasted-token registration later re-added + * on the platform credential, which the add path already refuses; this is + * the locked-row backstop. + */ +function refusePlatformCredentialOffAccount( + context: Context, + row: Pick, +): Response | null { + if (row.credential_kind !== 'platform') { + return null; + } + // Without the runtime token there is nothing to sync with; say so rather + // than let the credential seam's throw surface as a 500. + if (!context.env.CF_API_TOKEN) { + return context.json({ error: 'platform credential unavailable' }, 503); + } + if (platformAccountAllows(context, row)) { + return null; + } + return context.json( + { + error: `the zone for ${row.domain} is in Cloudflare account ${row.account_id}, not the platform's; re-add it with --token-stdin and a token for that account`, + }, + 409, + ); +} + +/** + * The sync sequence's locked core, shared by syncEmailDomainAndApply and + * the serialized engine section's healthy-row branch. Callers hold the + * project lock and the row's FOR UPDATE; `current` is the row as re-read + * under those locks — credential included, so a replace that committed + * while the caller waited is synced as its new credential. + */ +async function syncEmailDomainLocked( + context: Context, + tx: Tx, + projectId: string, + current: EmailDomain, + proxyOrigin: string, +): Promise<{ + result: Awaited>; + applied: EmailDomain; +}> { + const domain = current.domain; + const credential = await decryptEmailDomainToken(context.env, projectId, domain, current); + const result = await syncEmailDomain( + { + row: { + domain, + credential_kind: current.credential_kind, + account_id: current.account_id, + relay_worker_name: current.relay_worker_name, + zone_id: current.zone_id, + // Lets sync re-attempt a pending sending onboarding (a + // Workers Paid upgrade) and report the cleared step. + setup_remaining_steps: current.setup_state?.remaining_steps, + }, + credential, + proxyOrigin, + }, + { refreshGrant: () => refreshOauthGrantSerialized(tx, context.env, current.id) }, + ); + + let relaySecretSha256: string; + const relayPatch: { + relay_ciphertext?: string; + relay_iv?: string; + relay_key_version?: string; + } = {}; + if (result.new_relay_secret !== undefined) { + const relayEnc = await encryptEmailDomainRelaySecret( + context.env, + projectId, + domain, + result.new_relay_secret, + ); + relayPatch.relay_ciphertext = relayEnc.ciphertext; + relayPatch.relay_iv = relayEnc.iv; + relayPatch.relay_key_version = relayEnc.keyVersion; + relaySecretSha256 = await hashEmailRelaySecret(result.new_relay_secret); + } else { + // No rotation happened (failed before the secret PUT) — the + // mirror still moves with the new status, hashed from the + // existing secret. + relaySecretSha256 = await hashEmailRelaySecret( + await decryptEmailDomainRelaySecret(context.env, projectId, domain, current), + ); + } + // A successful sync does not clear a pending human step on its own: + // the engine cannot observe the routing-subdomain dashboard form, + // so remaining_steps survive — minus any the sync itself cleared + // (the sending-onboarding retry). The row stays 'pending' only + // while an INBOUND-BLOCKING step (routing_subdomain) remains; a + // sending_onboarding step rides along on an active row. + const keptSteps = current.setup_state?.remaining_steps?.filter( + (step) => !(result.cleared_steps ?? []).includes(step), + ); + const setupState: EmailDomainSetupState | null = + result.setup_state !== null + ? result.setup_state + : keptSteps && keptSteps.length > 0 + ? { remaining_steps: keptSteps } + : null; + const status: EmailDomain['status'] = + result.status === 'active' && setupState?.remaining_steps?.includes(ROUTING_SUBDOMAIN_STEP) + ? 'pending' + : result.status; + const appliedRows = await tx + .update(emailDomains) + .set({ + status, + relay_version: result.relay_version, + setup_state: setupState, + ...relayPatch, + updated_at: sql`now()`, + }) + .where(eq(emailDomains.id, current.id)) + .returning(); + const applied = appliedRows[0]; + if (!applied) { + throw new Error('email_domains row vanished mid-sync'); + } + // Mirror publication stays inside the serialized section: an + // overlapping sync publishes its own rotation behind this lock, so + // hashes land in the same order the secrets were written to the worker. + await writeEmailDomainMirror(context.env.ROUTING, applied, relaySecretSha256); + return { result, applied }; +} + +/** + * The serialized engine section shared by the add route (after its insert + * transaction) and the replace route (after its credential commit). Both + * locks are held across the engine's CF calls, the apply, and the mirror + * publication: the PROJECT row FOR UPDATE first — teardown's own order + * (lockAndMarkDeleting, deleteProjectCascade and the status route's + * mirror-repair fence all go project → row) — then the credential chain's + * row FOR UPDATE. The project fence gives a total order with teardown: + * either teardown marked first (this refuses) or this commits first and + * teardown's later cleanup sees everything the engine created — an + * unfenced setup could otherwise finish AFTER teardown's one-time + * enumerations ran, and the cascade would delete the row without its + * domain handle, orphaning the mirror, the relay/catch-all in the + * customer account, and a fresh OAuth grant. + * + * The row — credential and recorded progress included — is RE-READ under + * the lock, and the branch is decided there: a COMPLETE row takes the + * sync path (redeploy in place + relay-secret rotation); an incomplete + * one re-runs the full engine seeded from the locked row. The row lock + * serializes concurrent full-setup retries: without it two of them could + * upload DIFFERENT relay secrets to the same relay and then apply their + * rows/mirrors in the opposite order — the worker holding secret B while + * KV expects A, rejecting every inbound message — or strand competing + * relay names. + * + * The fallible row writes (deploy checkpoint, final apply) run behind + * savepoints (tryEmailDomainSavepoint) so their expected 23505s abort + * only the one statement: the transaction then COMMITS the row and the + * engine's grant rotations, and the loser compensation below works + * through the row's live chain head exactly as it did when every write + * autocommitted. Holding the project lock across the CF calls blocks a + * teardown for the duration — the same tradeoff sync and the + * mirror-repair fence make. + */ +export async function runEmailDomainEngineSerialized( + context: Context, + projectId: string, + rowId: string, + proxyOrigin: string, + opts: Readonly<{ + relaySecret: string; + relayEnc: { ciphertext: string; iv: string; keyVersion: string }; + requestedMode: unknown; + created: boolean; + }>, +): Promise { + const db = context.get('db'); + const { relaySecret, relayEnc, requestedMode, created } = opts; + // Captured BEFORE each checkpoint write: a 23505 throw still needs the + // progress payload for compensation below. + let checkpoint: { + zone_id: string | null; + account_id: string | null; + mode: 'apex' | 'subdomain' | null; + relay_worker_name: string; + } | null = null; + // Set when the engine's name-walk claimed a relay name on the row, so + // an engine throw can restore the row's locked pre-engine values — the + // claim commits with this transaction even when the checkpoint's + // savepoint rolls back, and a leaked entry would park the name while + // pointing the row at a worker the compensation may have deleted. + let relayNameClaimed = false; + type EngineOutcome = + | Readonly<{ + kind: 'ok_sync'; + result: Awaited>; + applied: EmailDomain; + }> + | Readonly<{ kind: 'ok_setup'; result: EmailDomainSetupResult; applied: EmailDomain }> + | Readonly<{ kind: 'project_gone' }> + | Readonly<{ kind: 'project_deleting' }> + | Readonly<{ kind: 'row_gone' }> + | Readonly<{ kind: 'refused'; response: Response }> + | Readonly<{ + kind: 'engine_failed'; + err: unknown; + race: { credentialKind: EmailDomain['credential_kind']; credential: string }; + }> + | Readonly<{ + kind: 'apply_failed'; + err: unknown; + result: EmailDomainSetupResult; + race: { credentialKind: EmailDomain['credential_kind']; credential: string }; + }>; + let outcome: EngineOutcome; + try { + outcome = await db.transaction(async (tx): Promise => { + const projectRows = await tx + .select({ status: projects.status }) + .from(projects) + .where(eq(projects.id, projectId)) + .for('update') + .limit(1); + const lockedProject = projectRows[0]; + if (!lockedProject) { + return { kind: 'project_gone' } as const; + } + if (lockedProject.status === 'deleting') { + return { kind: 'project_deleting' } as const; + } + // The row lock is the lease chain's own FOR UPDATE — refreshes, + // revokes, replaces and removals serialize on it exactly as before. + const locked = await tx + .select() + .from(emailDomains) + .where(eq(emailDomains.id, rowId)) + .for('update') + .limit(1); + const current = locked[0]; + if (!current) { + // An owner DELETE (or the admin revoke) took the row while this + // request waited on the lock; its cleanup owned the credential. + return { kind: 'row_gone' } as const; + } + + // Sync keeps a healthy row healthy; anything that never finished + // setup re-runs the full engine. A catch-all failure is the one + // 'failed' shape that still carries relay_worker_name/account_id — + // it must NOT take the sync path, which never writes the catch-all. + const setupIncomplete = + current.status === 'failed' || !current.relay_worker_name || !current.account_id; + if (!setupIncomplete) { + const refused = refusePlatformCredentialOffAccount(context, current); + if (refused) { + return { kind: 'refused', response: refused } as const; + } + const { result, applied } = await syncEmailDomainLocked( + context, + tx, + projectId, + current, + proxyOrigin, + ); + return { kind: 'ok_sync', result, applied } as const; + } + + // Setup never finished — re-run the full engine with a fresh relay + // secret, carrying the row's recorded progress so completed steps + // are idempotent (mode skips the MX check; a relay name redeploys in + // place; 'already done' 4xx is believed only on a confirming + // read-back). + const domain = current.domain; + const credential = await decryptEmailDomainToken(context.env, projectId, domain, current); + const race = { credentialKind: current.credential_kind, credential }; + let result: EmailDomainSetupResult | null = null; + let engineError: unknown = null; + try { + result = await setupEmailDomain( + { + row: { + domain, + credential_kind: current.credential_kind, + ...(current.mode === null ? {} : { mode: current.mode }), + ...(current.relay_worker_name === null + ? {} + : { relay_worker_name: current.relay_worker_name }), + ...(current.zone_id === null ? {} : { zone_id: current.zone_id }), + ...(current.account_id === null ? {} : { account_id: current.account_id }), + }, + credential, + relaySecret, + proxyOrigin, + // The platform's own token must never be pointed at a customer + // zone: a platform row may only register a zone in this account. + ...(current.credential_kind === 'platform' + ? { requireAccountId: context.env.CF_ACCOUNT_ID } + : {}), + }, + { + refreshGrant: () => refreshOauthGrantSerialized(tx, context.env, current.id), + claimRelayName: async (accountId, candidate) => { + // Reserve (account_id, candidate) on THIS row BEFORE any CF + // upload — first-to-claim turns the relay race into a walk + // skip: a concurrent setup's claim of the same name blocks + // on this uncommitted UPDATE, then 23505s past the winner's + // commit ('taken' → the walk advances) or proceeds past its + // rollback. The savepoint keeps a contested claim from + // poisoning the transaction (same machinery as the + // checkpoint). + const claim = await tryEmailDomainSavepoint(tx, 'void_email_relay_claim', () => + tx + .update(emailDomains) + .set({ account_id: accountId, relay_worker_name: candidate }) + .where(eq(emailDomains.id, current!.id)), + ); + if (claim.ok) { + relayNameClaimed = true; + return 'claimed' as const; + } + if (isUniqueViolation(claim.err)) { + return 'taken' as const; + } + throw claim.err; + }, + persistProgress: async (progress) => { + // Capture BEFORE the write: a 23505 throw still needs the + // checkpoint for compensation below. The savepoint keeps the + // transaction committable (see tryEmailDomainSavepoint). + checkpoint = progress; + const write = await tryEmailDomainSavepoint(tx, 'void_email_deploy_checkpoint', () => + applyEmailDomainDeployCheckpoint(tx, current!.id, progress), + ); + if (!write.ok) { + throw write.err; + } + }, + }, + ); + } catch (err) { + // The savepoint already rolled the failed statement back, so the + // transaction can still commit the row and the engine's grant + // rotations — the compensation outside then refreshes through the + // row's live chain head. + engineError = err; + } + if (result === null) { + // The engine threw AFTER a successful claim (the checkpoint's + // 23505 or a non-race failure): restore the row's locked + // pre-engine values so the claim doesn't outlive the setup it + // belonged to. Before the claim existed, the checkpoint's + // savepoint rollback left the row exactly this way. + if (relayNameClaimed) { + await tx + .update(emailDomains) + .set({ + account_id: current.account_id, + relay_worker_name: current.relay_worker_name, + }) + .where(eq(emailDomains.id, current.id)); + } + return { kind: 'engine_failed', err: engineError, race } as const; + } + const applied = await tryEmailDomainSavepoint(tx, 'void_email_apply', () => + applyEmailDomainSetupResult(tx, current.id, result, relayEnc), + ); + if (!applied.ok) { + return { kind: 'apply_failed', err: applied.err, result, race } as const; + } + // Mirror AFTER the row is final, and inside the serialized section: + // an overlapping retry publishes its own setup behind this lock, so + // hashes land in the same order the secrets were written to the + // worker. + await writeEmailDomainMirror( + context.env.ROUTING, + applied.value, + await hashEmailRelaySecret(relaySecret), + ); + return { kind: 'ok_setup', result, applied: applied.value } as const; + }); + } catch (err) { + if (isUniqueViolation(err)) { + // NO compensation here — the sync branch deployed in place on the + // row's OWN relay, so the 409 just refuses the live-status flip: a + // 'failed' row carrying its zone can collide with the winner when + // sync flips it back to a live status. + return context.json( + { + error: uniqueViolationConstraintName(err).includes('relay') + ? 'another email domain is deploying the same relay worker — retry' + : 'another active email domain already uses this zone — remove it first', + }, + 409, + ); + } + throw err; + } + + if (outcome.kind === 'project_gone') { + return context.json({ error: 'project not found' }, 404); + } + if (outcome.kind === 'project_deleting') { + return context.json({ error: 'Project is being deleted' }, 409); + } + if (outcome.kind === 'row_gone') { + return context.json({ error: 'email domain not found' }, 404); + } + if (outcome.kind === 'refused') { + return outcome.response; + } + if (outcome.kind === 'engine_failed') { + const err = outcome.err; + if (isUniqueViolation(err) && checkpoint !== null) { + const conflict = await compensateEmailDomainRace( + context, + rowId, + outcome.race, + checkpoint, + err, + ); + return context.json({ error: conflict }, 409); + } + // Any other checkpoint failure leaves the deployed relay unreferenced + // (the savepoint rolled the UPDATE back): compensate from the captured + // checkpoint before propagating. + if (checkpoint !== null) { + await compensateCheckpointDeploy(context, rowId, outcome.race, checkpoint, { + rowGone: err instanceof EmailDomainRowGoneError, + }); + } + if (err instanceof EmailDomainRowGoneError) { + return context.json({ error: 'project is being deleted' }, 409); + } + throw err; + } + if (outcome.kind === 'apply_failed') { + const { err, result } = outcome; + if (isUniqueViolation(err)) { + // The concurrent-setup backstop (the schema's partial unique + // indexes): two setups racing one zone both pass the engine's + // CF-side reads, so the loser is caught here — a 409, never a + // falsely-active second row. (A relay-NAME 23505 is no longer + // reachable from a fresh setup — the pre-upload claim absorbs it — + // but the mapping stays as a safety net.) The row stays in its + // pre-apply state; the loser's CF-side work is compensated + // best-effort inside. + const conflict = await compensateEmailDomainRace( + context, + rowId, + outcome.race, + { + zone_id: result.zone_id, + account_id: result.account_id, + relay_worker_name: result.relay_worker_name, + }, + err, + ); + return context.json({ error: conflict }, 409); + } + // A NON-unique apply failure after the engine deployed CF-side: the + // savepoint makes the apply decisively uncommitted, so the + // possibly-deployed relay is compensated best-effort — no orphan + // worker for a zone the row never recorded. Nothing to clean when + // setup failed before the deploy. + if (result.relay_worker_name !== null) { + try { + // Not a name race — the loser owns its relay, so the compensation + // runs with the result's OWN account_id (not nulled). + await removeEmailDomain( + { + row: { + credential_kind: outcome.race.credentialKind, + account_id: result.account_id, + relay_worker_name: result.relay_worker_name, + zone_id: result.zone_id, + }, + credential: outcome.race.credential, + }, + { + // Same single-use refresh doctrine as the race compensation. + refreshGrant: () => refreshOauthGrantSerialized(context.get('db'), context.env, rowId), + }, + ); + } catch (compErr) { + console.error('[email] orphan compensation failed:', compErr); + } + } + throw err; + } + if (outcome.kind === 'ok_sync') { + return context.json( + { + ...serializeEmailDomain(outcome.applied, outcome.result.error), + ...(outcome.result.catch_all === undefined ? {} : { catch_all: outcome.result.catch_all }), + }, + 200, + ); + } + const { result, applied: row } = outcome; + if (requestedMode !== undefined && row.mode !== null && requestedMode !== row.mode) { + return context.json( + { + error: `requested mode ${String(requestedMode)} but ${row.domain} is ${row.mode} on its zone`, + domain: serializeEmailDomain(row, result.error), + }, + 409, + ); + } + return context.json(serializeEmailDomain(row, result.error), created ? 201 : 200); +} + +/** + * POST /domains on an existing row — "add again replaces the token, rules + * stay" (design doc, branch table). The new credential is stored first + * because the oauth_grant engine path refreshes THROUGH the row. Healthy + * rows (active / pending / credential renewal) take the sync path — + * redeploy in place + rotate the relay secret. A row that never finished + * setup re-runs the FULL engine with a fresh relay secret: status + * 'failed', or the relay/account fields missing. The engine is + * re-runnable against the row's recorded progress, so a catch-all + * failure gets its rule written rather than just re-deploying the relay. + * Both branches run inside runEmailDomainEngineSerialized, decided on the + * LOCKED row. + */ +async function replaceEmailDomainCredential( + context: Context, + projectId: string, + existing: EmailDomain, + credentialKind: EmailDomain['credential_kind'], + /** `null` only for the platform's own credential, which the row does not store. */ + credential: string | null, + proxyOrigin: string, + requestedMode: unknown, + attemptId: string | undefined, + managedBy: EmailDomain['managed_by'], + projectSlug: string, +): Promise { + const db = context.get('db'); + const domain = existing.domain; + const tokenEnc = + credential === null + ? null + : await encryptEmailDomainToken(context.env, projectId, domain, credential); + // Capture the old grant's CURRENT chain head and commit the replacement + // under one row lock: an interleaved lease refresh between an unlocked + // capture and the commit would rotate the old grant, the commit would + // overwrite the rotated head it persisted, and the revoke below would + // submit the already-consumed token — CF answers 200 either way, so the + // live chain head would be orphaned with no database handle. After the + // commit the row holds the NEW credential, so no old-chain rotation can + // follow. + let oldGrantRefreshToken: string | null = null; + type SwapOutcome = + | 'swapped' + | 'project_gone' + | 'project_deleting' + | 'row_gone' + | Readonly<{ refused: Response }>; + const swap: SwapOutcome = await db.transaction(async (tx): Promise => { + // The PROJECT row lock comes first — teardown's own order — with the + // 'deleting' verdict re-read under it: a replace admitted on the + // route's unlocked entry read could otherwise commit a fresh grant + // AFTER teardown's one-time cleanup enumeration ran, and the cascade + // would then delete the row with the new grant's only handle. + const projectRows = await tx + .select({ status: projects.status }) + .from(projects) + .where(eq(projects.id, projectId)) + .for('update') + .limit(1); + const lockedProject = projectRows[0]; + if (!lockedProject) { + return 'project_gone'; + } + if (lockedProject.status === 'deleting') { + return 'project_deleting'; + } + const locked = await tx + .select({ + credential_kind: emailDomains.credential_kind, + managed_by: emailDomains.managed_by, + account_id: emailDomains.account_id, + token_ciphertext: emailDomains.token_ciphertext, + token_iv: emailDomains.token_iv, + token_key_version: emailDomains.token_key_version, + }) + .from(emailDomains) + .where(eq(emailDomains.id, existing.id)) + .for('update') + .limit(1); + const current = locked[0]; + if (!current) { + return 'row_gone'; + } + // The unlocked checks in addEmailDomainForProject again, on the locked + // row: the other side may have committed its own add in between. + const refused = refuseCrossOwnership(context, { ...current, domain }, managedBy, projectSlug); + if (refused) { + return { refused }; + } + if (credentialKind === 'platform' && !platformAccountAllows(context, current)) { + return { + refused: context.json( + { + error: `the zone for ${domain} is in Cloudflare account ${current.account_id}, not the platform's; re-add it with --token-stdin and a token for that account`, + }, + 409, + ), + }; + } + if (current.credential_kind === 'oauth_grant') { + try { + oldGrantRefreshToken = await decryptEmailDomainToken(context.env, projectId, domain, { + credential_kind: current.credential_kind, + token_ciphertext: current.token_ciphertext, + token_iv: current.token_iv, + token_key_version: current.token_key_version, + }); + } catch (err) { + console.error('[email] old grant decrypt failed; skipping revoke:', err); + } + } + await tx + .update(emailDomains) + .set({ + credential_kind: credentialKind, + token_ciphertext: tokenEnc?.ciphertext ?? null, + token_iv: tokenEnc?.iv ?? null, + token_key_version: tokenEnc?.keyVersion ?? null, + managed_by: managedBy, + // Re-derived by the engine for api_token; grants carry neither. + token_id: null, + token_expires_on: null, + attempt_id: attemptId ?? null, + updated_at: sql`now()`, + }) + .where(eq(emailDomains.id, existing.id)); + return 'swapped'; + }); + if (swap !== 'swapped') { + // Nothing was persisted — the posted grant has no handle. Against a + // deleting project the holder guard spares a re-pasted grant the row + // still stores: teardown's cleanup revokes that one itself. + await revokeRejectedGrant( + context, + credentialKind === 'oauth_grant' && credential !== null ? credential : undefined, + swap === 'project_deleting' ? existing : null, + ); + if (typeof swap === 'object') { + return swap.refused; + } + return swap === 'project_gone' + ? context.json({ error: 'project not found' }, 404) + : swap === 'project_deleting' + ? context.json({ error: 'Project is being deleted' }, 409) + : context.json({ error: 'email domain not found' }, 404); + } + + // Revoke AFTER the commit so a failed replace never strands the old + // grant; a re-pasted identical grant is skipped — revoking it would + // brick the just-stored credential. A revoke failure (network/non-2xx — + // the helper throws on both) is logged, never fatal: the new credential + // is already committed. + if (oldGrantRefreshToken !== null && oldGrantRefreshToken !== credential) { + try { + await revokeOauthGrant(oldGrantRefreshToken); + } catch (err) { + console.error('[email] old grant revoke failed after credential replace:', err); + } + } + + // The branch decision — sync a healthy row, or re-run the full engine + // on one that never finished setup — is made inside the serialized + // section on the LOCKED row: two concurrent retries would otherwise run + // setupEmailDomain outside any lock, upload different relay secrets to + // the same relay, and apply their rows/mirrors in the opposite order + // (the worker holding secret B while KV expects A). + const relaySecret = generateEmailRelaySecret(); + const relayEnc = await encryptEmailDomainRelaySecret(context.env, projectId, domain, relaySecret); + return await runEmailDomainEngineSerialized(context, projectId, existing.id, proxyOrigin, { + relaySecret, + relayEnc, + requestedMode, + created: false, + }); +} + +/** True when the stored mirror already matches the row + hash exactly. */ +async function emailDomainMirrorMatches( + kv: KVNamespace, + row: EmailDomain, + relaySecretSha256: string, +): Promise { + const raw = await kv.get(emailDomainMirrorKey(row.domain)); + if (raw === null) { + return false; + } + try { + const mirror = JSON.parse(raw) as Partial; + return ( + mirror.projectId === row.project_id && + mirror.status === row.status && + mirror.relaySecretSha256 === relaySecretSha256 && + mirror.relayWorkerName === row.relay_worker_name && + mirror.accountId === row.account_id && + mirror.zoneId === row.zone_id && + mirror.mode === row.mode + ); + } catch { + // Unparseable mirror — rewrite. + return false; + } +} + +/** + * The status GET's mirror repair — the same read-compare-write self-heal + * shape as the allowlist listing (`projectAllowlistKvDiffers`): rewrite + * only on a real difference, so polling never amplifies into KV writes. + * + * The write is fenced against project teardown: the mirror is + * authorization material, and once the cascade takes the row nothing + * sweeps the key. Holding `SELECT … FOR UPDATE` on the PROJECT row across + * the KV put gives a total order with teardown's 'deleting' flip and its + * cascade (both take the same lock): either they committed first — the + * project reads deleting or gone, or the domain row is cascaded away — + * and the write is skipped, or this write lands first and teardown's own + * mirror clear removes it. Without the fence, a status poll admitted + * before teardown could recreate an active mirror after teardown cleared + * it, re-authorizing a possibly-stranded relay with no row left to retry + * from. + * + * The payload is derived from the DOMAIN row re-read under FOR UPDATE — + * in project→domain order, the same order sync takes both — never from + * the caller's unlocked snapshot. A sync that rotated the relay secret, + * or a lease that persisted token_revoked, while this poll was between + * its entry read and this transaction would otherwise have its committed + * state overwritten by the stale snapshot: the mirror would go back to + * the pre-rotation hash (inbound rejects every message) or back to + * active (re-authorizing a revoked credential). The drift check also + * re-runs against the locked row — the mirror may have converged while + * this transaction waited on the locks, and the write stays repair-only. + */ +export async function repairEmailDomainMirrorIfDrifted( + db: Db, + env: Pick< + ApiEnv['Bindings'], + 'ROUTING' | 'PROJECT_SECRET_KEYS_JSON' | 'PROJECT_SECRET_ACTIVE_KEY_VERSION' + >, + row: EmailDomain, +): Promise { + const relaySecretSha256 = await hashEmailRelaySecret( + await decryptEmailDomainRelaySecret(env, row.project_id, row.domain, row), + ); + if (await emailDomainMirrorMatches(env.ROUTING, row, relaySecretSha256)) { + return; + } + await db.transaction(async (tx) => { + const lockedProject = await tx + .select({ status: projects.status }) + .from(projects) + .where(eq(projects.id, row.project_id)) + .for('update') + .limit(1); + if (!lockedProject[0] || lockedProject[0].status === 'deleting') { + return; + } + const locked = await tx + .select() + .from(emailDomains) + .where(eq(emailDomains.id, row.id)) + .for('update') + .limit(1); + const current = locked[0]; + if (!current) { + return; + } + const currentSha256 = await hashEmailRelaySecret( + await decryptEmailDomainRelaySecret(env, current.project_id, current.domain, current), + ); + if (await emailDomainMirrorMatches(env.ROUTING, current, currentSha256)) { + return; + } + await writeEmailDomainMirror(env.ROUTING, current, currentSha256); + }); +} + +// --------------------------------------------------------------------------- +// The four operations, shared by the owner routes and the operator surfaces. +// Callers have already resolved and guarded the project (ownership for +// owners; any project for an administrator). Every rejection of an add that +// carried an OAuth grant revokes it first — the CLI mints a fresh user-wide +// grant per attempt and drops its only copy on any error response. +// --------------------------------------------------------------------------- + +export async function addEmailDomainForProject( + context: Context, + project: Project, + input: EmailDomainAddInput, +): Promise { + const projectId = project.id; + const postedRefreshToken = + typeof input.refreshToken === 'string' && input.refreshToken.length > 0 + ? input.refreshToken + : undefined; + // Same teardown guard the destinations POST uses — after the parse, so it + // too can revoke the stranded grant. + if (project.status === 'deleting') { + await revokeRejectedGrant(context, postedRefreshToken); + return context.json({ error: 'Project is being deleted' }, 409); + } + // Length is checked on the RAW string before any normalization — same + // reason as the destinations route's address cap: the 400 costs nothing, + // the normalization and regex never run on attacker-sized input. + if (typeof input.domain !== 'string' || input.domain.length > MAX_DOMAIN_LENGTH) { + await revokeRejectedGrant(context, postedRefreshToken); + return context.json({ error: 'invalid domain' }, 400); + } + const domain = normalizeEmailDomain(input.domain); + if (!isValidEmailDomain(domain)) { + await revokeRejectedGrant(context, postedRefreshToken); + return context.json({ error: 'invalid domain' }, 400); + } + if (input.mode !== undefined && input.mode !== 'apex' && input.mode !== 'subdomain') { + await revokeRejectedGrant(context, postedRefreshToken); + return context.json({ error: 'invalid mode' }, 400); + } + // Optional attempt identity from the CLI: when present it is stamped on + // the row so a CLI that lost this POST's answer can recognize its own + // commit. + if ( + input.attemptId !== undefined && + (typeof input.attemptId !== 'string' || + input.attemptId.length < 1 || + input.attemptId.length > 128) + ) { + await revokeRejectedGrant(context, postedRefreshToken); + return context.json({ error: 'invalid attemptId' }, 400); + } + const attemptId = input.attemptId as string | undefined; + const refreshToken = postedRefreshToken; + const apiToken = + typeof input.apiToken === 'string' && input.apiToken.length > 0 ? input.apiToken : undefined; + if (refreshToken !== undefined && apiToken !== undefined) { + await revokeRejectedGrant(context, postedRefreshToken); + return context.json({ error: 'exactly one of refreshToken or apiToken is required' }, 400); + } + // An owner must bring a credential. An administrator who brings none + // registers the zone on the platform's own runtime token — valid only for + // a zone in the platform's account, which the engine enforces. + let credentialKind: EmailDomain['credential_kind']; + let credential: string | null; + if (refreshToken !== undefined) { + credentialKind = 'oauth_grant'; + credential = refreshToken; + } else if (apiToken !== undefined) { + credentialKind = 'api_token'; + credential = apiToken; + } else if (input.managedBy === 'admin') { + if (!context.env.CF_API_TOKEN) { + return context.json({ error: 'platform credential unavailable' }, 503); + } + credentialKind = 'platform'; + credential = null; + } else { + return context.json({ error: 'exactly one of refreshToken or apiToken is required' }, 400); + } + if (credential !== null && credential.length > MAX_CREDENTIAL_LENGTH) { + await revokeRejectedGrant(context, postedRefreshToken); + return context.json({ error: 'invalid credential' }, 400); + } + const db = context.get('db'); + const proxyOrigin = proxyOriginForEmail(context.env); + + const existing = await getEmailDomainRow(db, projectId, domain); + if (existing) { + // Neither side re-runs the other's row. An administrator's row runs on + // the platform's credential, which a replace would swap for the owner's + // grant; an owner's row holds the owner's credential, which an + // administrator's re-add would revoke while flipping the row to admin. + // Both are re-checked under the row lock in replaceEmailDomainCredential. + const refused = refuseCrossOwnership(context, existing, input.managedBy, project.slug); + if (refused) { + await revokeRejectedGrant(context, postedRefreshToken); + return refused; + } + // The platform's own token must never be pointed at a customer zone: a + // row that learned a foreign account (registered with a pasted token) + // cannot be re-run on the platform credential, and refusing here keeps + // the stored token in place. + if (credentialKind === 'platform' && !platformAccountAllows(context, existing)) { + return context.json( + { + error: `the zone for ${domain} is in Cloudflare account ${existing.account_id}, not the platform's; re-add it with --token-stdin and a token for that account`, + }, + 409, + ); + } + // "add again replaces the token, rules stay" (design doc, branch + // table) — the credential renewal path for token_expired / + // token_revoked rows, and the retry path for failed ones. + return await replaceEmailDomainCredential( + context, + projectId, + existing, + credentialKind, + credential, + proxyOrigin, + input.mode, + attemptId, + input.managedBy, + project.slug, + ); + } + + // A domain belongs to exactly one project: the proxy proves a relay + // secret against the single `email-domain:` mirror key, and two + // projects registering the same domain would flip-flop that key and + // misroute inbound mail. Reject before any credential work. + const claimed = await getEmailDomainRowByDomain(db, domain); + if (claimed && claimed.project_id !== projectId) { + await revokeRejectedGrant(context, postedRefreshToken, claimed); + return context.json({ error: 'domain is registered to another project' }, 409); + } + + // New domain. Persist the credential + a fresh relay secret FIRST: the + // oauth_grant setup refreshes THROUGH the row (the serialized dep locks + // and re-reads it), so the grant must be stored before the engine runs. + const relaySecret = generateEmailRelaySecret(); + const tokenEnc = + credential === null + ? null + : await encryptEmailDomainToken(context.env, projectId, domain, credential); + const relayEnc = await encryptEmailDomainRelaySecret(context.env, projectId, domain, relaySecret); + const id = generateId('edom'); + + // The insert runs under the PROJECT row's FOR UPDATE — teardown's own + // first lock (lockAndMarkDeleting, deleteProjectCascade) — with the + // 'deleting' verdict re-read inside it: an add admitted on the unlocked + // entry snapshot could otherwise insert and finish setup AFTER + // teardown's one-time cleanup enumerations ran, and the later cascade + // would delete the row without its domain handle — the mirror, the + // relay/catch-all in the customer account, and a fresh OAuth grant all + // orphaned. The engine then runs in the follow-up serialized section + // (runEmailDomainEngineSerialized), which re-takes this same lock, so + // teardown either marks first (both transactions refuse) or runs + // strictly after the committed setup and cleans everything it created. + type InsertOutcome = 'inserted' | 'project_gone' | 'project_deleting' | 'admin_managed'; + let outcome: InsertOutcome; + try { + outcome = await db.transaction(async (tx): Promise => { + const projectRows = await tx + .select({ status: projects.status }) + .from(projects) + .where(eq(projects.id, projectId)) + .for('update') + .limit(1); + const lockedProject = projectRows[0]; + if (!lockedProject) { + return 'project_gone'; + } + if (lockedProject.status === 'deleting') { + return 'project_deleting'; + } + // Authorized inside the locked section like every other ownership rule: + // while administrators manage domains an owner registers no NEW row. A + // row the owner registered before the switch takes the replace path + // above and never reaches this insert. + if (input.managedBy === 'owner' && (await getEmailDomainManagement(tx)) === 'admin') { + return 'admin_managed'; + } + await tx.insert(emailDomains).values({ + id, + project_id: projectId, + domain, + credential_kind: credentialKind, + managed_by: input.managedBy, + attempt_id: attemptId ?? null, + token_ciphertext: tokenEnc?.ciphertext ?? null, + token_iv: tokenEnc?.iv ?? null, + token_key_version: tokenEnc?.keyVersion ?? null, + relay_ciphertext: relayEnc.ciphertext, + relay_iv: relayEnc.iv, + relay_key_version: relayEnc.keyVersion, + }); + return 'inserted'; + }); + } catch (err) { + if (isUniqueViolation(err)) { + // Lost an add/add race on the domain-wide unique index. The winner + // decides the error: another project claimed the domain, or this + // project registered it concurrently. The transaction rolled back, + // so the posted grant was never persisted — revoke it (the + // decrypt-compare guard spares a re-pasted grant the winner stored). + const winner = await getEmailDomainRowByDomain(db, domain); + await revokeRejectedGrant(context, postedRefreshToken, winner); + if (winner && winner.project_id !== projectId) { + return context.json({ error: 'domain is registered to another project' }, 409); + } + return context.json({ error: 'domain concurrently modified, try again' }, 409); + } + throw err; + } + if (outcome !== 'inserted') { + // Nothing was persisted — the posted grant has no handle. + await revokeRejectedGrant(context, postedRefreshToken); + return outcome === 'project_gone' + ? context.json({ error: 'project not found' }, 404) + : outcome === 'admin_managed' + ? context.json( + { + error: 'email domains on this platform are managed by its administrator', + adminCommand: adminEmailDomainCommand('add', domain, project.slug), + }, + 403, + ) + : context.json({ error: 'Project is being deleted' }, 409); + } + + return await runEmailDomainEngineSerialized(context, projectId, id, proxyOrigin, { + relaySecret, + relayEnc, + requestedMode: input.mode, + created: true, + }); +} + +export async function getEmailDomainStatusForProject( + context: Context, + project: Project, + rawDomain: string, +): Promise { + const projectId = project.id; + const domain = normalizeEmailDomain(rawDomain); + const db = context.get('db'); + const row = await getEmailDomainRow(db, projectId, domain); + if (!row) { + return context.json({ error: 'email domain not found' }, 404); + } + + // Re-probe with the decrypted credential. Probe failures never hide the + // stored view — the row answers with credential_status 'probe_failed', + // the same non-fatal shape as the destinations listing's CF refresh. + let credentialStatus: 'active' | 'expired' | 'revoked' | 'probe_failed'; + let probeClient: CfCustomerClient | null = null; + let tokenPatch: { token_id: string | null; token_expires_on: string | null } | null = null; + if (row.credential_kind === 'platform') { + // The runtime token is not a user token, so /user/tokens/verify does + // not describe it; the probe is a zone-scoped read the token needs for + // every later step. A refusal reads probe_failed — never token_revoked + // or token_expired, which describe a stored credential's lifecycle. + try { + const client = createCfCustomerClient( + await decryptEmailDomainToken(context.env, projectId, row.domain, row), + ); + if (row.zone_id !== null) { + await client.getRoutingSettings(row.zone_id); + } else if ((await findZoneForEmailDomain(client, row.domain)) === null) { + throw new Error(`the platform credential cannot see a zone for ${row.domain}`); + } + credentialStatus = 'active'; + probeClient = client; + } catch (err) { + console.error('[email] platform credential probe failed:', err); + credentialStatus = 'probe_failed'; + } + } else if (row.credential_kind === 'api_token') { + try { + const credential = await decryptEmailDomainToken(context.env, projectId, row.domain, row); + const client = createCfCustomerClient(credential); + const verification = await client.verifyToken(); + if (verification.status === 'active') { + credentialStatus = 'active'; + probeClient = client; + tokenPatch = { + token_id: verification.id, + token_expires_on: verification.expires_on ?? null, + }; + } else if (verification.status === 'expired') { + credentialStatus = 'expired'; + } else { + credentialStatus = 'revoked'; + } + } catch (err) { + console.error('[email] domain credential probe failed:', err); + credentialStatus = 'probe_failed'; + } + } else { + try { + // The ONLY probe a grant supports is one refresh round-trip. The + // rotation is persisted under the row lock inside; the access token + // rides the drift probe below and is then dropped. + const tokens = await refreshOauthGrantSerialized(db, context.env, row.id); + credentialStatus = 'active'; + probeClient = createCfCustomerClient(tokens.accessToken); + } catch (err) { + if (err instanceof EmailDomainCredentialChangedError) { + // A replace committed mid-probe — the probe is void, nothing + // was written, and the fresh re-read below answers for the + // replacement credential. + credentialStatus = 'probe_failed'; + } else if (err instanceof OAuthGrantError && err.code === 'invalid_grant') { + // The row was already marked token_revoked under the lock. + credentialStatus = 'revoked'; + } else { + console.error('[email] domain grant probe failed:', err); + credentialStatus = 'probe_failed'; + } + } + } + + // Persist what the probe learned. A dead credential flips the row (and, + // via the mirror repair below, the proxy's view) to token_expired / + // token_revoked; a healthy api_token refreshes token_id/expires_on. + // Probes never RESURRECT a row — re-running add is the renewal path. + const patch: { + status?: EmailDomain['status']; + token_id?: string | null; + token_expires_on?: string | null; + } = {}; + if (credentialStatus === 'expired' && row.status !== 'token_expired') { + patch.status = 'token_expired'; + } else if (credentialStatus === 'revoked' && row.status !== 'token_revoked') { + patch.status = 'token_revoked'; + } + if ( + tokenPatch && + (tokenPatch.token_id !== row.token_id || tokenPatch.token_expires_on !== row.token_expires_on) + ) { + patch.token_id = tokenPatch.token_id; + patch.token_expires_on = tokenPatch.token_expires_on; + } + if (Object.keys(patch).length > 0) { + // Fenced on the probed credential's identity: a replace committing + // mid-probe (renewing an expired token is the case that bites) must + // not have the OLD credential's verdict — status flip or token + // metadata — stamped on the NEW one. A fenced-out patch voids the + // probe: the fresh re-read answers, and the next poll re-probes. + const applied = await db + .update(emailDomains) + .set({ ...patch, updated_at: sql`now()` }) + .where( + and( + eq(emailDomains.id, row.id), + eq(emailDomains.credential_kind, row.credential_kind), + // A platform row stores no ciphertext; `= NULL` would never match. + row.token_ciphertext === null + ? isNull(emailDomains.token_ciphertext) + : eq(emailDomains.token_ciphertext, row.token_ciphertext), + ), + ) + .returning({ id: emailDomains.id }); + if (applied.length === 0) { + credentialStatus = 'probe_failed'; + probeClient = null; + } + } + + let fresh = await getEmailDomainRowById(db, row.id); + if (!fresh) { + // Project teardown's cascade can take the row mid-request. + return context.json({ error: 'email domain not found' }, 404); + } + + // Self-clear recorded remaining steps on observable evidence. The + // routing-subdomain step's effect is public MX records naming + // Cloudflare's inbound exchanges; the sending-onboarding step's is + // the customer-credential read-back showing the subdomain onboarded + // (a Workers Paid upgrade plus a sync, which re-attempts the POST). + // No evidence — the probe failed or the step is genuinely undone — + // leaves the row for the next poll. The row flips pending → active + // only once no INBOUND-BLOCKING step (routing_subdomain) remains; a + // sending_onboarding step rides along on an active row. Suppressed + // while a removal failure is pending — the same doctrine as the + // mirror repair below: the row is mid-removal, and neither its + // status nor its setup state is this route's to rewrite. + const probedSetupState = fresh.setup_state; + const pendingSteps = probedSetupState?.remaining_steps; + if ( + probedSetupState && + probedSetupState.failed_step !== 'remove' && + pendingSteps && + pendingSteps.length > 0 + ) { + const cleared: Array = []; + if (pendingSteps.includes(ROUTING_SUBDOMAIN_STEP) && (await domainHasRoutingMx(fresh.domain))) { + cleared.push(ROUTING_SUBDOMAIN_STEP); + } + if ( + pendingSteps.includes(SENDING_ONBOARDING_STEP) && + probeClient !== null && + fresh.zone_id !== null && + (await sendingOnboardedReadback(probeClient, fresh.zone_id, fresh.domain)) + ) { + cleared.push(SENDING_ONBOARDING_STEP); + } + if (cleared.length > 0) { + const keptSteps = pendingSteps.filter((step) => !cleared.includes(step)); + // Fenced on the probed state: a revocation (or a removal-failure + // marker) landing while the evidence checks were in flight must + // not be overwritten back to a live state — the subsequent + // mirror repair would reauthorize inbound against it. A + // fenced-out update voids the self-clear: the re-read answers + // this poll, and the next poll re-proves the evidence. + await db + .update(emailDomains) + .set({ + ...(fresh.status === 'pending' && !keptSteps.includes(ROUTING_SUBDOMAIN_STEP) + ? { status: 'active' as const } + : {}), + setup_state: keptSteps.length > 0 ? { remaining_steps: keptSteps } : null, + updated_at: sql`now()`, + }) + .where( + and( + eq(emailDomains.id, fresh.id), + eq(emailDomains.status, fresh.status), + sql`${emailDomains.setup_state} IS NOT DISTINCT FROM ${JSON.stringify(probedSetupState)}::jsonb`, + ), + ); + fresh = await getEmailDomainRowById(db, row.id); + if (!fresh) { + return context.json({ error: 'email domain not found' }, 404); + } + } + } + + // Relay drift: the version the row recorded vs the embedded + // RELAY_VERSION, plus an existence probe — the customer can edit or + // delete the relay in their own account, and status reports drift + // rather than fighting it. + let relayMissing: boolean | null = null; + if (probeClient && fresh.account_id && fresh.relay_worker_name) { + try { + relayMissing = !(await probeClient.relayScriptExists( + fresh.account_id, + fresh.relay_worker_name, + )); + } catch (err) { + console.error('[email] relay existence probe failed:', err); + } + } + const relayDrift = fresh.relay_version !== RELAY_VERSION || relayMissing === true; + + // Catch-all drift probe — the same try/catch-and-report-never-fight + // doctrine as the relay probe above. Read-only: healing is the sync + // POST's job (its ensure runs in the engine). + let catchAll: 'points_here' | 'points_elsewhere' | 'missing' | 'disabled' | 'unknown' = 'unknown'; + if (probeClient && fresh.zone_id && fresh.relay_worker_name) { + try { + const rule = await probeClient.getCatchAll(fresh.zone_id); + catchAll = + rule === null + ? 'missing' + : !rule.enabled + ? 'disabled' + : rule.workerName === fresh.relay_worker_name + ? 'points_here' + : 'points_elsewhere'; + } catch (err) { + console.error('[email] catch-all probe failed:', err); + } + } + const catchAllDrift = + (fresh.status === 'active' || fresh.status === 'pending') && + (catchAll === 'points_elsewhere' || catchAll === 'missing' || catchAll === 'disabled'); + + // This GET is the mirror's repair point (the same read-compare-write + // self-heal shape as the allowlist listing): probe transitions and any + // other drift land here; no difference means no write. Suppressed + // while a removal failure is pending — the row is mid-removal, and the + // proxy mirror must not be "repaired" against a state the operator is + // about to finish removing. + if (fresh.setup_state?.failed_step !== 'remove') { + await repairEmailDomainMirrorIfDrifted(db, context.env, fresh); + } + + return context.json({ + ...serializeEmailDomain(fresh), + credential_status: credentialStatus, + current_relay_version: RELAY_VERSION, + relay_drift: relayDrift, + relay_missing: relayMissing, + catch_all: catchAll, + catch_all_drift: catchAllDrift, + }); +} + +export async function syncEmailDomainForProject( + context: Context, + project: Project, + rawDomain: string, + /** Who asked: an owner may not touch an administrator's row, and vice versa. */ + actor: EmailDomain['managed_by'], +): Promise { + const projectId = project.id; + // Sync mutates CF state and the row — same teardown guard as add/remove. + if (project.status === 'deleting') { + return context.json({ error: 'Project is being deleted' }, 409); + } + const domain = normalizeEmailDomain(rawDomain); + const db = context.get('db'); + const row = await getEmailDomainRow(db, projectId, domain); + if (!row) { + return context.json({ error: 'email domain not found' }, 404); + } + // Sync redeploys the relay in place at RELAY_VERSION and rotates its + // secret (overwrite, never read-compare — worker secrets are + // write-only). The row and credential are re-read inside the row + // lock, so a replace that lands while this request waits is synced + // as the NEW credential, never verified as the superseded one. + return syncEmailDomainAndApply(context, projectId, row.id, proxyOriginForEmail(context.env), { + managedBy: actor, + projectSlug: project.slug, + }); +} + +export async function removeEmailDomainForProject( + context: Context, + project: Project, + rawDomain: string, + force: boolean, + /** Who asked: an owner may not touch an administrator's row, and vice versa. */ + actor: EmailDomain['managed_by'], +): Promise { + const projectId = project.id; + if (project.status === 'deleting') { + return context.json({ error: 'Project is being deleted' }, 409); + } + const domain = normalizeEmailDomain(rawDomain); + const db = context.get('db'); + const row = await getEmailDomainRow(db, projectId, domain); + if (!row) { + return context.json({ error: 'email domain not found' }, 404); + } + // Remove under the row's serialized lock, with the row and credential + // re-read inside it: a replace that commits between this request's + // entry read and the cleanup must not have its fresh credential + // orphaned by the row delete — the replace never invalidates an old + // api_token, so cleanup built on a stale snapshot would SUCCEED, the + // row would be deleted, and a newly committed oauth grant's only + // handle would go with it (live at CF with its broad scopes, + // revocable by nobody). The lock serializes with the replace's commit + // and with lease refreshes; the serialized helpers take THIS tx — + // passing the pool handle here would deadlock. + // + // removeEmailDomain never throws; a decrypt failure skips CF cleanup + // entirely. A failed DESTRUCTIVE step (catch-all or relay delete) + // KEEPS the row so the operator can retry — unless forced, or the + // grant is already dead: token_revoked rows are always deleted (CF + // may be unreachable and keeping buys no retry leverage — re-add is + // the renewal path), and a 'revoke_grant'-only failure still deletes + // (a stranded grant breaks no mail). + type Removal = + | Readonly<{ kind: 'removed'; failedSteps: ReadonlyArray }> + | Readonly<{ kind: 'kept'; failedSteps: ReadonlyArray }> + | Readonly<{ kind: 'vanished' }> + | Readonly<{ kind: 'refused'; response: Response }>; + const removal: Removal = await withEmailDomainRowLock(db, row.id, async (tx) => { + const locked = await tx.select().from(emailDomains).where(eq(emailDomains.id, row.id)).limit(1); + const current = locked[0]; + if (!current) { + // Teardown's cascade can take the row mid-request — the + // idempotent delete branch below proceeds. + return { kind: 'vanished' }; + } + // Authorization on the LOCKED row: the other side may have committed + // its own add between this request's entry read and the lock. + const refused = refuseCrossOwnership(context, current, actor, project.slug, 'remove'); + if (refused) { + return { kind: 'refused', response: refused }; + } + let failedSteps: ReadonlyArray; + try { + const credential = await decryptEmailDomainToken( + context.env, + projectId, + current.domain, + current, + ); + const result = await removeEmailDomain( + { + row: { + credential_kind: current.credential_kind, + account_id: current.account_id, + relay_worker_name: current.relay_worker_name, + zone_id: current.zone_id, + }, + credential, + }, + { + // Refresh rotations must persist through the row's serialized + // chain while the row lives — in the keep-row world a failed + // removal KEEPS the row, and a bricked rotation would kill + // the retry. The revoke likewise goes through the chain: it + // revokes the row's LIVE head, so a lease refresh that + // committed before this removal cannot leave a rotated grant + // orphaned behind a 200-for-a-consumed-token. + refreshGrant: () => refreshOauthGrantSerialized(tx, context.env, current.id), + ...(current.credential_kind === 'oauth_grant' + ? { revokeGrant: () => revokeOauthGrantSerialized(tx, context.env, current.id) } + : {}), + }, + ); + failedSteps = result.failed_steps; + } catch (err) { + console.error('[email] domain remove credential decrypt failed:', err); + failedSteps = ['delete_relay', 'catch_all']; + } + + const destructive = failedSteps.some((s) => s === 'catch_all' || s === 'delete_relay'); + // Decide on the CURRENT status, not the pre-removal snapshot: an + // invalid_grant inside the removal's refresh persisted + // token_revoked through THIS tx, and a now-revoked row takes the + // unconditional-delete path exactly like one revoked before the + // request — keeping it would leave its stale active mirror + // accepting inbound with repair suppressed. The row cannot vanish + // here — this tx holds its lock. + const currentStatus = ( + await tx + .select({ status: emailDomains.status }) + .from(emailDomains) + .where(eq(emailDomains.id, current.id)) + .limit(1) + )[0]?.status; + if (destructive && !force && currentStatus !== 'token_revoked') { + await markEmailDomainRemovalFailed(tx, current.id, failedSteps); + return { kind: 'kept', failedSteps }; + } + + // Mirror key BEFORE the row — never destroy the record a retry + // would re-derive the mirror from (the destinations DELETE + // inversion). A KV failure rolls the transaction back with the row + // intact, so the retry can finish it. + await clearEmailDomainMirror(context.env.ROUTING, current.domain); + await tx.delete(emailDomains).where(eq(emailDomains.id, current.id)); + return { kind: 'removed', failedSteps }; + }); + + if (removal.kind === 'refused') { + return removal.response; + } + if (removal.kind === 'kept') { + return context.json({ ok: false, removed: false, failed_steps: removal.failedSteps }, 200); + } + if (removal.kind === 'vanished') { + // Teardown already cleared the mirror; the re-clear is idempotent. + await clearEmailDomainMirror(context.env.ROUTING, row.domain); + } + const failedSteps = removal.kind === 'removed' ? removal.failedSteps : []; + return context.json({ + ok: true, + removed: true, + ...(force && failedSteps.length > 0 ? { forced: true } : {}), + ...(failedSteps.length > 0 ? { failed_steps: failedSteps } : {}), + }); +} diff --git a/platform/packages/api/src/email/domains.ts b/platform/packages/api/src/email/domains.ts index 06b45e3bc..a810a945a 100644 --- a/platform/packages/api/src/email/domains.ts +++ b/platform/packages/api/src/email/domains.ts @@ -86,12 +86,30 @@ export function encryptEmailDomainToken( return encryptProjectSecret(env, projectId, emailDomainTokenAadName(domain), token); } -export function decryptEmailDomainToken( - env: EmailDomainSecretBindings, +export type PlatformCredentialBindings = Pick; + +/** + * The one place a row's credential is resolved. `platform` rows (an + * administrator's, design doc 0078 "Administrator-managed domains") hold + * no credential and resolve to the platform's own runtime token, which is + * never written to the row, a mirror or a log; every other kind decrypts + * the stored triple. + */ +export async function decryptEmailDomainToken( + env: EmailDomainSecretBindings & Partial, projectId: string, domain: string, - row: Pick, + row: Pick, ): Promise { + if (row.credential_kind === 'platform') { + if (!env.CF_API_TOKEN) { + throw new Error('platform credential unavailable: CF_API_TOKEN is not configured'); + } + return env.CF_API_TOKEN; + } + if (row.token_ciphertext === null || row.token_iv === null || row.token_key_version === null) { + throw new Error('email_domains row is missing its stored credential'); + } return decryptProjectSecret(env, projectId, { name: emailDomainTokenAadName(domain), ciphertext: row.token_ciphertext, diff --git a/platform/packages/api/src/email/limits.ts b/platform/packages/api/src/email/limits.ts new file mode 100644 index 000000000..02096849d --- /dev/null +++ b/platform/packages/api/src/email/limits.ts @@ -0,0 +1,40 @@ +/** + * Per-project outbound caps and the kill switch live in ROUTING KV, where + * the proxy (`packages/proxy/src/email.ts`) reads them on every send. The + * keys and defaults below are the same contract written from both ends — + * nothing at build time catches a drift, so they are spelled once here for + * the api's writers (admin UI, operator API). + */ +export const emailSuspendKey = (projectId: string) => `email-suspended:${projectId}`; +export const emailMonthlyLimitKey = (projectId: string) => `email-limit:${projectId}`; +export const emailBurstLimitKey = (projectId: string) => `email-burst-limit:${projectId}`; + +/** Mirrors `getMonthlyLimit` in `packages/proxy/src/email.ts`. */ +export const DEFAULT_EMAIL_MONTHLY_LIMIT = 200; +/** Mirrors `getBurstLimit` in `packages/proxy/src/email.ts`. */ +export const DEFAULT_EMAIL_BURST_LIMIT = 10; + +/** Highest override either writer accepts; bounds a typo, not a plan. */ +export const MAX_EMAIL_LIMIT = 1_000_000; + +/** + * Undefined means "no usable override" — either the key is absent or it + * holds a value the proxy would itself reject, in which case the proxy is + * already serving the default and every reader must say so too. + */ +export function parseEmailLimit(raw: string | null | undefined): number | undefined { + if (!raw) { + return undefined; + } + const n = parseInt(raw, 10); + return Number.isFinite(n) && n > 0 ? n : undefined; +} + +/** Strict form of `parseEmailLimit` for operator input: digits only, within the cap. */ +export function parseEmailLimitInput(raw: string): number | undefined { + if (!/^[1-9]\d*$/.test(raw)) { + return undefined; + } + const n = Number(raw); + return n <= MAX_EMAIL_LIMIT ? n : undefined; +} diff --git a/platform/packages/api/src/email/relay-engine.ts b/platform/packages/api/src/email/relay-engine.ts index dd88a9532..0a11c1a2c 100644 --- a/platform/packages/api/src/email/relay-engine.ts +++ b/platform/packages/api/src/email/relay-engine.ts @@ -170,12 +170,19 @@ export type EmailDomainSetupInput = Readonly<{ */ row: Pick & Partial>; - /** Decrypted credential: the pasted API token, or the grant's refresh token. */ + /** Decrypted credential: the pasted API token, the grant's refresh token, or the platform's runtime token. */ credential: string; /** Decrypted relay secret, handed to the relay worker as VOID_RELAY_SECRET. */ relaySecret: string; /** Baked into the relay as the VOID_PROXY_ORIGIN plain-text var. */ proxyOrigin: string; + /** + * A `platform` row may only register a zone in the platform's own + * Cloudflare account: the runtime token must never be pointed at a + * customer zone. Setup fails at zone_lookup, before any write, when the + * zone's account differs. + */ + requireAccountId?: string; }>; export type EmailDomainSyncInput = Readonly<{ @@ -269,7 +276,7 @@ const defaultCreateClient = (credential: string): CfCustomerClient => * Returns the MX exchange hostnames (priority and trailing dot stripped). * Public DNS — no credential involved. */ -async function lookupMxOverDns(domain: string): Promise> { +export async function lookupMxOverDns(domain: string): Promise> { const response = await fetch( `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(domain)}&type=MX`, { headers: { accept: 'application/dns-json' } }, @@ -342,6 +349,25 @@ export async function domainHasRoutingMx(domain: string): Promise { } } +/** + * The zone is the deepest suffix of the email domain that exists as a zone + * in an account the credential can see (mail.acme.com → acme.com, or the + * subdomain itself on an Enterprise subdomain setup). Errors propagate. + */ +export async function findZoneForEmailDomain( + client: CfCustomerClient, + domain: string, +): Promise { + const labels = domain.split('.'); + for (let start = 0; start <= labels.length - 2; start++) { + const zone = await client.findZone(labels.slice(start).join('.')); + if (zone) { + return zone; + } + } + return null; +} + type CredentialResolution = | Readonly<{ ok: true; @@ -356,7 +382,10 @@ type CredentialResolution = * Setup step 1. api_token rows verify via GET /user/tokens/verify (which * also yields token_id/expires_on for `status` output); oauth_grant rows * verify by running one refresh round-trip and continuing on the fresh - * access token. + * access token. `platform` rows carry the runtime token, which is not a + * user token: there is nothing to verify up front, and the first + * authenticated step (zone_lookup) fails the row with Cloudflare's own + * message when a permission is missing. */ async function resolveCredential( credentialKind: EmailDomain['credential_kind'], @@ -364,6 +393,9 @@ async function resolveCredential( deps: RelayEngineDeps, ): Promise { const createClient = deps.createClient ?? defaultCreateClient; + if (credentialKind === 'platform') { + return { ok: true, client: createClient(credential), token_id: null, token_expires_on: null }; + } if (credentialKind === 'api_token') { const client = createClient(credential); let verification: CfTokenVerification; @@ -463,19 +495,12 @@ export async function setupEmailDomain( progress.token_expires_on = resolved.token_expires_on; const client = resolved.client; - // Step 2: zone lookup. The zone is the deepest suffix of the email - // domain that exists as a zone in an account the credential can see - // (mail.acme.com → acme.com, or the subdomain itself on an Enterprise - // subdomain setup). Also yields account_id. - const labels = input.row.domain.split('.'); - let zone: CfCustomerZone | null = null; - for (let start = 0; start <= labels.length - 2 && !zone; start++) { - const candidate = labels.slice(start).join('.'); - try { - zone = await client.findZone(candidate); - } catch (err) { - return fail('zone_lookup', err); - } + // Step 2: zone lookup. Also yields account_id. + let zone: CfCustomerZone | null; + try { + zone = await findZoneForEmailDomain(client, input.row.domain); + } catch (err) { + return fail('zone_lookup', err); } if (!zone) { return failWith( @@ -484,6 +509,13 @@ export async function setupEmailDomain( `no Cloudflare zone found for ${input.row.domain} in an account this credential can see`, ); } + if (input.requireAccountId !== undefined && zone.account.id !== input.requireAccountId) { + return failWith( + 'failed', + 'zone_lookup', + `zone for ${input.row.domain} is in Cloudflare account ${zone.account.id}, not the platform's account ${input.requireAccountId}; register it with --token-stdin and a token for that account`, + ); + } progress.zone_id = zone.id; progress.account_id = zone.account.id; diff --git a/platform/packages/api/src/email/settings.ts b/platform/packages/api/src/email/settings.ts new file mode 100644 index 000000000..d2222ebf0 --- /dev/null +++ b/platform/packages/api/src/email/settings.ts @@ -0,0 +1,255 @@ +/** + * Installation-wide email settings (design doc 0078, "Recipient policy" and + * "Administrator-managed domains"). Authoritative in `platform_settings`; + * the recipient policy is also mirrored to ROUTING KV because the proxy + * reads it on every shared-sender send and has no database. + * + * The mirror is a wire contract with `packages/proxy/src/email-policy.ts`: + * key `email-recipient-policy`, value `JSON.stringify(policy)`. An absent or + * unreadable mirror reads as `verified` there — today's behavior — so a + * failed publish can only tighten, never loosen, what the proxy enforces. + */ + +import { sql } from 'drizzle-orm'; +import type { Db, DbOrTx, Tx } from '../db.ts'; +import { getPlatformSetting, setPlatformSetting } from '../platform-settings.ts'; +import { isValidEmailDomain, MAX_DOMAIN_LENGTH, normalizeEmailDomain } from './domain-name.ts'; + +export type EmailRecipientPolicyMode = 'verified' | 'domains' | 'any'; +export type EmailRecipientPolicy = Readonly<{ + mode: EmailRecipientPolicyMode; + domains: ReadonlyArray; +}>; +export type EmailDomainManagement = 'self-serve' | 'admin'; + +export const EMAIL_RECIPIENT_POLICY_SETTING = 'email.recipientPolicy'; +export const EMAIL_DOMAIN_MANAGEMENT_SETTING = 'email.domains'; +export const EMAIL_RECIPIENT_POLICY_MIRROR_KEY = 'email-recipient-policy'; +export const DEFAULT_EMAIL_RECIPIENT_POLICY: EmailRecipientPolicy = { + mode: 'verified', + domains: [], +}; +export const MAX_POLICY_DOMAINS = 100; + +const POLICY_MODES: ReadonlyArray = ['verified', 'domains', 'any']; +const MODE_RANK: Readonly> = { + verified: 0, + domains: 1, + any: 2, +}; +const MANAGEMENT_MODES: ReadonlyArray = ['self-serve', 'admin']; + +export class EmailSettingsError extends Error { + readonly status = 400 as const; + constructor(message: string) { + super(message); + this.name = 'EmailSettingsError'; + } +} + +export function isEmailDomainManagement(value: unknown): value is EmailDomainManagement { + return typeof value === 'string' && (MANAGEMENT_MODES as ReadonlyArray).includes(value); +} + +/** + * Validates operator input `{ mode, domains? }`. `domains` may be an array + * or one comma/whitespace-separated string; entries are normalized like a + * registered email domain. Only `domains` mode keeps a list, and it must + * not be empty — an empty list would be `verified` wearing another name. + */ +export function normalizeEmailRecipientPolicy(input: unknown): EmailRecipientPolicy { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw new EmailSettingsError('recipient policy must be an object with a mode'); + } + const { mode, domains } = input as { mode?: unknown; domains?: unknown }; + if (typeof mode !== 'string' || !(POLICY_MODES as ReadonlyArray).includes(mode)) { + throw new EmailSettingsError(`mode must be one of ${POLICY_MODES.join(', ')}`); + } + if (mode !== 'domains') { + return { mode: mode as EmailRecipientPolicyMode, domains: [] }; + } + const raw: Array = + domains === undefined || domains === null + ? [] + : typeof domains === 'string' + ? domains.split(/[\s,]+/) + : Array.isArray(domains) + ? domains + : [domains]; + const normalized = new Set(); + for (const entry of raw) { + if (typeof entry !== 'string') { + throw new EmailSettingsError('domains must be strings'); + } + if (entry.trim() === '') { + continue; + } + if (entry.length > MAX_DOMAIN_LENGTH) { + throw new EmailSettingsError('a recipient domain is too long'); + } + const domain = normalizeEmailDomain(entry); + if (!isValidEmailDomain(domain)) { + throw new EmailSettingsError(`'${entry.trim()}' is not a valid domain`); + } + normalized.add(domain); + } + if (normalized.size === 0) { + throw new EmailSettingsError('mode domains needs at least one recipient domain'); + } + if (normalized.size > MAX_POLICY_DOMAINS) { + throw new EmailSettingsError(`at most ${MAX_POLICY_DOMAINS} recipient domains`); + } + return { mode: 'domains', domains: [...normalized].sort() }; +} + +/** + * True when `next` lets through anything `prev` did not: a higher mode, or + * a `domains` list that gains a domain — even one that also drops others. + * Only a lower mode or a pure shrink is a tightening. + */ +export function widensEmailRecipientPolicy( + prev: EmailRecipientPolicy, + next: EmailRecipientPolicy, +): boolean { + if (next.mode !== prev.mode) { + return MODE_RANK[next.mode] > MODE_RANK[prev.mode]; + } + if (next.mode !== 'domains') { + return false; + } + const before = new Set(prev.domains); + return next.domains.some((domain) => !before.has(domain)); +} + +function readStoredPolicy(value: unknown): EmailRecipientPolicy | null { + try { + return normalizeEmailRecipientPolicy(value); + } catch { + return null; + } +} + +export async function getEmailRecipientPolicy(db: DbOrTx): Promise { + const stored = await getPlatformSetting(db, EMAIL_RECIPIENT_POLICY_SETTING); + if (stored === null) { + return DEFAULT_EMAIL_RECIPIENT_POLICY; + } + const policy = readStoredPolicy(stored); + if (policy === null) { + console.error( + `[email-settings] ${EMAIL_RECIPIENT_POLICY_SETTING} is malformed; enforcing the default policy`, + ); + return DEFAULT_EMAIL_RECIPIENT_POLICY; + } + return policy; +} + +export function serializeEmailRecipientPolicyMirror(policy: EmailRecipientPolicy): string { + return JSON.stringify({ mode: policy.mode, domains: [...policy.domains] }); +} + +/** + * The publisher and the repair serialize on one transaction-scoped advisory + * lock, so their read → compare → put sequences never interleave: KV has no + * compare-and-set, and without the lock a repair that read an older policy + * could land its put after a newer publish and reopen a gate the operator + * had just closed. The lock is held across the KV calls on purpose — the + * same total-order trick the email-domain mirror uses with the project row. + */ +function withRecipientPolicyLock(db: Db, fn: (tx: Tx) => Promise): Promise { + return db.transaction(async (tx) => { + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtext(${EMAIL_RECIPIENT_POLICY_SETTING}))`, + ); + return fn(tx); + }); +} + +/** + * One KV write per publish — Workers KV refuses a second write to a key + * within a second, so the mirror is never cleared and re-put. The write is + * ordered so the mirror is never LOOSER than the database in any partial + * outcome: + * tightening put the mirror, then commit. A failed put changes nothing; + * a failed commit leaves the proxy stricter than the database + * until the next read republishes. + * widening commit, then put. A failed put leaves the proxy on the old, + * stricter policy — the operator is told — until a retry or + * the next read republishes. + * The mirror therefore never authorizes a domain the database has not + * committed; a list edited both ways goes as a widening, so its one residual + * is the removed domains staying reachable — the policy in force a moment + * earlier — until the republish. Publisher and repair share the advisory lock. + */ +export async function setEmailRecipientPolicy( + db: Db, + kv: KVNamespace, + policy: EmailRecipientPolicy, +): Promise { + const widened = await withRecipientPolicyLock(db, async (tx) => { + const widening = widensEmailRecipientPolicy(await getEmailRecipientPolicy(tx), policy); + if (!widening) { + try { + await kv.put( + EMAIL_RECIPIENT_POLICY_MIRROR_KEY, + serializeEmailRecipientPolicyMirror(policy), + ); + } catch (err) { + console.error('[email-settings] recipient policy mirror write failed:', err); + throw new Error( + 'the recipient policy routing mirror could not be updated; the saved policy is unchanged and the next read republishes it', + ); + } + } + await setPlatformSetting(tx, EMAIL_RECIPIENT_POLICY_SETTING, { + mode: policy.mode, + domains: [...policy.domains], + }); + return widening; + }); + if (widened) { + try { + await publishEmailRecipientPolicyMirror(db, kv); + } catch (err) { + console.error('[email-settings] recipient policy mirror write failed:', err); + throw new Error( + 'recipient policy saved; publishing it to routing failed and the previous policy stays in force until a retry or the next read republishes it', + ); + } + } +} + +/** Under the publish lock: read the committed policy and put the mirror when it differs. */ +async function publishEmailRecipientPolicyMirror(db: Db, kv: KVNamespace): Promise { + await withRecipientPolicyLock(db, async (tx) => { + const [policy, mirrored] = await Promise.all([ + getEmailRecipientPolicy(tx), + kv.get(EMAIL_RECIPIENT_POLICY_MIRROR_KEY), + ]); + const expected = serializeEmailRecipientPolicyMirror(policy); + if (mirrored !== expected) { + await kv.put(EMAIL_RECIPIENT_POLICY_MIRROR_KEY, expected); + } + }); +} + +/** Best-effort read-compare repair of the mirror; never throws. */ +export async function repairEmailRecipientPolicyMirror(db: Db, kv: KVNamespace): Promise { + try { + await publishEmailRecipientPolicyMirror(db, kv); + } catch (err) { + console.error('[email-settings] recipient policy mirror repair failed:', err); + } +} + +export async function getEmailDomainManagement(db: DbOrTx): Promise { + const stored = await getPlatformSetting(db, EMAIL_DOMAIN_MANAGEMENT_SETTING); + return isEmailDomainManagement(stored) ? stored : 'self-serve'; +} + +export async function setEmailDomainManagement( + db: DbOrTx, + mode: EmailDomainManagement, +): Promise { + await setPlatformSetting(db, EMAIL_DOMAIN_MANAGEMENT_SETTING, mode); +} diff --git a/platform/packages/api/src/operator/audit.ts b/platform/packages/api/src/operator/audit.ts index daf384bce..dbcd81fcd 100644 --- a/platform/packages/api/src/operator/audit.ts +++ b/platform/packages/api/src/operator/audit.ts @@ -121,6 +121,39 @@ function auditTarget( if (segments[0] === 'deployments' && id && segments[2] === 'cancel') { return JSON.stringify({ deploymentId: id }); } + if (segments[0] === 'email') { + if (segments[1] === 'policy') { + return JSON.stringify({ + mode: boundedString(body.mode, 512), + domains: boundedString(body.domains, 100 * 254, true), + }); + } + if (segments[1] === 'settings') { + return JSON.stringify({ domains: boundedString(body.domains, 512) }); + } + // Never the credential: `apiToken` rides the add body and stays out. + const domain = boundedString(segments[2], 253, true); + if (segments[1] === 'domains' && segments.length === 2) { + return JSON.stringify({ + domain: boundedString(body.domain, 253, true), + project: boundedString(body.project, 512), + }); + } + if (segments[1] === 'domains' && domain && segments[3] === 'sync') { + return JSON.stringify({ domain }); + } + if (segments[1] === 'domains' && domain && segments[3] === 'remove') { + return JSON.stringify({ domain, force: body.force === true }); + } + const project = pathIdentifier(segments[2]); + if (segments[1] === 'projects' && project && segments[3] === 'limit') { + return JSON.stringify({ + project, + monthly: boundedString(body.monthly, 16), + burst: boundedString(body.burst, 16), + }); + } + } if (segments[0] === 'system' && segments[1] === 'backfill-queue-tokens') { return JSON.stringify({ entries: 'queue:*' }); } diff --git a/platform/packages/api/src/operator/email-routes.ts b/platform/packages/api/src/operator/email-routes.ts new file mode 100644 index 000000000..3b838ce2f --- /dev/null +++ b/platform/packages/api/src/operator/email-routes.ts @@ -0,0 +1,497 @@ +import { asc, eq, inArray } from 'drizzle-orm'; +import { Hono } from 'hono'; +import type { Context } from 'hono'; +import { capabilityMiddleware } from '../capabilities.ts'; +import { getProject, getProjectBySlug, type Db } from '../db.ts'; +import { + MAX_DOMAIN_LENGTH, + isValidEmailDomain, + normalizeEmailDomain, +} from '../email/domain-name.ts'; +import { + addEmailDomainForProject, + getEmailDomainRowByDomain, + getEmailDomainStatusForProject, + removeEmailDomainForProject, + serializeEmailDomain, + syncEmailDomainForProject, +} from '../email/domain-service.ts'; +import { + emailBurstLimitKey, + emailMonthlyLimitKey, + parseEmailLimit, + parseEmailLimitInput, + MAX_EMAIL_LIMIT, +} from '../email/limits.ts'; +import { lookupMxOverDns } from '../email/relay-engine.ts'; +import { + EmailSettingsError, + getEmailDomainManagement, + getEmailRecipientPolicy, + isEmailDomainManagement, + normalizeEmailRecipientPolicy, + repairEmailRecipientPolicyMirror, + setEmailDomainManagement, + setEmailRecipientPolicy, + type EmailRecipientPolicy, +} from '../email/settings.ts'; +import type { ApiEnv } from '../env.ts'; +import { emailDomains, projects } from '../schema.ts'; +import { dryRun, OperatorError, preview, readBody, stringField } from './common.ts'; + +/** + * Installation-wide email administration (design doc 0078, "Recipient + * policy" and "Administrator-managed domains"). The browser admin UI calls + * the same service functions below so both surfaces write one contract. + */ +export const emailOperatorRoutes = new Hono().use( + '/email/*', + capabilityMiddleware('email'), +); + +function policyEffect(policy: EmailRecipientPolicy): string { + switch (policy.mode) { + case 'any': + return "Shared-sender mail reaches any recipient; addresses on the platform's own mail domain stay verified-destination-only, so no project reaches another project's inbox without its consent. Cloudflare still refuses unverified destinations until the platform mail domain is onboarded for Email Sending."; + case 'domains': + return `Shared-sender mail reaches verified destinations and any address on ${policy.domains.join(', ')}; addresses on the platform's own mail domain stay verified-destination-only. Cloudflare still refuses unverified destinations until the platform mail domain is onboarded for Email Sending.`; + default: + return "Shared-sender mail reaches only each project's verified destinations."; + } +} + +export async function getOperatorEmailPolicy(context: Context) { + const db = context.get('db'); + const policy = await getEmailRecipientPolicy(db); + await repairEmailRecipientPolicyMirror(db, context.env.ROUTING); + return { policy: { mode: policy.mode, domains: [...policy.domains] } }; +} + +export async function setOperatorEmailPolicy( + context: Context, + input: { mode: string; domains?: string }, + dryRunRequested = false, +) { + let policy: EmailRecipientPolicy; + try { + policy = normalizeEmailRecipientPolicy(input); + } catch (err) { + if (err instanceof EmailSettingsError) { + throw new OperatorError(400, err.message, 'invalid_argument'); + } + throw err; + } + const db = context.get('db'); + const previous = await getEmailRecipientPolicy(db); + const action = 'email.policy.set'; + const target = { mode: policy.mode, domains: [...policy.domains] }; + if (dryRunRequested) { + return preview(action, target, { + previous: { mode: previous.mode, domains: [...previous.domains] }, + effect: policyEffect(policy), + }); + } + try { + await setEmailRecipientPolicy(db, context.env.ROUTING, policy); + } catch (err) { + throw new OperatorError(503, err instanceof Error ? err.message : String(err), 'mirror_failed'); + } + return { action, target, policy: target }; +} + +export async function getOperatorEmailSettings(context: Context) { + return { domains: { management: await getEmailDomainManagement(context.get('db')) } }; +} + +export async function setOperatorEmailSettings( + context: Context, + domains: string, + dryRunRequested = false, +) { + if (!isEmailDomainManagement(domains)) { + throw new OperatorError(400, 'domains must be self-serve or admin.', 'invalid_argument'); + } + const db = context.get('db'); + const previous = await getEmailDomainManagement(db); + const action = 'email.settings.set'; + const target = { domains }; + if (dryRunRequested) { + return preview(action, target, { + previous: { domains: previous }, + effect: + domains === 'admin' + ? 'Project owners are told to ask an administrator; `void email domain add` no longer opens a Cloudflare consent.' + : 'Project owners register email domains with their own Cloudflare credential.', + }); + } + await setEmailDomainManagement(db, domains); + return { action, target, domains: { management: domains } }; +} + +async function resolveProject(db: Db, selector: string) { + const project = (await getProject(db, selector)) ?? (await getProjectBySlug(db, selector)); + if (!project) { + throw new OperatorError(404, 'Project not found'); + } + return project; +} + +export async function setOperatorEmailLimit( + context: Context, + selector: string, + input: { monthly?: string; burst?: string }, + dryRunRequested = false, +) { + const parse = (name: 'monthly' | 'burst'): number | undefined => { + const raw = input[name]; + if (raw === undefined || raw === '') { + return undefined; + } + const value = parseEmailLimitInput(raw); + if (value === undefined) { + throw new OperatorError( + 400, + `${name} must be an integer from 1 to ${MAX_EMAIL_LIMIT}.`, + 'invalid_argument', + ); + } + return value; + }; + const monthly = parse('monthly'); + const burst = parse('burst'); + if (monthly === undefined && burst === undefined) { + throw new OperatorError(400, 'Supply --monthly, --burst, or both.', 'invalid_argument'); + } + const db = context.get('db'); + const project = await resolveProject(db, selector); + const kv = context.env.ROUTING; + const [previousMonthly, previousBurst] = await Promise.all([ + kv.get(emailMonthlyLimitKey(project.id)), + kv.get(emailBurstLimitKey(project.id)), + ]); + const action = 'email.limit.set'; + const target = { + projectId: project.id, + slug: project.slug, + ...(monthly === undefined ? {} : { monthly }), + ...(burst === undefined ? {} : { burst }), + }; + const previous = { + monthly: parseEmailLimit(previousMonthly) ?? null, + burst: parseEmailLimit(previousBurst) ?? null, + }; + if (dryRunRequested) { + return preview(action, target, { previous }); + } + const applied: Array<'monthly' | 'burst'> = []; + const failed: Array<'monthly' | 'burst'> = []; + for (const [name, key, value] of [ + ['monthly', emailMonthlyLimitKey(project.id), monthly], + ['burst', emailBurstLimitKey(project.id), burst], + ] as const) { + if (value === undefined) { + continue; + } + try { + await kv.put(key, String(value)); + applied.push(name); + } catch (err) { + console.error(`[operator-email] ${name} cap write failed:`, err); + failed.push(name); + } + } + if (failed.length === 0) { + return { action, target, previous }; + } + if (applied.length === 0) { + throw new OperatorError( + 502, + 'No cap was changed: routing storage refused the write.', + 'kv_failed', + { failed }, + ); + } + return { action, target, previous, ok: false as const, partial: true as const, applied, failed }; +} + +emailOperatorRoutes.get('/email/policy', async (context) => + context.json(await getOperatorEmailPolicy(context)), +); +emailOperatorRoutes.post('/email/policy', async (context) => { + const body = await readBody(context); + return context.json( + await setOperatorEmailPolicy( + context, + { + mode: stringField(body, 'mode', { maxLength: 32 }), + domains: + stringField(body, 'domains', { optional: true, maxLength: 100 * 254 }) || undefined, + }, + dryRun(body), + ), + ); +}); +emailOperatorRoutes.get('/email/settings', async (context) => + context.json(await getOperatorEmailSettings(context)), +); +emailOperatorRoutes.post('/email/settings', async (context) => { + const body = await readBody(context); + return context.json( + await setOperatorEmailSettings( + context, + stringField(body, 'domains', { maxLength: 32 }), + dryRun(body), + ), + ); +}); +emailOperatorRoutes.post('/email/projects/:project/limit', async (context) => { + const body = await readBody(context); + const result = await setOperatorEmailLimit( + context, + context.req.param('project'), + { + monthly: stringField(body, 'monthly', { optional: true, maxLength: 16 }) || undefined, + burst: stringField(body, 'burst', { optional: true, maxLength: 16 }) || undefined, + }, + dryRun(body), + ); + return context.json(result, 'ok' in result && result.ok === false ? 207 : 200); +}); + +// --------------------------------------------------------------------------- +// Administrator-managed domains. The operations are the owner routes' own +// (email/domain-service.ts); this layer resolves the project for the +// administrator, previews without touching Cloudflare, and re-shapes the +// service's JSON responses into operator results. +// --------------------------------------------------------------------------- + +/** The service answers with the owner routes' JSON; carry a refusal over as an OperatorError. */ +async function operatorResult( + response: Response, + action: string, + target: Record, +): Promise> { + const body = (await response.json()) as Record; + if (!response.ok) { + const message = + typeof body.error === 'string' && body.error !== '' + ? body.error + : 'The email domain operation failed.'; + throw new OperatorError(response.status, message, 'email_domain_error', body); + } + // A kept row after a failed destructive step answers 200 with `ok: false` + // (the owner contract), and a setup or sync that ended on a Cloudflare + // failure answers 200 with the row in a failed state. Both surface as a + // top-level `ok: false` so the audit records a failed mutation and the + // CLI reports the partial result. + const failed = + body.ok === false || + body.status === 'failed' || + body.status === 'token_revoked' || + body.status === 'token_expired'; + return { action, target, ...(failed ? { ok: false } : {}), domain: body }; +} + +function requireDomain(raw: string): string { + if (raw.length > MAX_DOMAIN_LENGTH) { + throw new OperatorError(400, 'Invalid domain.', 'invalid_argument'); + } + const domain = normalizeEmailDomain(raw); + if (!isValidEmailDomain(domain)) { + throw new OperatorError(400, 'Invalid domain.', 'invalid_argument'); + } + return domain; +} + +async function requireDomainRow(db: Db, raw: string) { + const domain = requireDomain(raw); + const row = await getEmailDomainRowByDomain(db, domain); + if (!row) { + throw new OperatorError(404, 'Email domain not found.'); + } + const project = await getProject(db, row.project_id); + if (!project) { + throw new OperatorError(404, 'Project not found.'); + } + return { domain, row, project }; +} + +export async function listOperatorEmailDomains(context: Context, selector?: string) { + const db = context.get('db'); + const project = selector ? await resolveProject(db, selector) : null; + const rows = await db + .select() + .from(emailDomains) + .where(project ? eq(emailDomains.project_id, project.id) : undefined) + .orderBy(asc(emailDomains.created_at)); + const projectIds = [...new Set(rows.map((row) => row.project_id))]; + const owners = + projectIds.length === 0 + ? [] + : await db + .select({ id: projects.id, slug: projects.slug }) + .from(projects) + .where(inArray(projects.id, projectIds)); + const slugs = new Map(owners.map((owner) => [owner.id, owner.slug])); + return { + domains: rows.map((row) => ({ + ...serializeEmailDomain(row), + project: { id: row.project_id, slug: slugs.get(row.project_id) ?? null }, + })), + }; +} + +export async function addOperatorEmailDomain( + context: Context, + input: { domain: string; project: string; apiToken?: string }, + dryRunRequested = false, +) { + const db = context.get('db'); + const domain = requireDomain(input.domain); + const project = await resolveProject(db, input.project); + const existing = await getEmailDomainRowByDomain(db, domain); + if (existing && existing.project_id !== project.id) { + throw new OperatorError(409, 'This email domain is registered to another project.'); + } + if (existing && existing.managed_by === 'owner') { + throw new OperatorError( + 409, + 'This email domain was registered by its project owner; ask them to remove it first.', + ); + } + const credentialKind = input.apiToken ? 'api_token' : 'platform'; + const action = 'email.domain.add'; + const target = { domain, projectId: project.id, slug: project.slug, credentialKind }; + if (dryRunRequested) { + // Read-only: the engine's own MX rule, previewed over public DNS. No + // Cloudflare call — the preview must never spend the credential. + let mx: 'apex' | 'subdomain' | 'unknown' = 'unknown'; + try { + mx = (await lookupMxOverDns(domain)).length > 0 ? 'subdomain' : 'apex'; + } catch { + mx = 'unknown'; + } + return preview(action, target, { + existing: existing ? { status: existing.status, managed_by: existing.managed_by } : null, + plan: existing ? 'rerun' : 'setup', + mx, + effect: existing?.mode + ? `Re-runs setup for ${domain} on ${project.slug} in its stored ${existing.mode} mode; the credential is replaced, rules and the relay stay.` + : mx === 'subdomain' + ? `${domain} already receives mail; setup will refuse it — name a subdomain such as mail.${domain} instead.` + : credentialKind === 'platform' + ? `Registers ${domain} for ${project.slug} on the platform's own Cloudflare credential; the zone must be in the platform's account.` + : `Registers ${domain} for ${project.slug} with the supplied API token.`, + }); + } + return operatorResult( + await addEmailDomainForProject(context, project, { + domain, + apiToken: input.apiToken, + managedBy: 'admin', + }), + action, + target, + ); +} + +export async function getOperatorEmailDomain(context: Context, rawDomain: string) { + const { row, project } = await requireDomainRow(context.get('db'), rawDomain); + const response = await getEmailDomainStatusForProject(context, project, row.domain); + const body = (await response.json()) as Record; + if (!response.ok) { + throw new OperatorError(response.status, String(body.error ?? 'status failed')); + } + return { ...body, project: { id: project.id, slug: project.slug } }; +} + +export async function syncOperatorEmailDomain( + context: Context, + rawDomain: string, + dryRunRequested = false, +) { + const { domain, row, project } = await requireDomainRow(context.get('db'), rawDomain); + const action = 'email.domain.sync'; + const target = { domain, projectId: project.id, slug: project.slug }; + if (dryRunRequested) { + return preview(action, target, { + status: row.status, + managed_by: row.managed_by, + relay_worker_name: row.relay_worker_name, + effect: + 'Redeploys the relay worker at the current version, rotates its secret and re-ensures the zone catch-all.', + }); + } + return operatorResult( + await syncEmailDomainForProject(context, project, domain, 'admin'), + action, + target, + ); +} + +export async function removeOperatorEmailDomain( + context: Context, + rawDomain: string, + force: boolean, + dryRunRequested = false, +) { + const { domain, row, project } = await requireDomainRow(context.get('db'), rawDomain); + const action = 'email.domain.remove'; + const target = { domain, projectId: project.id, slug: project.slug, force }; + if (dryRunRequested) { + return preview(action, target, { + status: row.status, + managed_by: row.managed_by, + relay_worker_name: row.relay_worker_name, + effect: force + ? 'Deletes the row even if the Cloudflare cleanup fails; anything left behind is reported for manual removal.' + : 'Resets the zone catch-all, deletes the relay worker, then deletes the row; a failed destructive step keeps the row for retry.', + }); + } + return operatorResult( + await removeEmailDomainForProject(context, project, domain, force, 'admin'), + action, + target, + ); +} + +emailOperatorRoutes.get('/email/domains', async (context) => + context.json(await listOperatorEmailDomains(context, context.req.query('project') || undefined)), +); +emailOperatorRoutes.post('/email/domains', async (context) => { + const body = await readBody(context); + return context.json( + await addOperatorEmailDomain( + context, + { + domain: stringField(body, 'domain', { maxLength: MAX_DOMAIN_LENGTH }), + project: stringField(body, 'project'), + apiToken: stringField(body, 'apiToken', { optional: true, maxLength: 2048 }) || undefined, + }, + dryRun(body), + ), + ); +}); +emailOperatorRoutes.get('/email/domains/:domain', async (context) => + context.json(await getOperatorEmailDomain(context, context.req.param('domain'))), +); +emailOperatorRoutes.post('/email/domains/:domain/sync', async (context) => { + const body = await readBody(context); + return context.json( + await syncOperatorEmailDomain(context, context.req.param('domain'), dryRun(body)), + ); +}); +emailOperatorRoutes.post('/email/domains/:domain/remove', async (context) => { + const body = await readBody(context); + if (body.force !== undefined && typeof body.force !== 'boolean') { + throw new OperatorError(400, 'force must be a boolean.', 'invalid_argument'); + } + return context.json( + await removeOperatorEmailDomain( + context, + context.req.param('domain'), + body.force === true, + dryRun(body), + ), + ); +}); diff --git a/platform/packages/api/src/operator/index.ts b/platform/packages/api/src/operator/index.ts index 6afc9554f..af043735e 100644 --- a/platform/packages/api/src/operator/index.ts +++ b/platform/packages/api/src/operator/index.ts @@ -7,6 +7,7 @@ import { operatorAudit, operatorAuditRoutes } from './audit.ts'; import { authenticationConfigRoutes } from './auth-config.ts'; import { authenticatedSessionRoutes, operatorAuth, operatorSessionRoutes } from './auth.ts'; import { OperatorError } from './common.ts'; +import { emailOperatorRoutes } from './email-routes.ts'; import { operationsRoutes } from './operations-routes.ts'; import { tenantRoutes } from './tenant-routes.ts'; @@ -61,6 +62,7 @@ operatorApi.route('/', operatorAuditRoutes); operatorApi.route('/', tenantRoutes); operatorApi.route('/', operationsRoutes); operatorApi.route('/', authenticationConfigRoutes); +operatorApi.route('/', emailOperatorRoutes); operatorApi.notFound((context) => context.json({ error: 'not_found', message: 'Unknown operator API endpoint.' }, 404), ); diff --git a/platform/packages/api/src/pg-errors.ts b/platform/packages/api/src/pg-errors.ts new file mode 100644 index 000000000..9cc6763f6 --- /dev/null +++ b/platform/packages/api/src/pg-errors.ts @@ -0,0 +1,63 @@ +/** Postgres SQLSTATEs — surfaced by both `pg` and PGlite. */ +const PG_FOREIGN_KEY_VIOLATION = '23503'; +const PG_UNIQUE_VIOLATION = '23505'; + +/** + * Does this error carry the given Postgres SQLSTATE anywhere in its cause + * chain? + * + * The SQLSTATE rides on the DRIVER error, and drizzle wraps that in a + * `DrizzleQueryError` whose own `code` is undefined — so the `cause` chain + * has to be walked, not just the frame that was thrown. Verified against + * PGlite in `api-delete.test.ts`; `pg` reports the same code the same way. + * Bounded so a self-referencing `cause` cannot spin. + */ +export function hasSqlState(err: unknown, code: string): boolean { + let cursor: unknown = err; + for (let depth = 0; depth < 8; depth++) { + if (typeof cursor !== 'object' || cursor === null) { + return false; + } + if ((cursor as { code?: unknown }).code === code) { + return true; + } + cursor = (cursor as { cause?: unknown }).cause; + } + return false; +} + +/** The project row vanished under an insert that references it. */ +export function isForeignKeyViolation(err: unknown): boolean { + return hasSqlState(err, PG_FOREIGN_KEY_VIOLATION); +} + +/** Lost an insert race on a unique index (the add/add domains race). */ +export function isUniqueViolation(err: unknown): boolean { + return hasSqlState(err, PG_UNIQUE_VIOLATION); +} + +/** + * The constraint behind a unique violation, walked like hasSqlState: the + * `23505` code can sit on a nested cause (drizzle wraps the driver error), + * and the constraint name rides the same frame the code does. Both drivers + * here — PGlite and `pg` via pg-protocol — expose it as `constraint` + * (`constraint_name` is postgres.js naming, not used in this repo). An + * empty answer classifies as a zone race — the relay race is the + * destructive misclassification (its compensation would delete a worker + * another live row owns), so the name must be found wherever the driver + * put it. + */ +export function uniqueViolationConstraintName(err: unknown): string { + let cursor: unknown = err; + for (let depth = 0; depth < 8; depth++) { + if (typeof cursor !== 'object' || cursor === null) { + return ''; + } + const name = (cursor as { constraint?: unknown }).constraint; + if (typeof name === 'string' && name !== '') { + return name; + } + cursor = (cursor as { cause?: unknown }).cause; + } + return ''; +} diff --git a/platform/packages/api/src/platform-settings.ts b/platform/packages/api/src/platform-settings.ts new file mode 100644 index 000000000..5a37676ed --- /dev/null +++ b/platform/packages/api/src/platform-settings.ts @@ -0,0 +1,23 @@ +import { eq, sql } from 'drizzle-orm'; +import type { DbOrTx } from './db.ts'; +import { platformSettings } from './schema.ts'; + +/** Reads one installation setting; `null` when it was never written. */ +export async function getPlatformSetting(db: DbOrTx, key: string): Promise { + const rows = await db + .select({ value: platformSettings.value }) + .from(platformSettings) + .where(eq(platformSettings.key, key)) + .limit(1); + return rows[0]?.value ?? null; +} + +export async function setPlatformSetting(db: DbOrTx, key: string, value: unknown): Promise { + await db + .insert(platformSettings) + .values({ key, value }) + .onConflictDoUpdate({ + target: platformSettings.key, + set: { value, updated_at: sql`now()` }, + }); +} diff --git a/platform/packages/api/src/routes/email.ts b/platform/packages/api/src/routes/email.ts index 173316fc3..8b87b281f 100644 --- a/platform/packages/api/src/routes/email.ts +++ b/platform/packages/api/src/routes/email.ts @@ -18,43 +18,22 @@ import { type OwnerVerificationAudit, type Tx, } from '../db.ts'; -import { OAuthGrantError, createCfCustomerClient, revokeOauthGrant } from '../email/cf-customer.ts'; -import type { CfCustomerClient } from '../email/cf-customer.ts'; import { - EmailDomainCredentialChangedError, - refreshOauthGrantSerialized, - revokeOauthGrantSerialized, - withEmailDomainRowLock, -} from '../email/credential-lease.ts'; -import { - applyEmailDomainDeployCheckpoint, - clearEmailDomainMirror, - decryptEmailDomainRelaySecret, - decryptEmailDomainToken, - EmailDomainRowGoneError, - encryptEmailDomainRelaySecret, - encryptEmailDomainToken, - generateEmailRelaySecret, - hashEmailRelaySecret, - markEmailDomainRemovalFailed, - writeEmailDomainMirror, - type EmailDomain, - type EmailDomainMirror, -} from '../email/domains.ts'; -import { - ROUTING_SUBDOMAIN_STEP, - SENDING_ONBOARDING_STEP, - domainHasRoutingMx, - removeEmailDomain, - sendingOnboardedReadback, - setupEmailDomain, - syncEmailDomain, - type EmailDomainSetupResult, -} from '../email/relay-engine.ts'; -import { RELAY_VERSION } from '../email/relay-worker.ts'; + addEmailDomainForProject, + getEmailDomainStatusForProject, + removeEmailDomainForProject, + revokeRejectedGrant, + serializeEmailDomain, + syncEmailDomainForProject, +} from '../email/domain-service.ts'; import { sendPlatformVerificationEmail } from '../email/send-verification.ts'; +import { + getEmailDomainManagement, + getEmailRecipientPolicy, + repairEmailRecipientPolicyMirror, +} from '../email/settings.ts'; import type { ApiEnv } from '../env.ts'; -import { generateId } from '../id.ts'; +import { isForeignKeyViolation, isUniqueViolation } from '../pg-errors.ts'; import { projectAllowlistKvDiffers, syncProjectAllowlistKv, @@ -67,7 +46,7 @@ import { listDestinations, type CfDestinationAddress, } from '../provisioning/email-routing.ts'; -import { emailDomains, projects, type EmailDomainSetupState } from '../schema.ts'; +import { emailDomains } from '../schema.ts'; import type { EmailDestination } from '../types.ts'; /** @@ -99,17 +78,6 @@ const ADD_RATE_PER_DAY = 50; */ const MAX_ADDRESS_LENGTH = 320; -/** - * Caps for the custom-domain add route, checked on the RAW strings before - * normalization (same reasoning as MAX_ADDRESS_LENGTH). 253 is the DNS - * name cap from RFC 1035 — no legitimate domain exceeds it, IDN punycode - * included. The credential cap is generous for either kind (a pasted API - * token is ~50 chars; an OAuth refresh token a few hundred) and exists only - * to bound the encrypt + CF call work. - */ -const MAX_DOMAIN_LENGTH = 253; -const MAX_CREDENTIAL_LENGTH = 2048; - /** * Email observability + destinations management. * @@ -679,6 +647,33 @@ const app = new Hono() // ROUTING KV mirror the proxy reads (contract 2). Neither the credential // nor the relay secret is ever returned — serializers carry metadata only. // ------------------------------------------------------------------------- + + // Installation settings the CLI needs before it acts: who manages email + // domains here (an `admin` platform prints the administrator's command + // instead of opening a Cloudflare consent) and the shared-sender recipient + // policy. Read-only for owners; the operator API and admin UI write them. + .get('/:id/email/settings', async (context) => { + const projectId = context.req.param('id'); + const db = context.get('db'); + const project = await getProject(db, projectId); + if (!project) { + return context.json({ error: 'project not found' }, 404); + } + if (project.user_id !== context.get('user').id) { + return context.json({ error: 'forbidden' }, 403); + } + const [management, recipientPolicy] = await Promise.all([ + getEmailDomainManagement(db), + getEmailRecipientPolicy(db), + ]); + // Same best-effort repair the destinations listing does for its mirror. + await repairEmailRecipientPolicyMirror(db, context.env.ROUTING); + return context.json({ + domains: { management, projectSlug: project.slug }, + recipientPolicy: { mode: recipientPolicy.mode, domains: [...recipientPolicy.domains] }, + }); + }) + .get('/:id/email/domains', async (context) => { const projectId = context.req.param('id'); const project = await getProject(context.get('db'), projectId); @@ -724,7 +719,8 @@ const app = new Hono() typeof body.refreshToken === 'string' && body.refreshToken.length > 0 ? body.refreshToken : undefined; - const project = await getProject(context.get('db'), projectId); + const db = context.get('db'); + const project = await getProject(db, projectId); if (!project) { await revokeRejectedGrant(context, postedRefreshToken); return context.json({ error: 'project not found' }, 404); @@ -733,170 +729,13 @@ const app = new Hono() await revokeRejectedGrant(context, postedRefreshToken); return context.json({ error: 'forbidden' }, 403); } - // Same teardown guard the destinations POST uses — but after the parse, - // so it too can revoke the stranded grant. - if (project.status === 'deleting') { - await revokeRejectedGrant(context, postedRefreshToken); - return context.json({ error: 'Project is being deleted' }, 409); - } - // Length is checked on the RAW string before any normalization — same - // reason as MAX_ADDRESS_LENGTH above: the 400 costs nothing, the - // normalization and regex never run on attacker-sized input. - if (typeof body.domain !== 'string' || body.domain.length > MAX_DOMAIN_LENGTH) { - await revokeRejectedGrant(context, postedRefreshToken); - return context.json({ error: 'invalid domain' }, 400); - } - const domain = normalizeEmailDomain(body.domain); - if (!isValidEmailDomain(domain)) { - await revokeRejectedGrant(context, postedRefreshToken); - return context.json({ error: 'invalid domain' }, 400); - } - if (body.mode !== undefined && body.mode !== 'apex' && body.mode !== 'subdomain') { - await revokeRejectedGrant(context, postedRefreshToken); - return context.json({ error: 'invalid mode' }, 400); - } - // Optional attempt identity from the CLI (absent on older clients): when - // present it is stamped on the row so a CLI that lost this POST's answer - // can recognize its own commit. - if ( - body.attemptId !== undefined && - (typeof body.attemptId !== 'string' || - body.attemptId.length < 1 || - body.attemptId.length > 128) - ) { - await revokeRejectedGrant(context, postedRefreshToken); - return context.json({ error: 'invalid attemptId' }, 400); - } - const attemptId = body.attemptId as string | undefined; - const refreshToken = postedRefreshToken; - const apiToken = - typeof body.apiToken === 'string' && body.apiToken.length > 0 ? body.apiToken : undefined; - if ((refreshToken === undefined) === (apiToken === undefined)) { - await revokeRejectedGrant(context, postedRefreshToken); - return context.json({ error: 'exactly one of refreshToken or apiToken is required' }, 400); - } - const credential = (refreshToken ?? apiToken)!; - if (credential.length > MAX_CREDENTIAL_LENGTH) { - await revokeRejectedGrant(context, postedRefreshToken); - return context.json({ error: 'invalid credential' }, 400); - } - const credentialKind: EmailDomain['credential_kind'] = - refreshToken !== undefined ? 'oauth_grant' : 'api_token'; - const db = context.get('db'); - const proxyOrigin = proxyOriginForEmail(context.env); - - const existing = await getEmailDomainRow(db, projectId, domain); - if (existing) { - // "add again replaces the token, rules stay" (design doc, branch - // table) — the credential renewal path for token_expired / - // token_revoked rows, and the retry path for failed ones. - return await replaceEmailDomainCredential( - context, - projectId, - existing, - credentialKind, - credential, - proxyOrigin, - body.mode, - attemptId, - ); - } - - // A domain belongs to exactly one project: the proxy proves a relay - // secret against the single `email-domain:` mirror key, and two - // projects registering the same domain would flip-flop that key and - // misroute inbound mail. Reject before any credential work. - const claimed = await getEmailDomainRowByDomain(db, domain); - if (claimed && claimed.project_id !== projectId) { - await revokeRejectedGrant(context, postedRefreshToken, claimed); - return context.json({ error: 'domain is registered to another project' }, 409); - } - - // New domain. Persist the credential + a fresh relay secret FIRST: the - // oauth_grant setup refreshes THROUGH the row (the serialized dep locks - // and re-reads it), so the grant must be stored before the engine runs. - const relaySecret = generateEmailRelaySecret(); - const tokenEnc = await encryptEmailDomainToken(context.env, projectId, domain, credential); - const relayEnc = await encryptEmailDomainRelaySecret( - context.env, - projectId, - domain, - relaySecret, - ); - const id = generateId('edom'); - - // The insert runs under the PROJECT row's FOR UPDATE — teardown's own - // first lock (lockAndMarkDeleting, deleteProjectCascade) — with the - // 'deleting' verdict re-read inside it: an add admitted on the unlocked - // entry snapshot could otherwise insert and finish setup AFTER - // teardown's one-time cleanup enumerations ran, and the later cascade - // would delete the row without its domain handle — the mirror, the - // relay/catch-all in the customer account, and a fresh OAuth grant all - // orphaned. The engine then runs in the follow-up serialized section - // (runEmailDomainEngineSerialized), which re-takes this same lock, so - // teardown either marks first (both transactions refuse) or runs - // strictly after the committed setup and cleans everything it created. - type InsertOutcome = 'inserted' | 'project_gone' | 'project_deleting'; - let outcome: InsertOutcome; - try { - outcome = await db.transaction(async (tx): Promise => { - const projectRows = await tx - .select({ status: projects.status }) - .from(projects) - .where(eq(projects.id, projectId)) - .for('update') - .limit(1); - const lockedProject = projectRows[0]; - if (!lockedProject) { - return 'project_gone'; - } - if (lockedProject.status === 'deleting') { - return 'project_deleting'; - } - await tx.insert(emailDomains).values({ - id, - project_id: projectId, - domain, - credential_kind: credentialKind, - attempt_id: attemptId ?? null, - token_ciphertext: tokenEnc.ciphertext, - token_iv: tokenEnc.iv, - token_key_version: tokenEnc.keyVersion, - relay_ciphertext: relayEnc.ciphertext, - relay_iv: relayEnc.iv, - relay_key_version: relayEnc.keyVersion, - }); - return 'inserted'; - }); - } catch (err) { - if (isUniqueViolation(err)) { - // Lost an add/add race on the domain-wide unique index. The winner - // decides the error: another project claimed the domain, or this - // project registered it concurrently. The transaction rolled back, - // so the posted grant was never persisted — revoke it (the - // decrypt-compare guard spares a re-pasted grant the winner stored). - const winner = await getEmailDomainRowByDomain(db, domain); - await revokeRejectedGrant(context, postedRefreshToken, winner); - if (winner && winner.project_id !== projectId) { - return context.json({ error: 'domain is registered to another project' }, 409); - } - return context.json({ error: 'domain concurrently modified, try again' }, 409); - } - throw err; - } - if (outcome !== 'inserted') { - // Nothing was persisted — the posted grant has no handle. - await revokeRejectedGrant(context, postedRefreshToken); - return outcome === 'project_gone' - ? context.json({ error: 'project not found' }, 404) - : context.json({ error: 'Project is being deleted' }, 409); - } - - return await runEmailDomainEngineSerialized(context, projectId, id, proxyOrigin, { - relaySecret, - relayEnc, - requestedMode: body.mode, - created: true, + return addEmailDomainForProject(context, project, { + domain: typeof body.domain === 'string' ? body.domain : '', + mode: body.mode, + refreshToken: postedRefreshToken, + apiToken: typeof body.apiToken === 'string' ? body.apiToken : undefined, + attemptId: body.attemptId, + managedBy: 'owner', }); }) @@ -909,1581 +748,40 @@ const app = new Hono() if (project.user_id !== context.get('user').id) { return context.json({ error: 'forbidden' }, 403); } - - const domain = normalizeEmailDomain(context.req.param('domain')); - const db = context.get('db'); - const row = await getEmailDomainRow(db, projectId, domain); - if (!row) { - return context.json({ error: 'email domain not found' }, 404); - } - - // Re-probe with the decrypted credential. Probe failures never hide the - // stored view — the row answers with credential_status 'probe_failed', - // the same non-fatal shape as the destinations listing's CF refresh. - let credentialStatus: 'active' | 'expired' | 'revoked' | 'probe_failed'; - let probeClient: CfCustomerClient | null = null; - let tokenPatch: { token_id: string | null; token_expires_on: string | null } | null = null; - if (row.credential_kind === 'api_token') { - try { - const credential = await decryptEmailDomainToken(context.env, projectId, row.domain, row); - const client = createCfCustomerClient(credential); - const verification = await client.verifyToken(); - if (verification.status === 'active') { - credentialStatus = 'active'; - probeClient = client; - tokenPatch = { - token_id: verification.id, - token_expires_on: verification.expires_on ?? null, - }; - } else if (verification.status === 'expired') { - credentialStatus = 'expired'; - } else { - credentialStatus = 'revoked'; - } - } catch (err) { - console.error('[email] domain credential probe failed:', err); - credentialStatus = 'probe_failed'; - } - } else { - try { - // The ONLY probe a grant supports is one refresh round-trip. The - // rotation is persisted under the row lock inside; the access token - // rides the drift probe below and is then dropped. - const tokens = await refreshOauthGrantSerialized(db, context.env, row.id); - credentialStatus = 'active'; - probeClient = createCfCustomerClient(tokens.accessToken); - } catch (err) { - if (err instanceof EmailDomainCredentialChangedError) { - // A replace committed mid-probe — the probe is void, nothing - // was written, and the fresh re-read below answers for the - // replacement credential. - credentialStatus = 'probe_failed'; - } else if (err instanceof OAuthGrantError && err.code === 'invalid_grant') { - // The row was already marked token_revoked under the lock. - credentialStatus = 'revoked'; - } else { - console.error('[email] domain grant probe failed:', err); - credentialStatus = 'probe_failed'; - } - } - } - - // Persist what the probe learned. A dead credential flips the row (and, - // via the mirror repair below, the proxy's view) to token_expired / - // token_revoked; a healthy api_token refreshes token_id/expires_on. - // Probes never RESURRECT a row — re-running add is the renewal path. - const patch: { - status?: EmailDomain['status']; - token_id?: string | null; - token_expires_on?: string | null; - } = {}; - if (credentialStatus === 'expired' && row.status !== 'token_expired') { - patch.status = 'token_expired'; - } else if (credentialStatus === 'revoked' && row.status !== 'token_revoked') { - patch.status = 'token_revoked'; - } - if ( - tokenPatch && - (tokenPatch.token_id !== row.token_id || tokenPatch.token_expires_on !== row.token_expires_on) - ) { - patch.token_id = tokenPatch.token_id; - patch.token_expires_on = tokenPatch.token_expires_on; - } - if (Object.keys(patch).length > 0) { - // Fenced on the probed credential's identity: a replace committing - // mid-probe (renewing an expired token is the case that bites) must - // not have the OLD credential's verdict — status flip or token - // metadata — stamped on the NEW one. A fenced-out patch voids the - // probe: the fresh re-read answers, and the next poll re-probes. - const applied = await db - .update(emailDomains) - .set({ ...patch, updated_at: sql`now()` }) - .where( - and( - eq(emailDomains.id, row.id), - eq(emailDomains.credential_kind, row.credential_kind), - eq(emailDomains.token_ciphertext, row.token_ciphertext), - ), - ) - .returning({ id: emailDomains.id }); - if (applied.length === 0) { - credentialStatus = 'probe_failed'; - probeClient = null; - } - } - - let fresh = await getEmailDomainRowById(db, row.id); - if (!fresh) { - // Project teardown's cascade can take the row mid-request. - return context.json({ error: 'email domain not found' }, 404); - } - - // Self-clear recorded remaining steps on observable evidence. The - // routing-subdomain step's effect is public MX records naming - // Cloudflare's inbound exchanges; the sending-onboarding step's is - // the customer-credential read-back showing the subdomain onboarded - // (a Workers Paid upgrade plus a sync, which re-attempts the POST). - // No evidence — the probe failed or the step is genuinely undone — - // leaves the row for the next poll. The row flips pending → active - // only once no INBOUND-BLOCKING step (routing_subdomain) remains; a - // sending_onboarding step rides along on an active row. Suppressed - // while a removal failure is pending — the same doctrine as the - // mirror repair below: the row is mid-removal, and neither its - // status nor its setup state is this route's to rewrite. - const probedSetupState = fresh.setup_state; - const pendingSteps = probedSetupState?.remaining_steps; - if ( - probedSetupState && - probedSetupState.failed_step !== 'remove' && - pendingSteps && - pendingSteps.length > 0 - ) { - const cleared: Array = []; - if ( - pendingSteps.includes(ROUTING_SUBDOMAIN_STEP) && - (await domainHasRoutingMx(fresh.domain)) - ) { - cleared.push(ROUTING_SUBDOMAIN_STEP); - } - if ( - pendingSteps.includes(SENDING_ONBOARDING_STEP) && - probeClient !== null && - fresh.zone_id !== null && - (await sendingOnboardedReadback(probeClient, fresh.zone_id, fresh.domain)) - ) { - cleared.push(SENDING_ONBOARDING_STEP); - } - if (cleared.length > 0) { - const keptSteps = pendingSteps.filter((step) => !cleared.includes(step)); - // Fenced on the probed state: a revocation (or a removal-failure - // marker) landing while the evidence checks were in flight must - // not be overwritten back to a live state — the subsequent - // mirror repair would reauthorize inbound against it. A - // fenced-out update voids the self-clear: the re-read answers - // this poll, and the next poll re-proves the evidence. - await db - .update(emailDomains) - .set({ - ...(fresh.status === 'pending' && !keptSteps.includes(ROUTING_SUBDOMAIN_STEP) - ? { status: 'active' as const } - : {}), - setup_state: keptSteps.length > 0 ? { remaining_steps: keptSteps } : null, - updated_at: sql`now()`, - }) - .where( - and( - eq(emailDomains.id, fresh.id), - eq(emailDomains.status, fresh.status), - sql`${emailDomains.setup_state} IS NOT DISTINCT FROM ${JSON.stringify(probedSetupState)}::jsonb`, - ), - ); - fresh = await getEmailDomainRowById(db, row.id); - if (!fresh) { - return context.json({ error: 'email domain not found' }, 404); - } - } - } - - // Relay drift: the version the row recorded vs the embedded - // RELAY_VERSION, plus an existence probe — the customer can edit or - // delete the relay in their own account, and status reports drift - // rather than fighting it. - let relayMissing: boolean | null = null; - if (probeClient && fresh.account_id && fresh.relay_worker_name) { - try { - relayMissing = !(await probeClient.relayScriptExists( - fresh.account_id, - fresh.relay_worker_name, - )); - } catch (err) { - console.error('[email] relay existence probe failed:', err); - } - } - const relayDrift = fresh.relay_version !== RELAY_VERSION || relayMissing === true; - - // Catch-all drift probe — the same try/catch-and-report-never-fight - // doctrine as the relay probe above. Read-only: healing is the sync - // POST's job (its ensure runs in the engine). - let catchAll: 'points_here' | 'points_elsewhere' | 'missing' | 'disabled' | 'unknown' = - 'unknown'; - if (probeClient && fresh.zone_id && fresh.relay_worker_name) { - try { - const rule = await probeClient.getCatchAll(fresh.zone_id); - catchAll = - rule === null - ? 'missing' - : !rule.enabled - ? 'disabled' - : rule.workerName === fresh.relay_worker_name - ? 'points_here' - : 'points_elsewhere'; - } catch (err) { - console.error('[email] catch-all probe failed:', err); - } - } - const catchAllDrift = - (fresh.status === 'active' || fresh.status === 'pending') && - (catchAll === 'points_elsewhere' || catchAll === 'missing' || catchAll === 'disabled'); - - // This GET is the mirror's repair point (the same read-compare-write - // self-heal shape as the allowlist listing): probe transitions and any - // other drift land here; no difference means no write. Suppressed - // while a removal failure is pending — the row is mid-removal, and the - // proxy mirror must not be "repaired" against a state the operator is - // about to finish removing. - if (fresh.setup_state?.failed_step !== 'remove') { - await repairEmailDomainMirrorIfDrifted(db, context.env, fresh); - } - - return context.json({ - ...serializeEmailDomain(fresh), - credential_status: credentialStatus, - current_relay_version: RELAY_VERSION, - relay_drift: relayDrift, - relay_missing: relayMissing, - catch_all: catchAll, - catch_all_drift: catchAllDrift, - }); + return getEmailDomainStatusForProject(context, project, context.req.param('domain')); }) .post('/:id/email/domains/:domain/sync', async (context) => { const projectId = context.req.param('id'); - const project = await getProject(context.get('db'), projectId); + const db = context.get('db'); + const project = await getProject(db, projectId); if (!project) { return context.json({ error: 'project not found' }, 404); } if (project.user_id !== context.get('user').id) { return context.json({ error: 'forbidden' }, 403); } - // Sync mutates CF state and the row — same teardown guard as add/remove. - if (project.status === 'deleting') { - return context.json({ error: 'Project is being deleted' }, 409); - } - - const domain = normalizeEmailDomain(context.req.param('domain')); - const db = context.get('db'); - const row = await getEmailDomainRow(db, projectId, domain); - if (!row) { - return context.json({ error: 'email domain not found' }, 404); - } - - // Sync redeploys the relay in place at RELAY_VERSION and rotates its - // secret (overwrite, never read-compare — worker secrets are - // write-only). The row and credential are re-read inside the row - // lock, so a replace that lands while this request waits is synced - // as the NEW credential, never verified as the superseded one. - return syncEmailDomainAndApply(context, projectId, row.id, proxyOriginForEmail(context.env)); + return syncEmailDomainForProject(context, project, context.req.param('domain'), 'owner'); }) .delete('/:id/email/domains/:domain', async (context) => { const projectId = context.req.param('id'); - const project = await getProject(context.get('db'), projectId); + const db = context.get('db'); + const project = await getProject(db, projectId); if (!project) { return context.json({ error: 'project not found' }, 404); } if (project.user_id !== context.get('user').id) { return context.json({ error: 'forbidden' }, 403); } - if (project.status === 'deleting') { - return context.json({ error: 'Project is being deleted' }, 409); - } - - const domain = normalizeEmailDomain(context.req.param('domain')); - const db = context.get('db'); - const row = await getEmailDomainRow(db, projectId, domain); - if (!row) { - return context.json({ error: 'email domain not found' }, 404); - } - - const force = context.req.query('force') === '1'; - - // Remove under the row's serialized lock, with the row and credential - // re-read inside it: a replace that commits between this request's - // entry read and the cleanup must not have its fresh credential - // orphaned by the row delete — the replace never invalidates an old - // api_token, so cleanup built on a stale snapshot would SUCCEED, the - // row would be deleted, and a newly committed oauth grant's only - // handle would go with it (live at CF with its broad scopes, - // revocable by nobody). The lock serializes with the replace's commit - // and with lease refreshes; the serialized helpers take THIS tx — - // passing the pool handle here would deadlock. - // - // removeEmailDomain never throws; a decrypt failure skips CF cleanup - // entirely. A failed DESTRUCTIVE step (catch-all or relay delete) - // KEEPS the row so the operator can retry — unless forced, or the - // grant is already dead: token_revoked rows are always deleted (CF - // may be unreachable and keeping buys no retry leverage — re-add is - // the renewal path), and a 'revoke_grant'-only failure still deletes - // (a stranded grant breaks no mail). - type Removal = - | Readonly<{ kind: 'removed'; failedSteps: ReadonlyArray }> - | Readonly<{ kind: 'kept'; failedSteps: ReadonlyArray }> - | Readonly<{ kind: 'vanished' }>; - const removal: Removal = await withEmailDomainRowLock(db, row.id, async (tx) => { - const locked = await tx - .select() - .from(emailDomains) - .where(eq(emailDomains.id, row.id)) - .limit(1); - const current = locked[0]; - if (!current) { - // Teardown's cascade can take the row mid-request — the - // idempotent delete branch below proceeds. - return { kind: 'vanished' }; - } - let failedSteps: ReadonlyArray; - try { - const credential = await decryptEmailDomainToken( - context.env, - projectId, - current.domain, - current, - ); - const result = await removeEmailDomain( - { - row: { - credential_kind: current.credential_kind, - account_id: current.account_id, - relay_worker_name: current.relay_worker_name, - zone_id: current.zone_id, - }, - credential, - }, - { - // Refresh rotations must persist through the row's serialized - // chain while the row lives — in the keep-row world a failed - // removal KEEPS the row, and a bricked rotation would kill - // the retry. The revoke likewise goes through the chain: it - // revokes the row's LIVE head, so a lease refresh that - // committed before this removal cannot leave a rotated grant - // orphaned behind a 200-for-a-consumed-token. - refreshGrant: () => refreshOauthGrantSerialized(tx, context.env, current.id), - ...(current.credential_kind === 'oauth_grant' - ? { revokeGrant: () => revokeOauthGrantSerialized(tx, context.env, current.id) } - : {}), - }, - ); - failedSteps = result.failed_steps; - } catch (err) { - console.error('[email] domain remove credential decrypt failed:', err); - failedSteps = ['delete_relay', 'catch_all']; - } - - const destructive = failedSteps.some((s) => s === 'catch_all' || s === 'delete_relay'); - // Decide on the CURRENT status, not the pre-removal snapshot: an - // invalid_grant inside the removal's refresh persisted - // token_revoked through THIS tx, and a now-revoked row takes the - // unconditional-delete path exactly like one revoked before the - // request — keeping it would leave its stale active mirror - // accepting inbound with repair suppressed. The row cannot vanish - // here — this tx holds its lock. - const currentStatus = ( - await tx - .select({ status: emailDomains.status }) - .from(emailDomains) - .where(eq(emailDomains.id, current.id)) - .limit(1) - )[0]?.status; - if (destructive && !force && currentStatus !== 'token_revoked') { - await markEmailDomainRemovalFailed(tx, current.id, failedSteps); - return { kind: 'kept', failedSteps }; - } - - // Mirror key BEFORE the row — never destroy the record a retry - // would re-derive the mirror from (the destinations DELETE - // inversion). A KV failure rolls the transaction back with the row - // intact, so the retry can finish it. - await clearEmailDomainMirror(context.env.ROUTING, current.domain); - await tx.delete(emailDomains).where(eq(emailDomains.id, current.id)); - return { kind: 'removed', failedSteps }; - }); - - if (removal.kind === 'kept') { - return context.json({ ok: false, removed: false, failed_steps: removal.failedSteps }, 200); - } - if (removal.kind === 'vanished') { - // Teardown already cleared the mirror; the re-clear is idempotent. - await clearEmailDomainMirror(context.env.ROUTING, row.domain); - } - const failedSteps = removal.kind === 'removed' ? removal.failedSteps : []; - return context.json({ - ok: true, - removed: true, - ...(force && failedSteps.length > 0 ? { forced: true } : {}), - ...(failedSteps.length > 0 ? { failed_steps: failedSteps } : {}), - }); - }); - -/** - * The proxy origin baked into the relay worker as VOID_PROXY_ORIGIN — the - * same resolution the platform discovery document uses. - */ -function proxyOriginForEmail(env: ApiEnv['Bindings']): string { - const origin = - env.PROXY_URL ?? - (env.PLATFORM_ENV === 'staging' - ? 'https://proxy.staging.void.cloud' - : 'https://proxy.void.cloud'); - return origin.replace(/\/+$/, ''); -} - -function normalizeEmailDomain(raw: string): string { - return raw.trim().toLowerCase().replace(/\.$/, ''); -} - -/** - * DNS hostname shape: two or more labels, each 1–63 chars, alphanumeric + - * interior hyphens. Real validation is the engine's zone lookup — this - * keeps junk out of the encrypt and CF calls. - */ -function isValidEmailDomain(domain: string): boolean { - return /^(?!-)[a-z0-9-]{1,63}(? { - const rows = await db - .select() - .from(emailDomains) - .where(and(eq(emailDomains.project_id, projectId), eq(emailDomains.domain, domain))) - .limit(1); - return rows[0] ?? null; -} - -async function getEmailDomainRowById(db: DbOrTx, id: string): Promise { - const rows = await db.select().from(emailDomains).where(eq(emailDomains.id, id)).limit(1); - return rows[0] ?? null; -} - -/** - * Cross-project lookup: a domain is globally unique across projects - * (`email_domains_domain_uq`), because one domain produces exactly one - * `email-domain:` mirror key and one relay secret. - */ -async function getEmailDomainRowByDomain(db: Db, domain: string): Promise { - const rows = await db.select().from(emailDomains).where(eq(emailDomains.domain, domain)).limit(1); - return rows[0] ?? null; -} - -/** - * The public shape of an email_domains row — metadata only. Neither - * ciphertext triple nor any plaintext secret ever appears here. - */ -function serializeEmailDomain(row: EmailDomain, error?: string) { - return { - id: row.id, - domain: row.domain, - mode: row.mode, - status: row.status, - credential_kind: row.credential_kind, - relay_worker_name: row.relay_worker_name, - relay_version: row.relay_version, - token_id: row.token_id, - token_expires_on: row.token_expires_on, - // Always present (null when the row predates attempt identity): key - // presence is how a reconciling CLI detects an api that stamps it. - attemptId: row.attempt_id ?? null, - setup_state: row.setup_state, - // The engine's failure text names the failing step / CF error — never - // credential material — so the CLI can print it verbatim. - ...(error === undefined ? {} : { error }), - created_at: row.created_at, - updated_at: row.updated_at, - }; -} - -/** - * Persist the engine's setup outcome onto the row and return the final - * row. Never touches the token_* triple: the caller wrote it at insert, - * and any rotation since was persisted by the serialized refresh under the - * row lock. - */ -async function applyEmailDomainSetupResult( - db: DbOrTx, - id: string, - result: EmailDomainSetupResult, - relay?: { ciphertext: string; iv: string; keyVersion: string }, -): Promise { - await db - .update(emailDomains) - .set({ - status: result.status, - zone_id: result.zone_id, - account_id: result.account_id, - mode: result.mode, - relay_worker_name: result.relay_worker_name, - relay_version: result.relay_version, - token_id: result.token_id, - token_expires_on: result.token_expires_on, - setup_state: result.setup_state, - ...(relay === undefined - ? {} - : { - relay_ciphertext: relay.ciphertext, - relay_iv: relay.iv, - relay_key_version: relay.keyVersion, - }), - updated_at: sql`now()`, - }) - .where(eq(emailDomains.id, id)); - const row = await getEmailDomainRowById(db, id); - if (!row) { - throw new Error('email_domains row vanished mid-setup'); - } - return row; -} - -/** - * The 23505 loser's best-effort CF compensation, shared by the apply-time - * backstop (applyEmailDomainSetupResultOrConflict) and the deploy-time - * checkpoint (persistProgress): both lose a zone (or one account's relay - * name) to a setup that already passed the engine's CF-side reads — - * first-to-persist wins. - * - * The relay-NAME branch is a safety net, not a live path: fresh setups - * CLAIM the name on their own row (the claimRelayName dep) BEFORE any - * upload, so a checkpoint-time relay-name 23505 is no longer reachable - * from the setup flow — the claim itself is the only relay-name 23505 - * left, and the walk absorbs it as 'taken'. The branch stays for rows - * written before the claim existed and as defense in depth. - * - * Works from what the engine RETURNED or checkpointed (the loser's row - * still holds nulls — a claimed name is undone before this runs). A - * relay-NAME conflict never deletes the worker — - * the name is contested, the winner is using it — so the compensation - * runs with account_id nulled: only the loser's zone rule is detached. On - * a ZONE conflict the winner is looked up and an owned catch-all is - * REPOINTED at its relay: the loser's conditional reset only detaches a - * rule naming its OWN relay, so without a repoint target the zone's rule - * dies with the loser; repointing hands it to the winner instead. (At - * checkpoint-time conflicts the loser never wrote its rule, so the - * repoint probe is a harmless no-op — one code path.) A failed winner - * lookup degrades to the plain reset. Deliberately NO revokeGrant dep: - * the loser's row/credential may survive — conflict compensation is not - * removal. - */ -async function compensateEmailDomainRace( - context: Context, - loserRowId: string, - race: { - credentialKind: EmailDomain['credential_kind']; - credential: string; - }, - loser: { - zone_id: string | null; - account_id: string | null; - relay_worker_name: string | null; - }, - err: unknown, -): Promise { - const relayRace = uniqueViolationConstraintName(err).includes('relay'); - let repointTo: string | null = null; - if (!relayRace && loser.zone_id !== null) { - try { - const winners = await context - .get('db') - .select() - .from(emailDomains) - .where( - and( - eq(emailDomains.zone_id, loser.zone_id), - // The exact live-status set of the schema's - // email_domains_zone_uq partial index — the rows this loser - // lost to. - inArray(emailDomains.status, ['active', 'pending']), - isNotNull(emailDomains.relay_worker_name), - ne(emailDomains.id, loserRowId), - ), - ) - .limit(1); - repointTo = winners[0]?.relay_worker_name ?? null; - } catch (lookupErr) { - console.error('[email] race winner lookup failed; falling back to reset:', lookupErr); - } - } - try { - await removeEmailDomain( - { - row: { - credential_kind: race.credentialKind, - // A contested relay name is never deleted (see the header). - account_id: relayRace ? null : loser.account_id, - relay_worker_name: loser.relay_worker_name, - zone_id: loser.zone_id, - }, - credential: race.credential, - ...(repointTo === null ? {} : { repoint_catch_all_to: repointTo }), - }, - { - // Grant refresh tokens are single-use: the compensation's refresh - // must persist its rotation through the row's serialized chain, - // or the cleanup would brick the credential it leaves behind. - refreshGrant: () => refreshOauthGrantSerialized(context.get('db'), context.env, loserRowId), - }, - ); - } catch (compensationErr) { - // removeEmailDomain never throws; this is paranoia for the log only. - console.error('[email] race compensation failed:', compensationErr); - } - return relayRace - ? 'another email domain is deploying the same relay worker — retry' - : 'another active email domain already uses this zone — remove it first'; -} - -/** - * Run one fallible statement of the serialized engine section behind a - * savepoint, so an expected constraint violation — the first-to-persist - * races the partial unique indexes exist to catch — aborts only that - * statement. The transaction stays committable, keeping the row and any - * grant rotations the engine already persisted through the row's - * serialized chain: without this a 23505 would poison the whole - * transaction, and the rollback would strand the rotated chain head (the - * only copy CF issued) with no database handle, breaking the race - * compensation's refresh-through-the-row. - */ -async function tryEmailDomainSavepoint( - tx: Tx, - name: string, - fn: () => Promise, -): Promise<{ ok: true; value: T } | { ok: false; err: unknown }> { - await tx.execute(sql.raw(`SAVEPOINT ${name}`)); - try { - const value = await fn(); - await tx.execute(sql.raw(`RELEASE SAVEPOINT ${name}`)); - return { ok: true, value }; - } catch (err) { - await tx.execute(sql.raw(`ROLLBACK TO SAVEPOINT ${name}`)); - return { ok: false, err }; - } -} - -/** - * Best-effort revocation of a POSTed OAuth grant the request is rejecting - * before persistence. The CLI mints a fresh user-wide grant (zone, email - * and workers scopes) per add attempt and drops its only copy on an error - * response, so a rejection without a revoke strands a live broad grant in - * the customer's account per retry. api_token credentials are out of - * scope: user-created out-of-band, and no token-delete primitive exists. - * - * `holder` is the conflicting row when one is in hand: a re-pasted grant - * equal to the row's stored credential must NOT be revoked — that would - * brick the live row (the same guard as the replace route's - * identical-grant skip). A holder whose credential can't be decrypted - * fails safe toward keeping. - */ -async function revokeRejectedGrant( - context: Context, - refreshToken: string | undefined, - holder?: EmailDomain | null, -): Promise { - if (refreshToken === undefined) { - return; - } - if (holder) { - let held: string | null = null; - try { - held = await decryptEmailDomainToken(context.env, holder.project_id, holder.domain, holder); - } catch (err) { - console.error('[email] rejected-grant holder decrypt failed; skipping revoke:', err); - return; - } - if (held === refreshToken) { - return; - } - } - try { - await revokeOauthGrant(refreshToken); - } catch (err) { - // Best-effort: the rejection stands either way, and this log line is - // the trace an operator gets. - console.error('[email] rejected-grant revoke failed:', err); - } -} - -/** - * Best-effort compensation for a checkpoint write that failed for any - * non-race reason (a DB error, or the row cascade-deleted by teardown - * mid-setup): the relay and its secret are deployed but the row carries - * no reference to them, so remove them from the captured checkpoint - * payload before the error propagates. The catch-all was never written - * (the checkpoint precedes it), so the conditional reset is a no-op. - * `rowGone` switches the refresh to the engine default, which acts on the - * held credential directly — the serialized chain needs the row. - */ -async function compensateCheckpointDeploy( - context: Context, - rowId: string, - race: { credentialKind: EmailDomain['credential_kind']; credential: string }, - checkpoint: { zone_id: string | null; account_id: string | null; relay_worker_name: string }, - opts: { rowGone: boolean }, -): Promise { - try { - await removeEmailDomain( - { - row: { - credential_kind: race.credentialKind, - account_id: checkpoint.account_id, - relay_worker_name: checkpoint.relay_worker_name, - zone_id: checkpoint.zone_id, - }, - credential: race.credential, - }, - opts.rowGone - ? {} - : { - // Same single-use refresh doctrine as the race compensation. - refreshGrant: () => refreshOauthGrantSerialized(context.get('db'), context.env, rowId), - }, - ); - } catch (compErr) { - console.error('[email] checkpoint-failure compensation failed:', compErr); - } -} - -/** - * The sync sequence — relay redeploy, secret rotation, row apply, mirror - * publication — under one transaction holding the project lock and then - * the credential chain's row lock, shared by the sync route and the - * replace path's healthy-row branch. The row lock must span the worker - * secret PUT, the row persist, and the mirror PUT: two overlapping syncs - * would otherwise interleave them (A-put, B-put, B-apply, A-apply) and - * leave the worker or the mirror holding a secret the other side no - * longer names — inbound authentication fails until the next sync. The - * row FOR UPDATE is the lease chain's own, held across the CF calls - * exactly like the refresh holds it across the token round-trip; - * concurrent sends on an oauth_grant domain wait out the sync, which is - * rare and manual. - * - * The row — credential included — is RE-READ inside the lock: a replace - * that committed while this request waited for the lock is synced as its - * new credential, never verified as the superseded one (whose expired or - * revoked verdict would otherwise be stamped onto the replacement). The - * mirror PUT runs inside the serialized section even though KV is not - * transactional: a commit failure after it leaves the mirror briefly - * ahead of the row, which the status GET's drift repair converges — the - * inverse (publishing outside the lock) would let an older sync's hash - * land last and break inbound auth until someone polls. - */ -async function syncEmailDomainAndApply( - context: Context, - projectId: string, - rowId: string, - proxyOrigin: string, -): Promise { - const db = context.get('db'); - let outcome: - | Readonly<{ - kind: 'ok'; - result: Awaited>; - applied: EmailDomain; - }> - | Readonly<{ kind: 'project_gone' }> - | Readonly<{ kind: 'project_deleting' }>; - try { - outcome = await db.transaction(async (tx) => { - // The project lock comes FIRST — teardown's own order - // (lockAndMarkDeleting, deleteProjectCascade and the status route's - // mirror-repair fence all go project → row) — so a sync serializes - // with teardown instead of racing it. Without this recheck a sync - // that passed the route's entry guard could redeploy the relay and - // republish the mirror AFTER teardown cleared them, and the cascade - // would then delete the row, leaving a recreated mirror, worker and - // catch-all with no database handle. Holding the project lock - // across the CF calls blocks a teardown for the duration — the same - // tradeoff the mirror-repair fence makes, and safe: when the sync - // commits first, teardown's later clear/remove/cascade sees - // everything it created. - const projectRows = await tx - .select({ status: projects.status }) - .from(projects) - .where(eq(projects.id, projectId)) - .for('update') - .limit(1); - const project = projectRows[0]; - if (!project) { - return { kind: 'project_gone' } as const; - } - if (project.status === 'deleting') { - return { kind: 'project_deleting' } as const; - } - // The row lock is the lease chain's own FOR UPDATE — refreshes, - // revokes, replaces and removals serialize on it exactly as before. - const locked = await tx - .select() - .from(emailDomains) - .where(eq(emailDomains.id, rowId)) - .for('update') - .limit(1); - const current = locked[0]; - if (!current) { - // Teardown's cascade can take the row while this request waited - // on the lock. - throw new Error('email_domains row vanished mid-sync'); - } - const { result, applied } = await syncEmailDomainLocked( - context, - tx, - projectId, - current, - proxyOrigin, - ); - return { kind: 'ok', result, applied } as const; - }); - } catch (err) { - if (isUniqueViolation(err)) { - // NO compensation here — sync deployed in place on the row's OWN - // relay, so the 409 just refuses the live-status flip: a 'failed' - // row carrying its zone can collide with the winner when sync - // flips it back to a live status. - return context.json( - { - error: uniqueViolationConstraintName(err).includes('relay') - ? 'another email domain is deploying the same relay worker — retry' - : 'another active email domain already uses this zone — remove it first', - }, - 409, - ); - } - throw err; - } - - if (outcome.kind === 'project_gone') { - return context.json({ error: 'project not found' }, 404); - } - if (outcome.kind === 'project_deleting') { - return context.json({ error: 'Project is being deleted' }, 409); - } - return context.json( - { - ...serializeEmailDomain(outcome.applied, outcome.result.error), - ...(outcome.result.catch_all === undefined ? {} : { catch_all: outcome.result.catch_all }), - }, - 200, - ); -} - -/** - * The sync sequence's locked core, shared by syncEmailDomainAndApply and - * the serialized engine section's healthy-row branch. Callers hold the - * project lock and the row's FOR UPDATE; `current` is the row as re-read - * under those locks — credential included, so a replace that committed - * while the caller waited is synced as its new credential. - */ -async function syncEmailDomainLocked( - context: Context, - tx: Tx, - projectId: string, - current: EmailDomain, - proxyOrigin: string, -): Promise<{ - result: Awaited>; - applied: EmailDomain; -}> { - const domain = current.domain; - const credential = await decryptEmailDomainToken(context.env, projectId, domain, current); - const result = await syncEmailDomain( - { - row: { - domain, - credential_kind: current.credential_kind, - account_id: current.account_id, - relay_worker_name: current.relay_worker_name, - zone_id: current.zone_id, - // Lets sync re-attempt a pending sending onboarding (a - // Workers Paid upgrade) and report the cleared step. - setup_remaining_steps: current.setup_state?.remaining_steps, - }, - credential, - proxyOrigin, - }, - { refreshGrant: () => refreshOauthGrantSerialized(tx, context.env, current.id) }, - ); - - let relaySecretSha256: string; - const relayPatch: { - relay_ciphertext?: string; - relay_iv?: string; - relay_key_version?: string; - } = {}; - if (result.new_relay_secret !== undefined) { - const relayEnc = await encryptEmailDomainRelaySecret( - context.env, - projectId, - domain, - result.new_relay_secret, - ); - relayPatch.relay_ciphertext = relayEnc.ciphertext; - relayPatch.relay_iv = relayEnc.iv; - relayPatch.relay_key_version = relayEnc.keyVersion; - relaySecretSha256 = await hashEmailRelaySecret(result.new_relay_secret); - } else { - // No rotation happened (failed before the secret PUT) — the - // mirror still moves with the new status, hashed from the - // existing secret. - relaySecretSha256 = await hashEmailRelaySecret( - await decryptEmailDomainRelaySecret(context.env, projectId, domain, current), - ); - } - // A successful sync does not clear a pending human step on its own: - // the engine cannot observe the routing-subdomain dashboard form, - // so remaining_steps survive — minus any the sync itself cleared - // (the sending-onboarding retry). The row stays 'pending' only - // while an INBOUND-BLOCKING step (routing_subdomain) remains; a - // sending_onboarding step rides along on an active row. - const keptSteps = current.setup_state?.remaining_steps?.filter( - (step) => !(result.cleared_steps ?? []).includes(step), - ); - const setupState: EmailDomainSetupState | null = - result.setup_state !== null - ? result.setup_state - : keptSteps && keptSteps.length > 0 - ? { remaining_steps: keptSteps } - : null; - const status: EmailDomain['status'] = - result.status === 'active' && setupState?.remaining_steps?.includes(ROUTING_SUBDOMAIN_STEP) - ? 'pending' - : result.status; - const appliedRows = await tx - .update(emailDomains) - .set({ - status, - relay_version: result.relay_version, - setup_state: setupState, - ...relayPatch, - updated_at: sql`now()`, - }) - .where(eq(emailDomains.id, current.id)) - .returning(); - const applied = appliedRows[0]; - if (!applied) { - throw new Error('email_domains row vanished mid-sync'); - } - // Mirror publication stays inside the serialized section: an - // overlapping sync publishes its own rotation behind this lock, so - // hashes land in the same order the secrets were written to the worker. - await writeEmailDomainMirror(context.env.ROUTING, applied, relaySecretSha256); - return { result, applied }; -} - -/** - * The serialized engine section shared by the add route (after its insert - * transaction) and the replace route (after its credential commit). Both - * locks are held across the engine's CF calls, the apply, and the mirror - * publication: the PROJECT row FOR UPDATE first — teardown's own order - * (lockAndMarkDeleting, deleteProjectCascade and the status route's - * mirror-repair fence all go project → row) — then the credential chain's - * row FOR UPDATE. The project fence gives a total order with teardown: - * either teardown marked first (this refuses) or this commits first and - * teardown's later cleanup sees everything the engine created — an - * unfenced setup could otherwise finish AFTER teardown's one-time - * enumerations ran, and the cascade would delete the row without its - * domain handle, orphaning the mirror, the relay/catch-all in the - * customer account, and a fresh OAuth grant. - * - * The row — credential and recorded progress included — is RE-READ under - * the lock, and the branch is decided there: a COMPLETE row takes the - * sync path (redeploy in place + relay-secret rotation); an incomplete - * one re-runs the full engine seeded from the locked row. The row lock - * serializes concurrent full-setup retries: without it two of them could - * upload DIFFERENT relay secrets to the same relay and then apply their - * rows/mirrors in the opposite order — the worker holding secret B while - * KV expects A, rejecting every inbound message — or strand competing - * relay names. - * - * The fallible row writes (deploy checkpoint, final apply) run behind - * savepoints (tryEmailDomainSavepoint) so their expected 23505s abort - * only the one statement: the transaction then COMMITS the row and the - * engine's grant rotations, and the loser compensation below works - * through the row's live chain head exactly as it did when every write - * autocommitted. Holding the project lock across the CF calls blocks a - * teardown for the duration — the same tradeoff sync and the - * mirror-repair fence make. - */ -async function runEmailDomainEngineSerialized( - context: Context, - projectId: string, - rowId: string, - proxyOrigin: string, - opts: Readonly<{ - relaySecret: string; - relayEnc: { ciphertext: string; iv: string; keyVersion: string }; - requestedMode: unknown; - created: boolean; - }>, -): Promise { - const db = context.get('db'); - const { relaySecret, relayEnc, requestedMode, created } = opts; - // Captured BEFORE each checkpoint write: a 23505 throw still needs the - // progress payload for compensation below. - let checkpoint: { - zone_id: string | null; - account_id: string | null; - mode: 'apex' | 'subdomain' | null; - relay_worker_name: string; - } | null = null; - // Set when the engine's name-walk claimed a relay name on the row, so - // an engine throw can restore the row's locked pre-engine values — the - // claim commits with this transaction even when the checkpoint's - // savepoint rolls back, and a leaked entry would park the name while - // pointing the row at a worker the compensation may have deleted. - let relayNameClaimed = false; - type EngineOutcome = - | Readonly<{ - kind: 'ok_sync'; - result: Awaited>; - applied: EmailDomain; - }> - | Readonly<{ kind: 'ok_setup'; result: EmailDomainSetupResult; applied: EmailDomain }> - | Readonly<{ kind: 'project_gone' }> - | Readonly<{ kind: 'project_deleting' }> - | Readonly<{ kind: 'row_gone' }> - | Readonly<{ - kind: 'engine_failed'; - err: unknown; - race: { credentialKind: EmailDomain['credential_kind']; credential: string }; - }> - | Readonly<{ - kind: 'apply_failed'; - err: unknown; - result: EmailDomainSetupResult; - race: { credentialKind: EmailDomain['credential_kind']; credential: string }; - }>; - let outcome: EngineOutcome; - try { - outcome = await db.transaction(async (tx): Promise => { - const projectRows = await tx - .select({ status: projects.status }) - .from(projects) - .where(eq(projects.id, projectId)) - .for('update') - .limit(1); - const lockedProject = projectRows[0]; - if (!lockedProject) { - return { kind: 'project_gone' } as const; - } - if (lockedProject.status === 'deleting') { - return { kind: 'project_deleting' } as const; - } - // The row lock is the lease chain's own FOR UPDATE — refreshes, - // revokes, replaces and removals serialize on it exactly as before. - const locked = await tx - .select() - .from(emailDomains) - .where(eq(emailDomains.id, rowId)) - .for('update') - .limit(1); - const current = locked[0]; - if (!current) { - // An owner DELETE (or the admin revoke) took the row while this - // request waited on the lock; its cleanup owned the credential. - return { kind: 'row_gone' } as const; - } - - // Sync keeps a healthy row healthy; anything that never finished - // setup re-runs the full engine. A catch-all failure is the one - // 'failed' shape that still carries relay_worker_name/account_id — - // it must NOT take the sync path, which never writes the catch-all. - const setupIncomplete = - current.status === 'failed' || !current.relay_worker_name || !current.account_id; - if (!setupIncomplete) { - const { result, applied } = await syncEmailDomainLocked( - context, - tx, - projectId, - current, - proxyOrigin, - ); - return { kind: 'ok_sync', result, applied } as const; - } - - // Setup never finished — re-run the full engine with a fresh relay - // secret, carrying the row's recorded progress so completed steps - // are idempotent (mode skips the MX check; a relay name redeploys in - // place; 'already done' 4xx is believed only on a confirming - // read-back). - const domain = current.domain; - const credential = await decryptEmailDomainToken(context.env, projectId, domain, current); - const race = { credentialKind: current.credential_kind, credential }; - let result: EmailDomainSetupResult | null = null; - let engineError: unknown = null; - try { - result = await setupEmailDomain( - { - row: { - domain, - credential_kind: current.credential_kind, - ...(current.mode === null ? {} : { mode: current.mode }), - ...(current.relay_worker_name === null - ? {} - : { relay_worker_name: current.relay_worker_name }), - ...(current.zone_id === null ? {} : { zone_id: current.zone_id }), - ...(current.account_id === null ? {} : { account_id: current.account_id }), - }, - credential, - relaySecret, - proxyOrigin, - }, - { - refreshGrant: () => refreshOauthGrantSerialized(tx, context.env, current.id), - claimRelayName: async (accountId, candidate) => { - // Reserve (account_id, candidate) on THIS row BEFORE any CF - // upload — first-to-claim turns the relay race into a walk - // skip: a concurrent setup's claim of the same name blocks - // on this uncommitted UPDATE, then 23505s past the winner's - // commit ('taken' → the walk advances) or proceeds past its - // rollback. The savepoint keeps a contested claim from - // poisoning the transaction (same machinery as the - // checkpoint). - const claim = await tryEmailDomainSavepoint(tx, 'void_email_relay_claim', () => - tx - .update(emailDomains) - .set({ account_id: accountId, relay_worker_name: candidate }) - .where(eq(emailDomains.id, current!.id)), - ); - if (claim.ok) { - relayNameClaimed = true; - return 'claimed' as const; - } - if (isUniqueViolation(claim.err)) { - return 'taken' as const; - } - throw claim.err; - }, - persistProgress: async (progress) => { - // Capture BEFORE the write: a 23505 throw still needs the - // checkpoint for compensation below. The savepoint keeps the - // transaction committable (see tryEmailDomainSavepoint). - checkpoint = progress; - const write = await tryEmailDomainSavepoint(tx, 'void_email_deploy_checkpoint', () => - applyEmailDomainDeployCheckpoint(tx, current!.id, progress), - ); - if (!write.ok) { - throw write.err; - } - }, - }, - ); - } catch (err) { - // The savepoint already rolled the failed statement back, so the - // transaction can still commit the row and the engine's grant - // rotations — the compensation outside then refreshes through the - // row's live chain head. - engineError = err; - } - if (result === null) { - // The engine threw AFTER a successful claim (the checkpoint's - // 23505 or a non-race failure): restore the row's locked - // pre-engine values so the claim doesn't outlive the setup it - // belonged to. Before the claim existed, the checkpoint's - // savepoint rollback left the row exactly this way. - if (relayNameClaimed) { - await tx - .update(emailDomains) - .set({ - account_id: current.account_id, - relay_worker_name: current.relay_worker_name, - }) - .where(eq(emailDomains.id, current.id)); - } - return { kind: 'engine_failed', err: engineError, race } as const; - } - const applied = await tryEmailDomainSavepoint(tx, 'void_email_apply', () => - applyEmailDomainSetupResult(tx, current.id, result, relayEnc), - ); - if (!applied.ok) { - return { kind: 'apply_failed', err: applied.err, result, race } as const; - } - // Mirror AFTER the row is final, and inside the serialized section: - // an overlapping retry publishes its own setup behind this lock, so - // hashes land in the same order the secrets were written to the - // worker. - await writeEmailDomainMirror( - context.env.ROUTING, - applied.value, - await hashEmailRelaySecret(relaySecret), - ); - return { kind: 'ok_setup', result, applied: applied.value } as const; - }); - } catch (err) { - if (isUniqueViolation(err)) { - // NO compensation here — the sync branch deployed in place on the - // row's OWN relay, so the 409 just refuses the live-status flip: a - // 'failed' row carrying its zone can collide with the winner when - // sync flips it back to a live status. - return context.json( - { - error: uniqueViolationConstraintName(err).includes('relay') - ? 'another email domain is deploying the same relay worker — retry' - : 'another active email domain already uses this zone — remove it first', - }, - 409, - ); - } - throw err; - } - - if (outcome.kind === 'project_gone') { - return context.json({ error: 'project not found' }, 404); - } - if (outcome.kind === 'project_deleting') { - return context.json({ error: 'Project is being deleted' }, 409); - } - if (outcome.kind === 'row_gone') { - return context.json({ error: 'email domain not found' }, 404); - } - if (outcome.kind === 'engine_failed') { - const err = outcome.err; - if (isUniqueViolation(err) && checkpoint !== null) { - const conflict = await compensateEmailDomainRace( - context, - rowId, - outcome.race, - checkpoint, - err, - ); - return context.json({ error: conflict }, 409); - } - // Any other checkpoint failure leaves the deployed relay unreferenced - // (the savepoint rolled the UPDATE back): compensate from the captured - // checkpoint before propagating. - if (checkpoint !== null) { - await compensateCheckpointDeploy(context, rowId, outcome.race, checkpoint, { - rowGone: err instanceof EmailDomainRowGoneError, - }); - } - if (err instanceof EmailDomainRowGoneError) { - return context.json({ error: 'project is being deleted' }, 409); - } - throw err; - } - if (outcome.kind === 'apply_failed') { - const { err, result } = outcome; - if (isUniqueViolation(err)) { - // The concurrent-setup backstop (the schema's partial unique - // indexes): two setups racing one zone both pass the engine's - // CF-side reads, so the loser is caught here — a 409, never a - // falsely-active second row. (A relay-NAME 23505 is no longer - // reachable from a fresh setup — the pre-upload claim absorbs it — - // but the mapping stays as a safety net.) The row stays in its - // pre-apply state; the loser's CF-side work is compensated - // best-effort inside. - const conflict = await compensateEmailDomainRace( - context, - rowId, - outcome.race, - { - zone_id: result.zone_id, - account_id: result.account_id, - relay_worker_name: result.relay_worker_name, - }, - err, - ); - return context.json({ error: conflict }, 409); - } - // A NON-unique apply failure after the engine deployed CF-side: the - // savepoint makes the apply decisively uncommitted, so the - // possibly-deployed relay is compensated best-effort — no orphan - // worker for a zone the row never recorded. Nothing to clean when - // setup failed before the deploy. - if (result.relay_worker_name !== null) { - try { - // Not a name race — the loser owns its relay, so the compensation - // runs with the result's OWN account_id (not nulled). - await removeEmailDomain( - { - row: { - credential_kind: outcome.race.credentialKind, - account_id: result.account_id, - relay_worker_name: result.relay_worker_name, - zone_id: result.zone_id, - }, - credential: outcome.race.credential, - }, - { - // Same single-use refresh doctrine as the race compensation. - refreshGrant: () => refreshOauthGrantSerialized(context.get('db'), context.env, rowId), - }, - ); - } catch (compErr) { - console.error('[email] orphan compensation failed:', compErr); - } - } - throw err; - } - if (outcome.kind === 'ok_sync') { - return context.json( - { - ...serializeEmailDomain(outcome.applied, outcome.result.error), - ...(outcome.result.catch_all === undefined ? {} : { catch_all: outcome.result.catch_all }), - }, - 200, - ); - } - const { result, applied: row } = outcome; - if (requestedMode !== undefined && row.mode !== null && requestedMode !== row.mode) { - return context.json( - { - error: `requested mode ${String(requestedMode)} but ${row.domain} is ${row.mode} on its zone`, - domain: serializeEmailDomain(row, result.error), - }, - 409, - ); - } - return context.json(serializeEmailDomain(row, result.error), created ? 201 : 200); -} - -/** - * POST /domains on an existing row — "add again replaces the token, rules - * stay" (design doc, branch table). The new credential is stored first - * because the oauth_grant engine path refreshes THROUGH the row. Healthy - * rows (active / pending / credential renewal) take the sync path — - * redeploy in place + rotate the relay secret. A row that never finished - * setup re-runs the FULL engine with a fresh relay secret: status - * 'failed', or the relay/account fields missing. The engine is - * re-runnable against the row's recorded progress, so a catch-all - * failure gets its rule written rather than just re-deploying the relay. - * Both branches run inside runEmailDomainEngineSerialized, decided on the - * LOCKED row. - */ -async function replaceEmailDomainCredential( - context: Context, - projectId: string, - existing: EmailDomain, - credentialKind: EmailDomain['credential_kind'], - credential: string, - proxyOrigin: string, - requestedMode: unknown, - attemptId: string | undefined, -): Promise { - const db = context.get('db'); - const domain = existing.domain; - const tokenEnc = await encryptEmailDomainToken(context.env, projectId, domain, credential); - // Capture the old grant's CURRENT chain head and commit the replacement - // under one row lock: an interleaved lease refresh between an unlocked - // capture and the commit would rotate the old grant, the commit would - // overwrite the rotated head it persisted, and the revoke below would - // submit the already-consumed token — CF answers 200 either way, so the - // live chain head would be orphaned with no database handle. After the - // commit the row holds the NEW credential, so no old-chain rotation can - // follow. - let oldGrantRefreshToken: string | null = null; - type SwapOutcome = 'swapped' | 'project_gone' | 'project_deleting' | 'row_gone'; - const swap: SwapOutcome = await db.transaction(async (tx): Promise => { - // The PROJECT row lock comes first — teardown's own order — with the - // 'deleting' verdict re-read under it: a replace admitted on the - // route's unlocked entry read could otherwise commit a fresh grant - // AFTER teardown's one-time cleanup enumeration ran, and the cascade - // would then delete the row with the new grant's only handle. - const projectRows = await tx - .select({ status: projects.status }) - .from(projects) - .where(eq(projects.id, projectId)) - .for('update') - .limit(1); - const lockedProject = projectRows[0]; - if (!lockedProject) { - return 'project_gone'; - } - if (lockedProject.status === 'deleting') { - return 'project_deleting'; - } - const locked = await tx - .select({ - credential_kind: emailDomains.credential_kind, - token_ciphertext: emailDomains.token_ciphertext, - token_iv: emailDomains.token_iv, - token_key_version: emailDomains.token_key_version, - }) - .from(emailDomains) - .where(eq(emailDomains.id, existing.id)) - .for('update') - .limit(1); - const current = locked[0]; - if (!current) { - return 'row_gone'; - } - if (current.credential_kind === 'oauth_grant') { - try { - oldGrantRefreshToken = await decryptEmailDomainToken(context.env, projectId, domain, { - token_ciphertext: current.token_ciphertext, - token_iv: current.token_iv, - token_key_version: current.token_key_version, - }); - } catch (err) { - console.error('[email] old grant decrypt failed; skipping revoke:', err); - } - } - await tx - .update(emailDomains) - .set({ - credential_kind: credentialKind, - token_ciphertext: tokenEnc.ciphertext, - token_iv: tokenEnc.iv, - token_key_version: tokenEnc.keyVersion, - // Re-derived by the engine for api_token; grants carry neither. - token_id: null, - token_expires_on: null, - attempt_id: attemptId ?? null, - updated_at: sql`now()`, - }) - .where(eq(emailDomains.id, existing.id)); - return 'swapped'; - }); - if (swap !== 'swapped') { - // Nothing was persisted — the posted grant has no handle. Against a - // deleting project the holder guard spares a re-pasted grant the row - // still stores: teardown's cleanup revokes that one itself. - await revokeRejectedGrant( + return removeEmailDomainForProject( context, - credentialKind === 'oauth_grant' ? credential : undefined, - swap === 'project_deleting' ? existing : null, + project, + context.req.param('domain'), + context.req.query('force') === '1', + 'owner', ); - return swap === 'project_gone' - ? context.json({ error: 'project not found' }, 404) - : swap === 'project_deleting' - ? context.json({ error: 'Project is being deleted' }, 409) - : context.json({ error: 'email domain not found' }, 404); - } - - // Revoke AFTER the commit so a failed replace never strands the old - // grant; a re-pasted identical grant is skipped — revoking it would - // brick the just-stored credential. A revoke failure (network/non-2xx — - // the helper throws on both) is logged, never fatal: the new credential - // is already committed. - if (oldGrantRefreshToken !== null && oldGrantRefreshToken !== credential) { - try { - await revokeOauthGrant(oldGrantRefreshToken); - } catch (err) { - console.error('[email] old grant revoke failed after credential replace:', err); - } - } - - // The branch decision — sync a healthy row, or re-run the full engine - // on one that never finished setup — is made inside the serialized - // section on the LOCKED row: two concurrent retries would otherwise run - // setupEmailDomain outside any lock, upload different relay secrets to - // the same relay, and apply their rows/mirrors in the opposite order - // (the worker holding secret B while KV expects A). - const relaySecret = generateEmailRelaySecret(); - const relayEnc = await encryptEmailDomainRelaySecret(context.env, projectId, domain, relaySecret); - return await runEmailDomainEngineSerialized(context, projectId, existing.id, proxyOrigin, { - relaySecret, - relayEnc, - requestedMode, - created: false, }); -} - -/** True when the stored mirror already matches the row + hash exactly. */ -async function emailDomainMirrorMatches( - kv: KVNamespace, - row: EmailDomain, - relaySecretSha256: string, -): Promise { - const raw = await kv.get(emailDomainMirrorKey(row.domain)); - if (raw === null) { - return false; - } - try { - const mirror = JSON.parse(raw) as Partial; - return ( - mirror.projectId === row.project_id && - mirror.status === row.status && - mirror.relaySecretSha256 === relaySecretSha256 && - mirror.relayWorkerName === row.relay_worker_name && - mirror.accountId === row.account_id && - mirror.zoneId === row.zone_id && - mirror.mode === row.mode - ); - } catch { - // Unparseable mirror — rewrite. - return false; - } -} - -/** - * The status GET's mirror repair — the same read-compare-write self-heal - * shape as the allowlist listing (`projectAllowlistKvDiffers`): rewrite - * only on a real difference, so polling never amplifies into KV writes. - * - * The write is fenced against project teardown: the mirror is - * authorization material, and once the cascade takes the row nothing - * sweeps the key. Holding `SELECT … FOR UPDATE` on the PROJECT row across - * the KV put gives a total order with teardown's 'deleting' flip and its - * cascade (both take the same lock): either they committed first — the - * project reads deleting or gone, or the domain row is cascaded away — - * and the write is skipped, or this write lands first and teardown's own - * mirror clear removes it. Without the fence, a status poll admitted - * before teardown could recreate an active mirror after teardown cleared - * it, re-authorizing a possibly-stranded relay with no row left to retry - * from. - * - * The payload is derived from the DOMAIN row re-read under FOR UPDATE — - * in project→domain order, the same order sync takes both — never from - * the caller's unlocked snapshot. A sync that rotated the relay secret, - * or a lease that persisted token_revoked, while this poll was between - * its entry read and this transaction would otherwise have its committed - * state overwritten by the stale snapshot: the mirror would go back to - * the pre-rotation hash (inbound rejects every message) or back to - * active (re-authorizing a revoked credential). The drift check also - * re-runs against the locked row — the mirror may have converged while - * this transaction waited on the locks, and the write stays repair-only. - */ -async function repairEmailDomainMirrorIfDrifted( - db: Db, - env: Pick< - ApiEnv['Bindings'], - 'ROUTING' | 'PROJECT_SECRET_KEYS_JSON' | 'PROJECT_SECRET_ACTIVE_KEY_VERSION' - >, - row: EmailDomain, -): Promise { - const relaySecretSha256 = await hashEmailRelaySecret( - await decryptEmailDomainRelaySecret(env, row.project_id, row.domain, row), - ); - if (await emailDomainMirrorMatches(env.ROUTING, row, relaySecretSha256)) { - return; - } - await db.transaction(async (tx) => { - const lockedProject = await tx - .select({ status: projects.status }) - .from(projects) - .where(eq(projects.id, row.project_id)) - .for('update') - .limit(1); - if (!lockedProject[0] || lockedProject[0].status === 'deleting') { - return; - } - const locked = await tx - .select() - .from(emailDomains) - .where(eq(emailDomains.id, row.id)) - .for('update') - .limit(1); - const current = locked[0]; - if (!current) { - return; - } - const currentSha256 = await hashEmailRelaySecret( - await decryptEmailDomainRelaySecret(env, current.project_id, current.domain, current), - ); - if (await emailDomainMirrorMatches(env.ROUTING, current, currentSha256)) { - return; - } - await writeEmailDomainMirror(env.ROUTING, current, currentSha256); - }); -} function monthBucket(): string { const d = new Date(); @@ -2638,70 +936,6 @@ async function tryInsertWithRetry( return { row: null, inserted: false }; } -/** Postgres SQLSTATEs — surfaced by both `pg` and PGlite. */ -const PG_FOREIGN_KEY_VIOLATION = '23503'; -const PG_UNIQUE_VIOLATION = '23505'; - -/** - * Does this error carry the given Postgres SQLSTATE anywhere in its cause - * chain? - * - * The SQLSTATE rides on the DRIVER error, and drizzle wraps that in a - * `DrizzleQueryError` whose own `code` is undefined — so the `cause` chain - * has to be walked, not just the frame that was thrown. Verified against - * PGlite in `api-delete.test.ts`; `pg` reports the same code the same way. - * Bounded so a self-referencing `cause` cannot spin. - */ -function hasSqlState(err: unknown, code: string): boolean { - let cursor: unknown = err; - for (let depth = 0; depth < 8; depth++) { - if (typeof cursor !== 'object' || cursor === null) { - return false; - } - if ((cursor as { code?: unknown }).code === code) { - return true; - } - cursor = (cursor as { cause?: unknown }).cause; - } - return false; -} - -/** The project row vanished under an insert that references it. */ -function isForeignKeyViolation(err: unknown): boolean { - return hasSqlState(err, PG_FOREIGN_KEY_VIOLATION); -} - -/** Lost an insert race on a unique index (the add/add domains race). */ -function isUniqueViolation(err: unknown): boolean { - return hasSqlState(err, PG_UNIQUE_VIOLATION); -} - -/** - * The constraint behind a unique violation, walked like hasSqlState: the - * `23505` code can sit on a nested cause (drizzle wraps the driver error), - * and the constraint name rides the same frame the code does. Both drivers - * here — PGlite and `pg` via pg-protocol — expose it as `constraint` - * (`constraint_name` is postgres.js naming, not used in this repo). An - * empty answer classifies as a zone race — the relay race is the - * destructive misclassification (its compensation would delete a worker - * another live row owns), so the name must be found wherever the driver - * put it. - */ -function uniqueViolationConstraintName(err: unknown): string { - let cursor: unknown = err; - for (let depth = 0; depth < 8; depth++) { - if (typeof cursor !== 'object' || cursor === null) { - return ''; - } - const name = (cursor as { constraint?: unknown }).constraint; - if (typeof name === 'string' && name !== '') { - return name; - } - cursor = (cursor as { cause?: unknown }).cause; - } - return ''; -} - /** * Undo the CF-side registration for a destination whose row never landed. * diff --git a/platform/packages/api/src/schema.ts b/platform/packages/api/src/schema.ts index dcd5c9963..1c8939e36 100644 --- a/platform/packages/api/src/schema.ts +++ b/platform/packages/api/src/schema.ts @@ -615,10 +615,18 @@ export const emailDomains = pgTable( .notNull() .default('pending') .$type<'pending' | 'active' | 'token_revoked' | 'token_expired' | 'failed'>(), - credential_kind: text('credential_kind').notNull().$type<'oauth_grant' | 'api_token'>(), - token_ciphertext: text('token_ciphertext').notNull(), - token_iv: text('token_iv').notNull(), - token_key_version: text('token_key_version').notNull(), + // `platform` rows carry no credential of their own: the engine and the + // lease resolve them to the runtime token (`CF_API_TOKEN`), so their + // `token_*` triple is null — the CHECK below pins that pairing. + credential_kind: text('credential_kind') + .notNull() + .$type<'oauth_grant' | 'api_token' | 'platform'>(), + token_ciphertext: text('token_ciphertext'), + token_iv: text('token_iv'), + token_key_version: text('token_key_version'), + // Who registered the row. An administrator's row runs on the platform's + // credential; owners may read it but not sync or remove it. + managed_by: text('managed_by').notNull().default('owner').$type<'owner' | 'admin'>(), relay_ciphertext: text('relay_ciphertext').notNull(), relay_iv: text('relay_iv').notNull(), relay_key_version: text('relay_key_version').notNull(), @@ -669,11 +677,27 @@ export const emailDomains = pgTable( ), check( 'email_domains_credential_kind_check', - sql`${table.credential_kind} IN ('oauth_grant', 'api_token')`, + sql`${table.credential_kind} IN ('oauth_grant', 'api_token', 'platform')`, + ), + check( + 'email_domains_platform_token_check', + sql`(${table.credential_kind} = 'platform' AND ${table.token_ciphertext} IS NULL AND ${table.token_iv} IS NULL AND ${table.token_key_version} IS NULL) OR (${table.credential_kind} <> 'platform' AND ${table.token_ciphertext} IS NOT NULL AND ${table.token_iv} IS NOT NULL AND ${table.token_key_version} IS NOT NULL)`, ), + check('email_domains_managed_by_check', sql`${table.managed_by} IN ('owner', 'admin')`), ], ); +// --------------------------------------------------------------------------- +// Installation-scoped settings an administrator changes at runtime (design +// doc 0078, "Recipient policy"). Authoritative here; hot-path readers get a +// ROUTING KV mirror written by the setting's own writer. +// --------------------------------------------------------------------------- +export const platformSettings = pgTable('platform_settings', { + key: text('key').primaryKey(), + value: jsonb('value').notNull(), + updated_at: timestamp('updated_at', { mode: 'string' }).notNull().defaultNow(), +}); + // --------------------------------------------------------------------------- // Operator sessions and mutation audit, separate from browser/deployment auth. // Audit identities survive account deletion deliberately. diff --git a/platform/packages/api/test/integration/admin-email.test.ts b/platform/packages/api/test/integration/admin-email.test.ts index e836e3752..1e38439fa 100644 --- a/platform/packages/api/test/integration/admin-email.test.ts +++ b/platform/packages/api/test/integration/admin-email.test.ts @@ -20,7 +20,7 @@ import { type EmailDomain, } from '../../src/email/domains.ts'; import { app } from '../../src/index.ts'; -import { emailDomains } from '../../src/schema.ts'; +import { emailDomains, operatorEvents, platformSettings } from '../../src/schema.ts'; import { setupEnv, teardownEnv, @@ -1258,3 +1258,240 @@ describe('POST /admin/email/:projectId/domains/:domain/revoke-grant', () => { expect((await domainRow(sibling.id, 'acme-grant-elsewhere.com'))?.status).toBe('active'); }); }); + +describe('admin email settings page', () => { + test('renders the recipient policy and domain management forms with the defaults selected', async () => { + const { adminJwt } = await seedAdminAndProject(); + // A stale, looser mirror is republished from the database on this read. + await env.ROUTING.put('email-recipient-policy', JSON.stringify({ mode: 'any', domains: [] })); + const res = await get('/admin/email', adminJwt); + expect(res.status).toBe(200); + expect(await env.ROUTING.get('email-recipient-policy')).toBe( + JSON.stringify({ mode: 'verified', domains: [] }), + ); + const html = await res.text(); + expect(html).toContain('Recipient policy'); + expect(html).toContain('Domain management'); + expect(html).toMatch(/name="mode" value="verified" checked/); + expect(html).toMatch(/name="domains" value="self-serve" checked/); + expect(html).toContain('UNVERIFIED_DESTINATION'); + expect(html).toContain('own mail domain'); + }); + + test('saves the recipient policy, mirrors it, audits it, and re-renders it selected', async () => { + const { adminJwt } = await seedAdminAndProject(); + const res = await post('/admin/email/policy', adminJwt, { + mode: 'domains', + domains: 'Corp.Example\nexample.net', + }); + expect(res.status).toBe(302); + expect(res.headers.get('Location')).toBe('/admin/email'); + expect(res.headers.get('X-Void-Audit-Id')).toBeTruthy(); + expect(await env.ROUTING.get('email-recipient-policy')).toBe( + JSON.stringify({ mode: 'domains', domains: ['corp.example', 'example.net'] }), + ); + const events = await env.db.select().from(operatorEvents); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ action: 'email/policy', status: 'success' }); + + const html = await (await get('/admin/email', adminJwt)).text(); + expect(html).toMatch(/name="mode" value="domains" checked/); + expect(html).toContain('corp.example'); + }); + + test('renders a 400 for an invalid policy and stores nothing', async () => { + const { adminJwt } = await seedAdminAndProject(); + await env.ROUTING.delete('email-recipient-policy'); + const res = await post('/admin/email/policy', adminJwt, { mode: 'domains', domains: '' }); + expect(res.status).toBe(400); + expect(await env.db.select().from(platformSettings)).toHaveLength(0); + expect(await env.ROUTING.get('email-recipient-policy')).toBeNull(); + }); + + test('is unavailable, reads and writes alike, when the platform does not enable email', async () => { + const { adminJwt, projectId } = await seedAdminAndProject(); + const disabled = { + ...env, + PLATFORM_CAPABILITIES: JSON.stringify({ + ai: true, + customDomains: true, + email: false, + githubActions: true, + githubOAuth: true, + googleOAuth: false, + managedBuilds: false, + sandbox: false, + }), + }; + const page = await app.fetch( + new Request('http://localhost/admin/email', { + headers: { Cookie: `admin_token=${adminJwt}` }, + }), + disabled, + ); + expect(page.status).toBe(501); + const write = await app.fetch( + new Request('http://localhost/admin/email/settings', { + method: 'POST', + headers: { + Cookie: `admin_token=${adminJwt}`, + 'Content-Type': 'application/x-www-form-urlencoded', + Origin: 'http://localhost', + }, + body: new URLSearchParams({ domains: 'admin' }).toString(), + }), + disabled, + ); + expect(write.status).toBe(501); + expect(await env.db.select().from(platformSettings)).toHaveLength(0); + const project = await app.fetch( + new Request(`http://localhost/admin/email/${projectId}`, { + headers: { Cookie: `admin_token=${adminJwt}` }, + }), + disabled, + ); + expect(project.status).toBe(501); + }); + + test('saves the domain management mode', async () => { + const { adminJwt } = await seedAdminAndProject(); + const res = await post('/admin/email/settings', adminJwt, { domains: 'admin' }); + expect(res.status).toBe(302); + const html = await (await get('/admin/email', adminJwt)).text(); + expect(html).toMatch(/name="domains" value="admin" checked/); + const bad = await post('/admin/email/settings', adminJwt, { domains: 'nobody' }); + expect(bad.status).toBe(400); + }); + + test('the project page still resolves under the index route', async () => { + const { adminJwt, projectId, slug } = await seedAdminAndProject(); + const res = await get(`/admin/email/${projectId}`, adminJwt); + expect(res.status).toBe(200); + expect(await res.text()).toContain(slug); + }); +}); + +describe('admin add domain form', () => { + test('registers an administrator-managed row on the platform credential and audits it', async () => { + const { adminJwt, projectId } = await seedAdminAndProject(); + const cfCalls: Array = []; + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const url = typeof input === 'string' ? input : (input as Request).url; + const method = init?.method ?? 'GET'; + cfCalls.push(`${method} ${url}`); + if (url.startsWith('https://cloudflare-dns.com/')) { + return Response.json({ Answer: [] }); + } + if (url.includes('/zones?')) { + return Response.json({ + success: true, + errors: [], + result: [{ id: 'zone_p', name: 'example.com', account: { id: env.CF_ACCOUNT_ID } }], + }); + } + if (/\/workers\/scripts\/[^/]+$/.test(url) && method === 'GET') { + return new Response('not found', { status: 404 }); + } + if (url.endsWith('/email/routing/rules/catch_all') && method === 'GET') { + return Response.json({ + success: true, + errors: [], + result: { enabled: false, matchers: [{ type: 'all' }], actions: [{ type: 'drop' }] }, + }); + } + return Response.json({ success: true, errors: [], result: {} }); + }); + + const res = await post(`/admin/email/${projectId}/domains/add`, adminJwt, { + domain: 'example.com', + token: '', + }); + expect(res.status).toBe(302); + expect(res.headers.get('Location')).toBe(`/admin/email/${projectId}`); + const row = (await domainRow(projectId, 'example.com'))!; + expect(row).toMatchObject({ + credential_kind: 'platform', + managed_by: 'admin', + status: 'active', + }); + expect(row.token_ciphertext).toBeNull(); + expect(cfCalls.some((call) => call.includes('/user/tokens/verify'))).toBe(false); + const events = await env.db.select().from(operatorEvents); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ action: 'email/domains', status: 'success' }); + expect(JSON.parse(events[0]!.target)).toEqual({ domain: 'example.com', project: projectId }); + + const html = await (await get(`/admin/email/${projectId}`, adminJwt)).text(); + expect(html).toContain('admin (platform credential)'); + }); + + test('renders a failed setup as a failed audited mutation while keeping the row', async () => { + const { adminJwt, projectId } = await seedAdminAndProject(); + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = typeof input === 'string' ? input : (input as Request).url; + if (url.includes('/zones?')) { + return Response.json( + { success: false, errors: [{ code: 10000, message: 'zone lookup unavailable' }] }, + { status: 502 }, + ); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + + const res = await post(`/admin/email/${projectId}/domains/add`, adminJwt, { + domain: 'failed.example', + token: '', + }); + expect(res.status).toBe(502); + const html = await res.text(); + expect(html).toContain('Adding failed.example did not finish'); + expect(html).toContain('zone lookup unavailable'); + expect(await domainRow(projectId, 'failed.example')).toMatchObject({ status: 'failed' }); + const events = await env.db.select().from(operatorEvents); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ action: 'email/domains', status: 'failed' }); + }); + + test('refuses to take over a row the project owner registered', async () => { + const { adminJwt, projectId } = await seedAdminAndProject(); + const row = await seedEmailDomain(projectId, 'owned.test', { credentialKind: 'api_token' }); + const cfCalls = recordCustomerCfCalls(); + const res = await post(`/admin/email/${projectId}/domains/add`, adminJwt, { + domain: 'owned.test', + token: '', + }); + expect(res.status).toBe(409); + expect(await res.text()).toContain('registered by its project owner'); + const after = (await domainRow(projectId, 'owned.test'))!; + expect(after.managed_by).toBe('owner'); + expect(after.credential_kind).toBe('api_token'); + expect(after.token_ciphertext).toBe(row.token_ciphertext); + expect(cfCalls).toEqual([]); + }); + + test('renders a 400 for a blank domain and records the failed attempt', async () => { + const { adminJwt, projectId } = await seedAdminAndProject(); + const res = await post(`/admin/email/${projectId}/domains/add`, adminJwt, { domain: ' ' }); + expect(res.status).toBe(400); + expect(await env.db.select().from(emailDomains)).toHaveLength(0); + }); + + test('keeps a pasted token out of the audit log', async () => { + const { adminJwt, projectId } = await seedAdminAndProject(); + vi.spyOn(globalThis, 'fetch').mockImplementation(async () => + Response.json( + { success: false, errors: [{ code: 10000, message: 'nope' }] }, + { status: 403 }, + ), + ); + const res = await post(`/admin/email/${projectId}/domains/add`, adminJwt, { + domain: 'elsewhere.test', + token: 'cf-pasted-token-dummy', + }); + // The engine records the refusal on the row; the form redirects. + expect([302, 400, 502]).toContain(res.status); + expect(JSON.stringify(await env.db.select().from(operatorEvents))).not.toContain( + 'cf-pasted-token-dummy', + ); + }); +}); diff --git a/platform/packages/api/test/integration/api-delete.test.ts b/platform/packages/api/test/integration/api-delete.test.ts index 11ca6848c..42ecc3f9b 100644 --- a/platform/packages/api/test/integration/api-delete.test.ts +++ b/platform/packages/api/test/integration/api-delete.test.ts @@ -906,6 +906,8 @@ type CustomerEmailCleanupCall = { body?: unknown; /** OAuth endpoint form fields (dash.cloudflare.com) — dummy fixtures only. */ form?: Record; + /** The bearer the customer-account call carried — dummy fixtures only. */ + authorization?: string | null; }; /** @@ -942,6 +944,7 @@ function captureCustomerEmailCleanup( vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => { const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; const method = init?.method ?? 'GET'; + const authorization = new Headers(init?.headers).get('Authorization'); if (url.startsWith('https://dash.cloudflare.com/oauth2/token')) { calls.push({ tag: 'oauthToken', method, url, form: readForm(init) }); @@ -965,7 +968,7 @@ function captureCustomerEmailCleanup( } if (url.includes('/email/routing/rules/catch_all') && method === 'GET') { - calls.push({ tag: 'catchAllGet', method, url }); + calls.push({ tag: 'catchAllGet', method, url, authorization }); const failure = fail('catchAllGet'); if (failure) { return failure; @@ -984,12 +987,12 @@ function captureCustomerEmailCleanup( } if (url.includes('/email/routing/rules/catch_all') && method === 'PUT') { const body = typeof init?.body === 'string' ? JSON.parse(init.body) : undefined; - calls.push({ tag: 'catchAll', method, url, body }); + calls.push({ tag: 'catchAll', method, url, body, authorization }); return fail('catchAll') ?? cfSuccess({}); } if (/\/accounts\/[^/]+\/workers\/scripts\/[^/?#]+$/.test(new URL(url).pathname)) { if (method === 'DELETE') { - calls.push({ tag: 'scriptDelete', method, url }); + calls.push({ tag: 'scriptDelete', method, url, authorization }); return fail('scriptDelete') ?? cfSuccess({}); } } @@ -1013,35 +1016,42 @@ describe('DELETE /projects/:id — customer-side email domain cleanup', () => { options: { status?: 'active' | 'failed'; relayWorkerName?: string | null; - credentialKind?: 'api_token' | 'oauth_grant'; + credentialKind?: 'api_token' | 'oauth_grant' | 'platform'; + accountId?: string; } = {}, ): Promise { const status = options.status ?? 'active'; const relayWorkerName = options.relayWorkerName === undefined ? RELAY_WORKER_BASE_NAME : options.relayWorkerName; const credentialKind = options.credentialKind ?? 'api_token'; + const accountId = options.accountId ?? CUSTOMER_ACCOUNT_ID; // The token triple must really decrypt — teardown decrypts it to build // the customer CF client (the grant's refresh token on the oauth_grant - // path). The relay triple is never read on this path (dummy stand-ins, - // as in seedDomainMirror above). - const tokenEnc = await encryptEmailDomainToken( - env, - projectId, - domain, - credentialKind === 'oauth_grant' ? DOMAIN_REFRESH_TOKEN : DOMAIN_API_TOKEN, - ); + // path). A platform row stores none: teardown resolves it to the + // runtime token. The relay triple is never read on this path (dummy + // stand-ins, as in seedDomainMirror above). + const tokenEnc = + credentialKind === 'platform' + ? null + : await encryptEmailDomainToken( + env, + projectId, + domain, + credentialKind === 'oauth_grant' ? DOMAIN_REFRESH_TOKEN : DOMAIN_API_TOKEN, + ); await env.db.insert(emailDomains).values({ id: `edom_${domain.replace(/[^a-z0-9]/g, '')}`, project_id: projectId, domain, zone_id: CUSTOMER_ZONE_ID, - account_id: CUSTOMER_ACCOUNT_ID, + account_id: accountId, mode: 'apex', status, credential_kind: credentialKind, - token_ciphertext: tokenEnc.ciphertext, - token_iv: tokenEnc.iv, - token_key_version: tokenEnc.keyVersion, + managed_by: credentialKind === 'platform' ? 'admin' : 'owner', + token_ciphertext: tokenEnc?.ciphertext ?? null, + token_iv: tokenEnc?.iv ?? null, + token_key_version: tokenEnc?.keyVersion ?? null, relay_ciphertext: 'dummy-ct', relay_iv: 'dummy-iv', relay_key_version: 'v1', @@ -1054,7 +1064,7 @@ describe('DELETE /projects/:id — customer-side email domain cleanup', () => { status, relaySecretSha256: '00'.repeat(32), relayWorkerName, - accountId: CUSTOMER_ACCOUNT_ID, + accountId, zoneId: CUSTOMER_ZONE_ID, mode: 'apex', }), @@ -1109,6 +1119,68 @@ describe('DELETE /projects/:id — customer-side email domain cleanup', () => { expect(await env.ROUTING.get('email-domain:acme-relay.example')).toBeNull(); }); + it('cleans up an administrator-managed platform row with the runtime token', async () => { + const user = await createTestUser(env.db); + const project = await createTestProject(env.db, user.id, 'mail-relay-platform'); + mockCfApi(); + const customerCalls = captureCustomerEmailCleanup({ + catchAllRule: { enabled: true, workerName: RELAY_WORKER_BASE_NAME }, + }); + await seedCustomerEmailDomain(project.id, 'acme-platform.example', { + credentialKind: 'platform', + accountId: env.CF_ACCOUNT_ID, + }); + + const res = await app.fetch( + new Request(`http://localhost/projects/${project.id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${user.jwt}` }, + }), + env, + ); + expect(res.status).toBe(200); + expect(customerCalls.map((call) => call.tag)).toEqual([ + 'catchAllGet', + 'catchAll', + 'scriptDelete', + ]); + expect(customerCalls[2]!.url).toContain( + `/accounts/${env.CF_ACCOUNT_ID}/workers/scripts/${RELAY_WORKER_BASE_NAME}`, + ); + for (const call of customerCalls) { + expect(call.authorization).toBe(`Bearer ${env.CF_API_TOKEN}`); + } + expect(await env.ROUTING.get('email-domain:acme-platform.example')).toBeNull(); + expect(await env.db.select().from(projects).where(eq(projects.id, project.id))).toHaveLength(0); + }); + + it('still tears the project down when the runtime token is gone, leaving the relay for manual cleanup', async () => { + const user = await createTestUser(env.db); + const project = await createTestProject(env.db, user.id, 'mail-relay-platform-lost'); + mockCfApi(); + const customerCalls = captureCustomerEmailCleanup({ + catchAllRule: { enabled: true, workerName: RELAY_WORKER_BASE_NAME }, + }); + await seedCustomerEmailDomain(project.id, 'acme-platform-lost.example', { + credentialKind: 'platform', + accountId: env.CF_ACCOUNT_ID, + }); + + const res = await app.fetch( + new Request(`http://localhost/projects/${project.id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${user.jwt}` }, + }), + { ...env, CF_API_TOKEN: '' }, + ); + expect(res.status).toBe(200); + // No credential, no customer-side call — but the mirror is gone and the + // project is deleted; the relay is the operator's to remove by hand. + expect(customerCalls).toEqual([]); + expect(await env.ROUTING.get('email-domain:acme-platform-lost.example')).toBeNull(); + expect(await env.db.select().from(projects).where(eq(projects.id, project.id))).toHaveLength(0); + }); + it('revokes the OAuth grant during teardown for an oauth_grant domain', async () => { const user = await createTestUser(env.db); const project = await createTestProject(env.db, user.id, 'mail-relay-oauth'); diff --git a/platform/packages/api/test/integration/api-email-domains.test.ts b/platform/packages/api/test/integration/api-email-domains.test.ts index da363d3e8..d8bfd1ab2 100644 --- a/platform/packages/api/test/integration/api-email-domains.test.ts +++ b/platform/packages/api/test/integration/api-email-domains.test.ts @@ -56,10 +56,16 @@ import { RELAY_VERSION, } from '../../src/email/relay-worker.ts'; import app from '../../src/index.ts'; -import { emailDomains, projects } from '../../src/schema.ts'; +import { emailDomains, platformSettings, projects } from '../../src/schema.ts'; +import { + callsByTag, + sleep, + stubCustomerCf, + tags, + type RecordedCall, + type StubFailTag, +} from './email-domain-cf-stub.ts'; import { - cfError, - cfSuccess, clearTables, createTestProject, createTestUser, @@ -149,326 +155,6 @@ afterEach(() => { vi.unstubAllGlobals(); }); -// --------------------------------------------------------------------------- -// Customer-side CF/OAuth/DNS fetch stub -// --------------------------------------------------------------------------- - -type StubFailTag = - | 'verifyToken' - | 'zones' - | 'mxLookup' - | 'routingDns' - | 'onboardSending' - | 'sendingSubdomainsGet' - | 'scriptGet' - | 'scriptPut' - | 'secretPut' - | 'catchAll' - | 'catchAllGet' - | 'routingSettingsGet' - | 'scriptDelete' - | 'oauthToken' - | 'oauthRevoke'; - -type RecordedCall = { - tag: StubFailTag; - method: string; - url: string; - /** Parsed JSON body for string bodies; parsed metadata for the script PUT. */ - body?: unknown; - /** OAuth token-endpoint form fields, minus nothing — dummy fixtures only. */ - form?: Record; - /** Secrets PUT shape: name/type/text shape — never the text itself. */ - secret?: { name: string; type: string; textShape: string }; -}; - -function stubCustomerCf( - config: { - zones?: Record; - verifyStatus?: string; - verifyExpiresOn?: string | null; - mx?: Record>; - existingScripts?: ReadonlyArray; - /** - * Initial catch-all state, applied to every zone. Absent → CF's real - * default for a zone that never had a rule written: a DISABLED drop - * rule (live probes). `null` → no rule at all (CF 404). GET/PUT then - * keep mutating the per-zone state. - */ - catchAllRule?: { enabled: boolean; workerName: string | null } | null; - /** GET email/routing's result; default { enabled: true }. */ - routingSettings?: Record; - /** GET sending/subdomains entries; default []. */ - sendingSubdomains?: ReadonlyArray<{ name: string; enabled: boolean }>; - failOn?: Partial>; - /** Side-effect hook fired right after a call is recorded (mid-request seams). */ - on?: Partial void | Promise>>; - oauth?: 'rotate' | 'invalid_grant' | 'server_error'; - } = {}, -) { - const calls: Array = []; - let oauthRound = 0; - const fail = (tag: StubFailTag): Response | null => { - const failure = config.failOn?.[tag]; - return failure ? cfError(failure.message, failure.status, failure.code) : null; - }; - const record = async (call: RecordedCall): Promise => { - calls.push(call); - await config.on?.[call.tag]?.(); - }; - - // Per-zone catch-all rule state, lazily seeded from the config. - const catchAllStates = new Map(); - // Per-script sha256 of the last secret PUT text (never the text). - const secretHashes = new Map(); - const catchAllStateFor = (zoneId: string) => { - if (!catchAllStates.has(zoneId)) { - catchAllStates.set( - zoneId, - config.catchAllRule === undefined || config.catchAllRule === null - ? config.catchAllRule === null - ? null - : { enabled: false, workerName: null } - : { ...config.catchAllRule }, - ); - } - return catchAllStates.get(zoneId)!; - }; - - const fn = vi.fn(async (input: string | URL | Request, init?: RequestInit): Promise => { - const url = - typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; - const method = init?.method ?? 'GET'; - - if (url.startsWith('https://dash.cloudflare.com/oauth2/token')) { - const form: Record = {}; - if (typeof init?.body === 'string') { - for (const [key, value] of new URLSearchParams(init.body)) { - form[key] = value; - } - } - await record({ tag: 'oauthToken', method, url, form }); - const failure = fail('oauthToken'); - if (failure) { - return failure; - } - if (config.oauth === 'invalid_grant') { - return Response.json({ error: 'invalid_grant' }, { status: 400 }); - } - if (config.oauth === 'server_error') { - return Response.json({ error: 'temporarily_unavailable' }, { status: 503 }); - } - // Chain off the incoming token so the rotation survives stub resets: - // a presented `cf-rotated-refresh-dummy-N` rotates to N+1, anything - // else (the originally posted grant) starts at 1. - const roundMatch = /^cf-rotated-refresh-dummy-(\d+)$/.exec(form.refresh_token ?? ''); - oauthRound = roundMatch ? Number(roundMatch[1]) + 1 : 1; - return Response.json({ - access_token: `cf-access-dummy-${oauthRound}`, - refresh_token: `cf-rotated-refresh-dummy-${oauthRound}`, - expires_in: 3600, - }); - } - - if (url.startsWith('https://dash.cloudflare.com/oauth2/revoke')) { - const form: Record = {}; - if (typeof init?.body === 'string') { - for (const [key, value] of new URLSearchParams(init.body)) { - form[key] = value; - } - } - await record({ tag: 'oauthRevoke', method, url, form }); - // RFC 7009 answers 200 whether the token was revoked or already - // invalid; the body is ignored. - return fail('oauthRevoke') ?? new Response(null, { status: 200 }); - } - - if (url.startsWith('https://cloudflare-dns.com/dns-query')) { - await record({ tag: 'mxLookup', method, url }); - const failure = fail('mxLookup'); - if (failure) { - return failure; - } - const name = new URL(url).searchParams.get('name') ?? ''; - const exchanges = config.mx?.[name] ?? []; - return Response.json({ - Answer: exchanges.map((exchange) => ({ type: 15, data: `10 ${exchange}.` })), - }); - } - - if (!url.startsWith('https://api.cloudflare.com/client/v4/')) { - throw new Error(`Unmocked fetch: ${method} ${url}`); - } - const path = url.slice('https://api.cloudflare.com/client/v4'.length); - - if (path === '/user/tokens/verify') { - await record({ tag: 'verifyToken', method, url }); - const failure = fail('verifyToken'); - if (failure) { - return failure; - } - return cfSuccess({ - id: 'tok_dummy', - status: config.verifyStatus ?? 'active', - expires_on: config.verifyExpiresOn ?? null, - }); - } - - if (path.startsWith('/zones?')) { - await record({ tag: 'zones', method, url }); - const failure = fail('zones'); - if (failure) { - return failure; - } - const name = new URL(url).searchParams.get('name') ?? ''; - const zone = config.zones?.[name]; - return cfSuccess( - zone ? [{ id: zone.id, name: zone.name, account: { id: zone.accountId } }] : [], - ); - } - - if (path.includes('/email/routing/dns') && method === 'POST') { - const body = typeof init?.body === 'string' ? JSON.parse(init.body) : undefined; - await record({ tag: 'routingDns', method, url, body }); - return fail('routingDns') ?? cfSuccess({}); - } - - if (path.includes('/email/sending/subdomains') && method === 'POST') { - const body = typeof init?.body === 'string' ? JSON.parse(init.body) : undefined; - await record({ tag: 'onboardSending', method, url, body }); - return fail('onboardSending') ?? cfSuccess({}); - } - - if (path.includes('/email/sending/subdomains') && method === 'GET') { - await record({ tag: 'sendingSubdomainsGet', method, url }); - const failure = fail('sendingSubdomainsGet'); - if (failure) { - return failure; - } - return cfSuccess(config.sendingSubdomains ?? []); - } - - // The settings GET is anchored at the exact path — /email/routing/dns - // and /email/routing/rules/... must not match it. - if (/\/zones\/[^/]+\/email\/routing$/.test(path) && method === 'GET') { - await record({ tag: 'routingSettingsGet', method, url }); - const failure = fail('routingSettingsGet'); - if (failure) { - return failure; - } - return cfSuccess(config.routingSettings ?? { enabled: true }); - } - - const catchAllMatch = /\/zones\/([^/]+)\/email\/routing\/rules\/catch_all$/.exec(path); - if (catchAllMatch && method === 'GET') { - await record({ tag: 'catchAllGet', method, url }); - const failure = fail('catchAllGet'); - if (failure) { - return failure; - } - const state = catchAllStateFor(catchAllMatch[1]!); - if (state === null) { - return cfError('rule not found', 404); - } - return cfSuccess({ - enabled: state.enabled, - matchers: [{ type: 'all' }], - actions: - state.workerName === null - ? [{ type: 'drop', value: [] }] - : [{ type: 'worker', value: [state.workerName] }], - }); - } - - if (catchAllMatch && method === 'PUT') { - const body = typeof init?.body === 'string' ? JSON.parse(init.body) : undefined; - await record({ tag: 'catchAll', method, url, body }); - const failure = fail('catchAll'); - if (failure) { - return failure; - } - // The write mutates the zone's rule: a worker action names the worker, - // anything else (the reset's drop) is a disabled non-worker rule. - const actions = (body as { actions?: Array<{ type: string; value?: Array }> }) - ?.actions; - const worker = actions?.find((action) => action.type === 'worker'); - catchAllStates.set(catchAllMatch[1]!, { - enabled: Boolean((body as { enabled?: unknown })?.enabled), - workerName: worker?.value?.[0] ?? null, - }); - return cfSuccess({}); - } - - const secretsMatch = /\/workers\/scripts\/([^/]+)\/secrets$/.exec(path); - if (secretsMatch && method === 'PUT') { - const parsed = typeof init?.body === 'string' ? JSON.parse(init.body) : {}; - const text = typeof parsed.text === 'string' ? parsed.text : ''; - // The worker's CURRENT secret, tracked by sha256 only — the same - // form the ROUTING mirror stores — so a test can prove worker, row - // and mirror carry ONE secret without the value ever being recorded. - secretHashes.set(secretsMatch[1]!, await hashEmailRelaySecret(text)); - await record({ - tag: 'secretPut', - method, - url, - secret: { - name: String(parsed.name), - type: String(parsed.type), - // Shape, never the value: length class + alphabet. - textShape: `len=${text.length} ${/^[A-Za-z0-9_-]+$/.test(text) ? 'base64url' : 'other'}`, - }, - }); - return fail('secretPut') ?? cfSuccess({}); - } - - const scriptMatch = /\/accounts\/[^/]+\/workers\/scripts\/([^/?#]+)$/.exec(path); - if (scriptMatch && method === 'GET') { - await record({ tag: 'scriptGet', method, url }); - const failure = fail('scriptGet'); - if (failure) { - return failure; - } - if (config.existingScripts?.includes(scriptMatch[1]!)) { - // The raw script GET answers source bytes, not a v4 envelope. - return new Response('export default {}', { status: 200 }); - } - return new Response('not found', { status: 404 }); - } - if (scriptMatch && method === 'PUT') { - let metadata: unknown; - if (init?.body instanceof FormData) { - const metaEntry = init.body.get('metadata'); - if (metaEntry instanceof Blob) { - metadata = JSON.parse(await metaEntry.text()); - } - } - await record({ tag: 'scriptPut', method, url, body: metadata }); - return fail('scriptPut') ?? cfSuccess({}); - } - if (scriptMatch && method === 'DELETE') { - await record({ tag: 'scriptDelete', method, url }); - return fail('scriptDelete') ?? cfSuccess({}); - } - - throw new Error(`Unmocked customer fetch: ${method} ${url}`); - }); - - vi.stubGlobal('fetch', fn); - return { calls, fn, catchAllStates, secretHashes }; -} - -function callsByTag(calls: Array, tag: StubFailTag): Array { - return calls.filter((call) => call.tag === tag); -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function tags(calls: Array): Array { - return calls.map((call) => call.tag); -} - // --------------------------------------------------------------------------- // Request helpers // --------------------------------------------------------------------------- @@ -616,6 +302,47 @@ async function seedEmailDomain( return row; } +describe('email_domains token triple constraint', () => { + it('rejects a partially-null stored token triple', async () => { + const user = await createTestUser(env.db); + const project = await createTestProject(env.db, user.id); + await expect( + env.db.insert(emailDomains).values({ + id: 'edom_partial_token', + project_id: project.id, + domain: 'partial-token.example', + credential_kind: 'api_token', + token_ciphertext: 'ciphertext', + token_iv: null, + token_key_version: 'v1', + relay_ciphertext: 'relay-ciphertext', + relay_iv: 'relay-iv', + relay_key_version: 'v1', + }), + ).rejects.toThrow(); + }); + + it('rejects a stored token on a platform row', async () => { + const user = await createTestUser(env.db); + const project = await createTestProject(env.db, user.id); + await expect( + env.db.insert(emailDomains).values({ + id: 'edom_platform_token', + project_id: project.id, + domain: 'platform-token.example', + credential_kind: 'platform', + managed_by: 'admin', + token_ciphertext: 'must-not-be-stored', + token_iv: null, + token_key_version: null, + relay_ciphertext: 'relay-ciphertext', + relay_iv: 'relay-iv', + relay_key_version: 'v1', + }), + ).rejects.toThrow(); + }); +}); + /** Add an active api_token domain through the real route. */ async function addActiveDomain( projectId: string, @@ -733,6 +460,100 @@ describe('email domain routes — owner guard', () => { }); }); +describe('email domain routes — administrator-managed platform', () => { + it('refuses an owner add under admin management, names the command, and revokes the grant', async () => { + const user = await createTestUser(env.db); + const project = await createTestProject(env.db, user.id, 'hr-portal'); + await env.db.insert(platformSettings).values({ key: 'email.domains', value: 'admin' }); + const stub = stubCustomerCf({ zones: { 'acme.com': ZONE } }); + + const res = await addDomain(project.id, user.jwt, { + domain: 'Acme.COM', + refreshToken: REFRESH_TOKEN, + }); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ + error: 'email domains on this platform are managed by its administrator', + adminCommand: 'void platform email domain-add acme.com --project hr-portal', + }); + expect(tags(stub.calls)).toEqual(['oauthRevoke']); + expect(await rowFor(project.id, 'acme.com')).toBeUndefined(); + }); + + it('still lets an owner renew, sync and remove a row they registered before the switch', async () => { + const user = await createTestUser(env.db); + const project = await createTestProject(env.db, user.id, 'hr-portal'); + await seedEmailDomain(project.id, 'acme.com', { status: 'token_expired' }); + await env.db.insert(platformSettings).values({ key: 'email.domains', value: 'admin' }); + const stub = stubCustomerCf({ zones: { 'acme.com': ZONE } }); + + // Renewal re-add of the owner's own row: allowed, credential replaced. + const readd = await addDomain(project.id, user.jwt, { + domain: 'acme.com', + apiToken: API_TOKEN_REPLACEMENT, + }); + expect(readd.status).toBe(200); + expect((await rowFor(project.id, 'acme.com'))?.managed_by).toBe('owner'); + expect(callsByTag(stub.calls, 'verifyToken').length).toBeGreaterThan(0); + + expect((await syncDomain(project.id, 'acme.com', user.jwt)).status).toBe(200); + // A NEW domain is what admin mode refuses. + const fresh = await addDomain(project.id, user.jwt, { + domain: 'new.example', + apiToken: API_TOKEN, + }); + expect(fresh.status).toBe(403); + expect(await fresh.json()).toMatchObject({ + adminCommand: 'void platform email domain-add new.example --project hr-portal', + }); + expect((await removeDomain(project.id, 'acme.com', user.jwt)).status).toBe(200); + expect(await rowFor(project.id, 'acme.com')).toBeUndefined(); + }); + + it("lets an owner read but not sync, remove or re-add an administrator's row", async () => { + const user = await createTestUser(env.db); + const project = await createTestProject(env.db, user.id, 'hr-portal'); + const seeded = await seedEmailDomain(project.id, 'acme.com', { status: 'active' }); + await env.db + .update(emailDomains) + .set({ managed_by: 'admin' }) + .where(eq(emailDomains.id, seeded.id)); + const stub = stubCustomerCf({ zones: { 'acme.com': ZONE } }); + + const list = await listDomains(project.id, user.jwt); + expect(list.status).toBe(200); + expect(await list.json()).toMatchObject([{ domain: 'acme.com', managed_by: 'admin' }]); + const status = await domainStatus(project.id, 'acme.com', user.jwt); + expect(status.status).toBe(200); + expect(await status.json()).toMatchObject({ managed_by: 'admin' }); + + const sync = await syncDomain(project.id, 'acme.com', user.jwt); + expect(sync.status).toBe(403); + expect(await sync.json()).toEqual({ + error: 'this email domain is managed by the platform administrator', + adminCommand: 'void platform email domain-sync acme.com', + }); + const remove = await removeDomain(project.id, 'acme.com', user.jwt); + expect(remove.status).toBe(403); + expect(await remove.json()).toMatchObject({ + adminCommand: 'void platform email domain-remove acme.com', + }); + const readd = await addDomain(project.id, user.jwt, { + domain: 'acme.com', + refreshToken: REFRESH_TOKEN, + }); + expect(readd.status).toBe(403); + expect(await readd.json()).toMatchObject({ + adminCommand: 'void platform email domain-add acme.com --project hr-portal', + }); + // Nothing destructive ran; the grant the re-add posted was revoked. + expect(callsByTag(stub.calls, 'scriptPut')).toHaveLength(0); + expect(callsByTag(stub.calls, 'scriptDelete')).toHaveLength(0); + expect(callsByTag(stub.calls, 'oauthRevoke')).toHaveLength(1); + expect((await rowFor(project.id, 'acme.com'))?.credential_kind).toBe('api_token'); + }); +}); + // --------------------------------------------------------------------------- // POST /projects/:id/email/domains // --------------------------------------------------------------------------- diff --git a/platform/packages/api/test/integration/api-email.test.ts b/platform/packages/api/test/integration/api-email.test.ts index e61797d36..edd2a696f 100644 --- a/platform/packages/api/test/integration/api-email.test.ts +++ b/platform/packages/api/test/integration/api-email.test.ts @@ -46,7 +46,7 @@ import { } from '../../src/email/send-verification.ts'; import app from '../../src/index.ts'; import { syncProjectAllowlistKvBestEffort } from '../../src/provisioning/email-allowlist.ts'; -import { emailDestinations, projects, users } from '../../src/schema.ts'; +import { emailDestinations, platformSettings, projects, users } from '../../src/schema.ts'; import { cfError, cfSuccess, @@ -2438,6 +2438,69 @@ describe('GET /email/destinations/confirm', () => { }); }); +// --------------------------------------------------------------------------- +// GET /projects/:id/email/settings +// --------------------------------------------------------------------------- + +describe('GET /projects/:id/email/settings', () => { + async function settings(projectId: string, jwt: string): Promise { + return app.fetch( + new Request(`http://localhost/projects/${projectId}/email/settings`, { + headers: { Authorization: `Bearer ${jwt}` }, + }), + env, + ); + } + + it('returns 401 without auth and 404 for an unknown project', async () => { + const anon = await app.fetch( + new Request('http://localhost/projects/proj_x/email/settings'), + env, + ); + expect(anon.status).toBe(401); + const user = await createTestUser(env.db); + expect((await settings('proj_missing', user.jwt)).status).toBe(404); + }); + + it("returns 403 for another user's project", async () => { + const owner = await createTestUser(env.db, 'owner'); + const intruder = await createTestUser(env.db, 'intruder'); + const project = await createTestProject(env.db, owner.id); + expect((await settings(project.id, intruder.jwt)).status).toBe(403); + }); + + it('reports the defaults with the project slug and repairs the policy mirror', async () => { + const user = await createTestUser(env.db); + const project = await createTestProject(env.db, user.id, 'settings-project'); + await env.ROUTING.delete('email-recipient-policy'); + + const res = await settings(project.id, user.jwt); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + domains: { management: 'self-serve', projectSlug: 'settings-project' }, + recipientPolicy: { mode: 'verified', domains: [] }, + }); + expect(await env.ROUTING.get('email-recipient-policy')).toBe( + JSON.stringify({ mode: 'verified', domains: [] }), + ); + }); + + it('reflects the stored settings', async () => { + const user = await createTestUser(env.db); + const project = await createTestProject(env.db, user.id); + await env.db.insert(platformSettings).values([ + { key: 'email.domains', value: 'admin' }, + { key: 'email.recipientPolicy', value: { mode: 'domains', domains: ['corp.example'] } }, + ]); + + const body = await (await settings(project.id, user.jwt)).json(); + expect(body).toMatchObject({ + domains: { management: 'admin' }, + recipientPolicy: { mode: 'domains', domains: ['corp.example'] }, + }); + }); +}); + // --------------------------------------------------------------------------- // GET /projects/:id/email/usage // --------------------------------------------------------------------------- diff --git a/platform/packages/api/test/integration/email-domain-cf-stub.ts b/platform/packages/api/test/integration/email-domain-cf-stub.ts new file mode 100644 index 000000000..074675c18 --- /dev/null +++ b/platform/packages/api/test/integration/email-domain-cf-stub.ts @@ -0,0 +1,323 @@ +import { vi } from 'vite-plus/test'; +import { hashEmailRelaySecret } from '../../src/email/domains.ts'; +import { cfError, cfSuccess } from './helpers.ts'; + +// --------------------------------------------------------------------------- +// Customer-side CF/OAuth/DNS fetch stub +// --------------------------------------------------------------------------- + +export type StubFailTag = + | 'verifyToken' + | 'zones' + | 'mxLookup' + | 'routingDns' + | 'onboardSending' + | 'sendingSubdomainsGet' + | 'scriptGet' + | 'scriptPut' + | 'secretPut' + | 'catchAll' + | 'catchAllGet' + | 'routingSettingsGet' + | 'scriptDelete' + | 'oauthToken' + | 'oauthRevoke'; + +export type RecordedCall = { + tag: StubFailTag; + method: string; + url: string; + /** Parsed JSON body for string bodies; parsed metadata for the script PUT. */ + body?: unknown; + /** OAuth token-endpoint form fields, minus nothing — dummy fixtures only. */ + form?: Record; + /** Secrets PUT shape: name/type/text shape — never the text itself. */ + secret?: { name: string; type: string; textShape: string }; +}; + +export function stubCustomerCf( + config: { + zones?: Record; + verifyStatus?: string; + verifyExpiresOn?: string | null; + mx?: Record>; + existingScripts?: ReadonlyArray; + /** + * Initial catch-all state, applied to every zone. Absent → CF's real + * default for a zone that never had a rule written: a DISABLED drop + * rule (live probes). `null` → no rule at all (CF 404). GET/PUT then + * keep mutating the per-zone state. + */ + catchAllRule?: { enabled: boolean; workerName: string | null } | null; + /** GET email/routing's result; default { enabled: true }. */ + routingSettings?: Record; + /** GET sending/subdomains entries; default []. */ + sendingSubdomains?: ReadonlyArray<{ name: string; enabled: boolean }>; + failOn?: Partial>; + /** Side-effect hook fired right after a call is recorded (mid-request seams). */ + on?: Partial void | Promise>>; + oauth?: 'rotate' | 'invalid_grant' | 'server_error'; + } = {}, +) { + const calls: Array = []; + let oauthRound = 0; + const fail = (tag: StubFailTag): Response | null => { + const failure = config.failOn?.[tag]; + return failure ? cfError(failure.message, failure.status, failure.code) : null; + }; + const record = async (call: RecordedCall): Promise => { + calls.push(call); + await config.on?.[call.tag]?.(); + }; + + // Per-zone catch-all rule state, lazily seeded from the config. + const catchAllStates = new Map(); + // Per-script sha256 of the last secret PUT text (never the text). + const secretHashes = new Map(); + const catchAllStateFor = (zoneId: string) => { + if (!catchAllStates.has(zoneId)) { + catchAllStates.set( + zoneId, + config.catchAllRule === undefined || config.catchAllRule === null + ? config.catchAllRule === null + ? null + : { enabled: false, workerName: null } + : { ...config.catchAllRule }, + ); + } + return catchAllStates.get(zoneId)!; + }; + + const fn = vi.fn(async (input: string | URL | Request, init?: RequestInit): Promise => { + const url = + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + const method = init?.method ?? 'GET'; + + if (url.startsWith('https://dash.cloudflare.com/oauth2/token')) { + const form: Record = {}; + if (typeof init?.body === 'string') { + for (const [key, value] of new URLSearchParams(init.body)) { + form[key] = value; + } + } + await record({ tag: 'oauthToken', method, url, form }); + const failure = fail('oauthToken'); + if (failure) { + return failure; + } + if (config.oauth === 'invalid_grant') { + return Response.json({ error: 'invalid_grant' }, { status: 400 }); + } + if (config.oauth === 'server_error') { + return Response.json({ error: 'temporarily_unavailable' }, { status: 503 }); + } + // Chain off the incoming token so the rotation survives stub resets: + // a presented `cf-rotated-refresh-dummy-N` rotates to N+1, anything + // else (the originally posted grant) starts at 1. + const roundMatch = /^cf-rotated-refresh-dummy-(\d+)$/.exec(form.refresh_token ?? ''); + oauthRound = roundMatch ? Number(roundMatch[1]) + 1 : 1; + return Response.json({ + access_token: `cf-access-dummy-${oauthRound}`, + refresh_token: `cf-rotated-refresh-dummy-${oauthRound}`, + expires_in: 3600, + }); + } + + if (url.startsWith('https://dash.cloudflare.com/oauth2/revoke')) { + const form: Record = {}; + if (typeof init?.body === 'string') { + for (const [key, value] of new URLSearchParams(init.body)) { + form[key] = value; + } + } + await record({ tag: 'oauthRevoke', method, url, form }); + // RFC 7009 answers 200 whether the token was revoked or already + // invalid; the body is ignored. + return fail('oauthRevoke') ?? new Response(null, { status: 200 }); + } + + if (url.startsWith('https://cloudflare-dns.com/dns-query')) { + await record({ tag: 'mxLookup', method, url }); + const failure = fail('mxLookup'); + if (failure) { + return failure; + } + const name = new URL(url).searchParams.get('name') ?? ''; + const exchanges = config.mx?.[name] ?? []; + return Response.json({ + Answer: exchanges.map((exchange) => ({ type: 15, data: `10 ${exchange}.` })), + }); + } + + if (!url.startsWith('https://api.cloudflare.com/client/v4/')) { + throw new Error(`Unmocked fetch: ${method} ${url}`); + } + const path = url.slice('https://api.cloudflare.com/client/v4'.length); + + if (path === '/user/tokens/verify') { + await record({ tag: 'verifyToken', method, url }); + const failure = fail('verifyToken'); + if (failure) { + return failure; + } + return cfSuccess({ + id: 'tok_dummy', + status: config.verifyStatus ?? 'active', + expires_on: config.verifyExpiresOn ?? null, + }); + } + + if (path.startsWith('/zones?')) { + await record({ tag: 'zones', method, url }); + const failure = fail('zones'); + if (failure) { + return failure; + } + const name = new URL(url).searchParams.get('name') ?? ''; + const zone = config.zones?.[name]; + return cfSuccess( + zone ? [{ id: zone.id, name: zone.name, account: { id: zone.accountId } }] : [], + ); + } + + if (path.includes('/email/routing/dns') && method === 'POST') { + const body = typeof init?.body === 'string' ? JSON.parse(init.body) : undefined; + await record({ tag: 'routingDns', method, url, body }); + return fail('routingDns') ?? cfSuccess({}); + } + + if (path.includes('/email/sending/subdomains') && method === 'POST') { + const body = typeof init?.body === 'string' ? JSON.parse(init.body) : undefined; + await record({ tag: 'onboardSending', method, url, body }); + return fail('onboardSending') ?? cfSuccess({}); + } + + if (path.includes('/email/sending/subdomains') && method === 'GET') { + await record({ tag: 'sendingSubdomainsGet', method, url }); + const failure = fail('sendingSubdomainsGet'); + if (failure) { + return failure; + } + return cfSuccess(config.sendingSubdomains ?? []); + } + + // The settings GET is anchored at the exact path — /email/routing/dns + // and /email/routing/rules/... must not match it. + if (/\/zones\/[^/]+\/email\/routing$/.test(path) && method === 'GET') { + await record({ tag: 'routingSettingsGet', method, url }); + const failure = fail('routingSettingsGet'); + if (failure) { + return failure; + } + return cfSuccess(config.routingSettings ?? { enabled: true }); + } + + const catchAllMatch = /\/zones\/([^/]+)\/email\/routing\/rules\/catch_all$/.exec(path); + if (catchAllMatch && method === 'GET') { + await record({ tag: 'catchAllGet', method, url }); + const failure = fail('catchAllGet'); + if (failure) { + return failure; + } + const state = catchAllStateFor(catchAllMatch[1]!); + if (state === null) { + return cfError('rule not found', 404); + } + return cfSuccess({ + enabled: state.enabled, + matchers: [{ type: 'all' }], + actions: + state.workerName === null + ? [{ type: 'drop', value: [] }] + : [{ type: 'worker', value: [state.workerName] }], + }); + } + + if (catchAllMatch && method === 'PUT') { + const body = typeof init?.body === 'string' ? JSON.parse(init.body) : undefined; + await record({ tag: 'catchAll', method, url, body }); + const failure = fail('catchAll'); + if (failure) { + return failure; + } + // The write mutates the zone's rule: a worker action names the worker, + // anything else (the reset's drop) is a disabled non-worker rule. + const actions = (body as { actions?: Array<{ type: string; value?: Array }> }) + ?.actions; + const worker = actions?.find((action) => action.type === 'worker'); + catchAllStates.set(catchAllMatch[1]!, { + enabled: Boolean((body as { enabled?: unknown })?.enabled), + workerName: worker?.value?.[0] ?? null, + }); + return cfSuccess({}); + } + + const secretsMatch = /\/workers\/scripts\/([^/]+)\/secrets$/.exec(path); + if (secretsMatch && method === 'PUT') { + const parsed = typeof init?.body === 'string' ? JSON.parse(init.body) : {}; + const text = typeof parsed.text === 'string' ? parsed.text : ''; + // The worker's CURRENT secret, tracked by sha256 only — the same + // form the ROUTING mirror stores — so a test can prove worker, row + // and mirror carry ONE secret without the value ever being recorded. + secretHashes.set(secretsMatch[1]!, await hashEmailRelaySecret(text)); + await record({ + tag: 'secretPut', + method, + url, + secret: { + name: String(parsed.name), + type: String(parsed.type), + // Shape, never the value: length class + alphabet. + textShape: `len=${text.length} ${/^[A-Za-z0-9_-]+$/.test(text) ? 'base64url' : 'other'}`, + }, + }); + return fail('secretPut') ?? cfSuccess({}); + } + + const scriptMatch = /\/accounts\/[^/]+\/workers\/scripts\/([^/?#]+)$/.exec(path); + if (scriptMatch && method === 'GET') { + await record({ tag: 'scriptGet', method, url }); + const failure = fail('scriptGet'); + if (failure) { + return failure; + } + if (config.existingScripts?.includes(scriptMatch[1]!)) { + // The raw script GET answers source bytes, not a v4 envelope. + return new Response('export default {}', { status: 200 }); + } + return new Response('not found', { status: 404 }); + } + if (scriptMatch && method === 'PUT') { + let metadata: unknown; + if (init?.body instanceof FormData) { + const metaEntry = init.body.get('metadata'); + if (metaEntry instanceof Blob) { + metadata = JSON.parse(await metaEntry.text()); + } + } + await record({ tag: 'scriptPut', method, url, body: metadata }); + return fail('scriptPut') ?? cfSuccess({}); + } + if (scriptMatch && method === 'DELETE') { + await record({ tag: 'scriptDelete', method, url }); + return fail('scriptDelete') ?? cfSuccess({}); + } + + throw new Error(`Unmocked customer fetch: ${method} ${url}`); + }); + + vi.stubGlobal('fetch', fn); + return { calls, fn, catchAllStates, secretHashes }; +} + +export function callsByTag(calls: Array, tag: StubFailTag): Array { + return calls.filter((call) => call.tag === tag); +} + +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function tags(calls: Array): Array { + return calls.map((call) => call.tag); +} diff --git a/platform/packages/api/test/integration/helpers.ts b/platform/packages/api/test/integration/helpers.ts index 0b84bdced..4d2b8f0ff 100644 --- a/platform/packages/api/test/integration/helpers.ts +++ b/platform/packages/api/test/integration/helpers.ts @@ -23,6 +23,7 @@ import { authCodes, operatorSessions, operatorEvents, + platformSettings, sandboxDrainCheckpoints, builds, githubConnections, @@ -499,6 +500,7 @@ export async function clearTables(db: Db): Promise { await db.delete(schema.platformLoginRevisions); await db.delete(schema.platformLoginConnections); await db.delete(schema.platformAuthSettings); + await db.delete(platformSettings); } /** Establish a real revocable operator session for integration requests. */ diff --git a/platform/packages/api/test/integration/operator-email-domains.test.ts b/platform/packages/api/test/integration/operator-email-domains.test.ts new file mode 100644 index 000000000..d2a4c4a84 --- /dev/null +++ b/platform/packages/api/test/integration/operator-email-domains.test.ts @@ -0,0 +1,703 @@ +import { eq } from 'drizzle-orm'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, + vi, +} from 'vite-plus/test'; +import { hmacSignState } from '../../src/auth/hmac.ts'; +import { + encryptEmailDomainRelaySecret, + encryptEmailDomainToken, + type EmailDomain, +} from '../../src/email/domains.ts'; +import { RELAY_WORKER_BASE_NAME } from '../../src/email/relay-engine.ts'; +import { RELAY_VERSION } from '../../src/email/relay-worker.ts'; +import type { ApiEnv } from '../../src/env.ts'; +import { app } from '../../src/index.ts'; +import { emailDomains, operatorEvents } from '../../src/schema.ts'; +import { callsByTag, stubCustomerCf, tags } from './email-domain-cf-stub.ts'; +import { + CF_ACCOUNT_ID, + CF_API_TOKEN, + clearTables, + createOperatorToken, + createTestProject, + createTestUser, + JWT_SECRET, + setupEnv, + teardownEnv, + type TestEnv, +} from './helpers.ts'; + +let env: TestEnv; +let token: string; + +// Dummy stand-ins — never real token material. +const CUSTOMER_TOKEN = 'cf-customer-token-dummy'; +const RELAY_SECRET = 'relay-secret-dummy'; +/** A zone in the platform's own account — what the platform credential may register. */ +const PLATFORM_ZONE = { id: 'zone_platform', name: 'example.com', accountId: CF_ACCOUNT_ID }; +/** A zone in a customer's account — refused on the platform credential. */ +const FOREIGN_ZONE = { id: 'zone_foreign', name: 'elsewhere.test', accountId: 'acct_customer' }; + +beforeAll(async () => { + env = await setupEnv(); +}); +afterAll(async () => { + await teardownEnv(); +}); +beforeEach(async () => { + await clearTables(env.db); + const mirrors = await env.ROUTING.list({ prefix: 'email-domain:' }); + await Promise.all(mirrors.keys.map((key) => env.ROUTING.delete(key.name))); + const admin = await createTestUser(env.db, 'domain-admin', 'github', undefined, 'free', 'admin'); + token = await createOperatorToken(env.db, admin); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +function req( + path: string, + body?: Record, + overrides?: Partial, +) { + return app.fetch( + new Request(`http://localhost/operator/v1${path}`, { + method: body ? 'POST' : 'GET', + headers: { + Authorization: `Bearer ${token}`, + ...(body ? { 'Content-Type': 'application/json' } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }), + { ...env, ...overrides }, + ); +} + +async function rowFor(domain: string): Promise { + const rows = await env.db.select().from(emailDomains).where(eq(emailDomains.domain, domain)); + return rows[0]; +} + +async function events() { + return env.db.select().from(operatorEvents); +} + +async function seedOwnerDomain(projectId: string, domain: string): Promise { + const tokenEnc = await encryptEmailDomainToken(env, projectId, domain, CUSTOMER_TOKEN); + const relayEnc = await encryptEmailDomainRelaySecret(env, projectId, domain, RELAY_SECRET); + await env.db.insert(emailDomains).values({ + id: `edom_owner_${domain.replace(/[^a-z0-9]/g, '')}`, + project_id: projectId, + domain, + zone_id: FOREIGN_ZONE.id, + account_id: FOREIGN_ZONE.accountId, + mode: 'apex', + status: 'active', + credential_kind: 'api_token', + managed_by: 'owner', + token_ciphertext: tokenEnc.ciphertext, + token_iv: tokenEnc.iv, + token_key_version: tokenEnc.keyVersion, + relay_ciphertext: relayEnc.ciphertext, + relay_iv: relayEnc.iv, + relay_key_version: relayEnc.keyVersion, + relay_worker_name: RELAY_WORKER_BASE_NAME, + relay_version: RELAY_VERSION, + }); + return (await rowFor(domain))!; +} + +async function seedProject(slug = 'hr-portal') { + const owner = await createTestUser(env.db, `${slug}-owner`); + return createTestProject(env.db, owner.id, slug); +} + +describe('operator email domain-add', () => { + test('previews without any Cloudflare call, then registers on the platform credential', async () => { + const project = await seedProject(); + const stub = stubCustomerCf({ zones: { 'example.com': PLATFORM_ZONE } }); + + const preview = await req('/email/domains', { + domain: 'Example.COM', + project: project.slug, + dryRun: true, + }); + expect(preview.status).toBe(200); + expect(await preview.json()).toMatchObject({ + dryRun: true, + action: 'email.domain.add', + target: { + domain: 'example.com', + projectId: project.id, + slug: project.slug, + credentialKind: 'platform', + }, + existing: null, + plan: 'setup', + mx: 'apex', + }); + // Public DNS only — the credential is never spent on a preview. + expect(tags(stub.calls)).toEqual(['mxLookup']); + expect(await rowFor('example.com')).toBeUndefined(); + expect(await events()).toHaveLength(0); + + const response = await req('/email/domains', { domain: 'example.com', project: project.id }); + expect(response.status).toBe(200); + const body = (await response.json()) as { action: string; domain: Record }; + expect(body.action).toBe('email.domain.add'); + expect(body.domain).toMatchObject({ + domain: 'example.com', + status: 'active', + credential_kind: 'platform', + managed_by: 'admin', + relay_worker_name: RELAY_WORKER_BASE_NAME, + }); + const row = (await rowFor('example.com'))!; + expect(row.credential_kind).toBe('platform'); + expect(row.managed_by).toBe('admin'); + expect(row.token_ciphertext).toBeNull(); + expect(row.token_iv).toBeNull(); + expect(row.token_key_version).toBeNull(); + expect(row.account_id).toBe(CF_ACCOUNT_ID); + // No user-token verify: the runtime token is not a user token. + expect(callsByTag(stub.calls, 'verifyToken')).toHaveLength(0); + // Every customer-account call went out with the runtime token. + for (const call of stub.fn.mock.calls) { + const init = call[1] as RequestInit | undefined; + const auth = new Headers(init?.headers).get('Authorization'); + if (auth) { + expect(auth).toBe(`Bearer ${CF_API_TOKEN}`); + } + } + const recorded = await events(); + expect(recorded).toHaveLength(1); + expect(recorded[0]).toMatchObject({ action: 'email/domains', status: 'success' }); + expect(JSON.parse(recorded[0]!.target)).toEqual({ + domain: 'example.com', + project: project.id, + }); + }); + + test('previews a re-run in the stored mode even when public MX is present', async () => { + const project = await seedProject(); + stubCustomerCf({ zones: { 'example.com': PLATFORM_ZONE } }); + expect( + (await req('/email/domains', { domain: 'example.com', project: project.id })).status, + ).toBe(200); + stubCustomerCf({ mx: { 'example.com': ['route1.mx.cloudflare.net'] } }); + + const preview = await req('/email/domains', { + domain: 'example.com', + project: project.id, + dryRun: true, + }); + const body = (await preview.json()) as Record; + expect(body).toMatchObject({ + plan: 'rerun', + mx: 'subdomain', + effect: expect.stringContaining('stored apex mode'), + }); + expect(String(body.effect)).not.toContain('setup will refuse'); + }); + + test('keeps the MX refusal preview for an existing row with no stored mode', async () => { + const project = await seedProject(); + stubCustomerCf({ zones: { 'example.com': PLATFORM_ZONE } }); + expect( + (await req('/email/domains', { domain: 'example.com', project: project.id })).status, + ).toBe(200); + await env.db + .update(emailDomains) + .set({ mode: null }) + .where(eq(emailDomains.domain, 'example.com')); + stubCustomerCf({ mx: { 'example.com': ['route1.mx.cloudflare.net'] } }); + + const preview = await req('/email/domains', { + domain: 'example.com', + project: project.id, + dryRun: true, + }); + expect(await preview.json()).toMatchObject({ + plan: 'rerun', + mx: 'subdomain', + effect: expect.stringContaining('setup will refuse'), + }); + }); + + test('reports a failed setup as a partial failure and a failed audit event', async () => { + const project = await seedProject(); + stubCustomerCf({ + zones: { 'example.com': PLATFORM_ZONE }, + failOn: { zones: { message: 'zone lookup failed', status: 502 } }, + }); + + const response = await req('/email/domains', { domain: 'example.com', project: project.id }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + ok: false, + domain: { status: 'failed', setup_state: { failed_step: 'zone_lookup' } }, + }); + expect((await events()).at(-1)).toMatchObject({ action: 'email/domains', status: 'failed' }); + }); + + test('keeps a pending setup as a successful operator result', async () => { + const project = await seedProject(); + stubCustomerCf({ + zones: { 'example.com': PLATFORM_ZONE }, + failOn: { routingDns: { message: 'use the dashboard', status: 403 } }, + }); + + const response = await req('/email/domains', { + domain: 'mail.example.com', + project: project.id, + }); + expect(response.status).toBe(200); + const body = (await response.json()) as Record & { + domain: Record; + }; + expect(body.domain.status).toBe('pending'); + expect(Object.hasOwn(body, 'ok')).toBe(false); + }); + + test('refuses a zone outside the platform account before any write', async () => { + const project = await seedProject(); + const stub = stubCustomerCf({ zones: { 'elsewhere.test': FOREIGN_ZONE } }); + const response = await req('/email/domains', { domain: 'elsewhere.test', project: project.id }); + // The engine records the failure on the row; the operator answers 200 + // with the row's error like the owner route does. + expect(response.status).toBe(200); + const body = (await response.json()) as { domain: Record }; + expect(body.domain).toMatchObject({ + status: 'failed', + setup_state: { failed_step: 'zone_lookup' }, + }); + expect(String(body.domain.error)).toContain(FOREIGN_ZONE.accountId); + expect(String(body.domain.error)).toContain(CF_ACCOUNT_ID); + expect(String(body.domain.error)).toContain('--token-stdin'); + // Zone lookup only — nothing was enabled, deployed or written to the zone. + expect(new Set(tags(stub.calls))).toEqual(new Set(['zones'])); + expect((await rowFor('elsewhere.test'))?.status).toBe('failed'); + }); + + test('a pasted token registers a zone elsewhere as an api_token admin row', async () => { + const project = await seedProject(); + const stub = stubCustomerCf({ zones: { 'elsewhere.test': FOREIGN_ZONE } }); + const preview = await req('/email/domains', { + domain: 'elsewhere.test', + project: project.id, + apiToken: CUSTOMER_TOKEN, + dryRun: true, + }); + expect(await preview.json()).toMatchObject({ target: { credentialKind: 'api_token' } }); + + const response = await req('/email/domains', { + domain: 'elsewhere.test', + project: project.id, + apiToken: CUSTOMER_TOKEN, + }); + expect(response.status).toBe(200); + const row = (await rowFor('elsewhere.test'))!; + expect(row).toMatchObject({ + credential_kind: 'api_token', + managed_by: 'admin', + status: 'active', + account_id: FOREIGN_ZONE.accountId, + }); + expect(row.token_ciphertext).not.toBeNull(); + expect(callsByTag(stub.calls, 'verifyToken')).toHaveLength(1); + const recorded = await events(); + expect(JSON.stringify(recorded)).not.toContain(CUSTOMER_TOKEN); + }); + + test('refuses to re-run a pasted-token row on the platform credential when its zone is elsewhere', async () => { + const project = await seedProject(); + stubCustomerCf({ zones: { 'elsewhere.test': FOREIGN_ZONE } }); + expect( + ( + await req('/email/domains', { + domain: 'elsewhere.test', + project: project.id, + apiToken: CUSTOMER_TOKEN, + }) + ).status, + ).toBe(200); + const before = (await rowFor('elsewhere.test'))!; + expect(before).toMatchObject({ credential_kind: 'api_token', status: 'active' }); + + const stub = stubCustomerCf({ zones: { 'elsewhere.test': FOREIGN_ZONE } }); + const response = await req('/email/domains', { domain: 'elsewhere.test', project: project.id }); + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ + message: expect.stringContaining(FOREIGN_ZONE.accountId), + }); + // The stored token is untouched and the runtime token never left. + const after = (await rowFor('elsewhere.test'))!; + expect(after.credential_kind).toBe('api_token'); + expect(after.token_ciphertext).toBe(before.token_ciphertext); + expect(stub.calls).toEqual([]); + }); + + test('does not take over a row the project owner registered', async () => { + const project = await seedProject(); + await seedOwnerDomain(project.id, 'owned.test'); + const stub = stubCustomerCf({ zones: { 'owned.test': FOREIGN_ZONE } }); + const response = await req('/email/domains', { domain: 'owned.test', project: project.id }); + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ + message: expect.stringContaining('registered by its project owner'), + }); + expect(stub.calls).toEqual([]); + expect((await rowFor('owned.test'))?.managed_by).toBe('owner'); + }); + + test('refuses a domain registered to another project', async () => { + const project = await seedProject(); + const other = await seedProject('other-app'); + await seedOwnerDomain(other.id, 'taken.test'); + const response = await req('/email/domains', { domain: 'taken.test', project: project.id }); + expect(response.status).toBe(409); + }); + + test.each([ + ['an unknown project', { domain: 'example.com', project: 'nope' }, 404], + ['an invalid domain', { domain: 'not a domain', project: 'hr-portal' }, 400], + ['a missing project', { domain: 'example.com' }, 400], + ])('rejects %s', async (_label, body, status) => { + await seedProject(); + const response = await req('/email/domains', body); + expect(response.status).toBe(status); + }); + + test('answers 503 when the platform has no runtime token', async () => { + const project = await seedProject(); + stubCustomerCf({ zones: { 'example.com': PLATFORM_ZONE } }); + const response = await req( + '/email/domains', + { domain: 'example.com', project: project.id }, + { CF_API_TOKEN: '' }, + ); + expect(response.status).toBe(503); + expect(await rowFor('example.com')).toBeUndefined(); + }); +}); + +describe('operator email domain list, status, sync and remove', () => { + async function addPlatformDomain(projectId: string, domain = 'example.com') { + const stub = stubCustomerCf({ zones: { [domain]: { ...PLATFORM_ZONE, name: domain } } }); + const response = await req('/email/domains', { domain, project: projectId }); + expect(response.status).toBe(200); + return stub; + } + + test("lists every row with its project, or one project's rows", async () => { + const project = await seedProject(); + const other = await seedProject('other-app'); + await addPlatformDomain(project.id); + await seedOwnerDomain(other.id, 'owned.test'); + + const all = (await (await req('/email/domains')).json()) as { + domains: Array<{ domain: string; managed_by: string; project: { slug: string } }>; + }; + expect(all.domains.map((d) => [d.domain, d.managed_by, d.project.slug])).toEqual([ + ['example.com', 'admin', project.slug], + ['owned.test', 'owner', other.slug], + ]); + const filtered = (await (await req(`/email/domains?project=${other.slug}`)).json()) as { + domains: Array<{ domain: string }>; + }; + expect(filtered.domains.map((d) => d.domain)).toEqual(['owned.test']); + expect((await req('/email/domains?project=missing')).status).toBe(404); + }); + + test('status probes a platform row with a zone-scoped read, never token verify', async () => { + const project = await seedProject(); + await addPlatformDomain(project.id); + const stub = stubCustomerCf({ zones: { 'example.com': PLATFORM_ZONE } }); + const response = await req('/email/domains/example.com'); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + domain: 'example.com', + credential_kind: 'platform', + managed_by: 'admin', + credential_status: 'active', + project: { id: project.id, slug: project.slug }, + }); + expect(callsByTag(stub.calls, 'verifyToken')).toHaveLength(0); + expect(callsByTag(stub.calls, 'routingSettingsGet').length).toBeGreaterThan(0); + }); + + test('status walks parent zones for a platform row that has not stored its zone yet', async () => { + const project = await seedProject(); + const zone = { ...PLATFORM_ZONE, name: 'acme.com' }; + stubCustomerCf({ zones: { 'acme.com': zone } }); + expect( + (await req('/email/domains', { domain: 'mail.acme.com', project: project.id })).status, + ).toBe(200); + await env.db + .update(emailDomains) + .set({ zone_id: null }) + .where(eq(emailDomains.domain, 'mail.acme.com')); + const stub = stubCustomerCf({ zones: { 'acme.com': zone } }); + + const response = await req('/email/domains/mail.acme.com'); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ credential_status: 'active' }); + expect( + callsByTag(stub.calls, 'zones').map((call) => new URL(call.url).searchParams.get('name')), + ).toEqual(['mail.acme.com', 'acme.com']); + }); + + test('status reports probe_failed when no parent zone is visible for a platform row', async () => { + const project = await seedProject(); + const zone = { ...PLATFORM_ZONE, name: 'acme.com' }; + stubCustomerCf({ zones: { 'acme.com': zone } }); + expect( + (await req('/email/domains', { domain: 'mail.acme.com', project: project.id })).status, + ).toBe(200); + await env.db + .update(emailDomains) + .set({ zone_id: null }) + .where(eq(emailDomains.domain, 'mail.acme.com')); + stubCustomerCf({ zones: {} }); + + const response = await req('/email/domains/mail.acme.com'); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ credential_status: 'probe_failed' }); + }); + + test('status reports probe_failed, not revoked, when the runtime token is refused', async () => { + const project = await seedProject(); + await addPlatformDomain(project.id); + stubCustomerCf({ + zones: { 'example.com': PLATFORM_ZONE }, + failOn: { routingSettingsGet: { message: 'forbidden', status: 403 } }, + }); + const body = (await (await req('/email/domains/example.com')).json()) as Record< + string, + unknown + >; + expect(body.credential_status).toBe('probe_failed'); + expect(body.status).toBe('active'); + expect((await rowFor('example.com'))?.status).toBe('active'); + }); + + test('sync previews, then redeploys the relay and rotates its secret', async () => { + const project = await seedProject(); + await addPlatformDomain(project.id); + const before = (await rowFor('example.com'))!; + const stub = stubCustomerCf({ zones: { 'example.com': PLATFORM_ZONE } }); + + const preview = await req('/email/domains/example.com/sync', { dryRun: true }); + expect(await preview.json()).toMatchObject({ + dryRun: true, + action: 'email.domain.sync', + target: { domain: 'example.com', projectId: project.id }, + }); + expect(stub.calls).toEqual([]); + + const response = await req('/email/domains/example.com/sync', {}); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ action: 'email.domain.sync' }); + expect(callsByTag(stub.calls, 'scriptPut')).toHaveLength(1); + expect(callsByTag(stub.calls, 'secretPut')).toHaveLength(1); + const after = (await rowFor('example.com'))!; + expect(after.relay_ciphertext).not.toBe(before.relay_ciphertext); + expect(after.credential_kind).toBe('platform'); + const recorded = await events(); + expect(recorded.map((event) => event.action).sort()).toEqual( + ['email/domains', 'email/domains/example.com/sync'].sort(), + ); + const syncEvent = recorded.find((event) => event.action === 'email/domains/example.com/sync'); + expect(JSON.parse(syncEvent!.target)).toEqual({ domain: 'example.com' }); + }); + + test('reports a sync whose stored token was revoked as a partial failure', async () => { + const project = await seedProject(); + stubCustomerCf({ zones: { 'example.com': PLATFORM_ZONE } }); + expect( + ( + await req('/email/domains', { + domain: 'example.com', + project: project.id, + apiToken: CUSTOMER_TOKEN, + }) + ).status, + ).toBe(200); + stubCustomerCf({ verifyStatus: 'revoked' }); + + const response = await req('/email/domains/example.com/sync', {}); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + ok: false, + domain: { status: 'token_revoked' }, + }); + expect((await events()).at(-1)).toMatchObject({ + action: 'email/domains/example.com/sync', + status: 'failed', + }); + }); + + test('remove resets the catch-all, deletes the relay and drops the row', async () => { + const project = await seedProject(); + await addPlatformDomain(project.id); + const stub = stubCustomerCf({ + zones: { 'example.com': PLATFORM_ZONE }, + catchAllRule: { enabled: true, workerName: RELAY_WORKER_BASE_NAME }, + }); + const preview = await req('/email/domains/example.com/remove', { dryRun: true }); + expect(await preview.json()).toMatchObject({ + dryRun: true, + action: 'email.domain.remove', + target: { domain: 'example.com', force: false }, + managed_by: 'admin', + }); + const response = await req('/email/domains/example.com/remove', {}); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + action: 'email.domain.remove', + domain: { ok: true, removed: true }, + }); + expect(callsByTag(stub.calls, 'scriptDelete')).toHaveLength(1); + expect(await rowFor('example.com')).toBeUndefined(); + expect(await env.ROUTING.get('email-domain:example.com')).toBeNull(); + const recorded = await events(); + expect(JSON.parse(recorded.at(-1)!.target)).toEqual({ domain: 'example.com', force: false }); + }); + + test('a kept row after a failed relay delete is reported as a failed mutation, not success', async () => { + const project = await seedProject(); + await addPlatformDomain(project.id); + stubCustomerCf({ + zones: { 'example.com': PLATFORM_ZONE }, + catchAllRule: { enabled: true, workerName: RELAY_WORKER_BASE_NAME }, + failOn: { scriptDelete: { message: 'boom', status: 500 } }, + }); + const response = await req('/email/domains/example.com/remove', {}); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + action: 'email.domain.remove', + ok: false, + domain: { ok: false, removed: false, failed_steps: expect.arrayContaining(['delete_relay']) }, + }); + expect((await rowFor('example.com'))?.setup_state).toMatchObject({ failed_step: 'remove' }); + const recorded = await events(); + expect(recorded.at(-1)).toMatchObject({ + action: 'email/domains/example.com/remove', + status: 'failed', + }); + }); + + test('remove --force is a JSON boolean and drops the row past a failed relay delete', async () => { + const project = await seedProject(); + await addPlatformDomain(project.id); + stubCustomerCf({ + zones: { 'example.com': PLATFORM_ZONE }, + catchAllRule: { enabled: true, workerName: RELAY_WORKER_BASE_NAME }, + failOn: { scriptDelete: { message: 'boom', status: 500 } }, + }); + expect((await req('/email/domains/example.com/remove', { force: 'yes' })).status).toBe(400); + const response = await req('/email/domains/example.com/remove', { force: true }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + domain: { ok: true, removed: true, forced: true }, + }); + expect(await rowFor('example.com')).toBeUndefined(); + }); + + test("an administrator may sync and remove an owner's row, but never re-add it", async () => { + const project = await seedProject(); + await seedOwnerDomain(project.id, 'owned.test'); + const stub = stubCustomerCf({ + zones: { 'owned.test': FOREIGN_ZONE }, + catchAllRule: { enabled: true, workerName: RELAY_WORKER_BASE_NAME }, + }); + + expect( + (await req('/email/domains', { domain: 'owned.test', project: project.id })).status, + ).toBe(409); + const sync = await req('/email/domains/owned.test/sync', {}); + expect(sync.status).toBe(200); + expect(await sync.json()).toMatchObject({ action: 'email.domain.sync' }); + // The owner's stored token did the work — never the runtime token. + for (const call of stub.fn.mock.calls) { + const auth = new Headers((call[1] as RequestInit | undefined)?.headers).get('Authorization'); + if (auth) { + expect(auth).toBe(`Bearer ${CUSTOMER_TOKEN}`); + } + } + expect((await rowFor('owned.test'))?.managed_by).toBe('owner'); + + const remove = await req('/email/domains/owned.test/remove', {}); + expect(remove.status).toBe(200); + expect(await remove.json()).toMatchObject({ domain: { ok: true, removed: true } }); + expect(await rowFor('owned.test')).toBeUndefined(); + }); + + test('an existing platform row survives runtime-token loss: probe_failed, no lease, 503 sync', async () => { + const project = await seedProject(); + await addPlatformDomain(project.id); + const stub = stubCustomerCf({ zones: { 'example.com': PLATFORM_ZONE } }); + const noToken = { CF_API_TOKEN: '' }; + + const status = await req('/email/domains/example.com', undefined, noToken); + expect(status.status).toBe(200); + expect(await status.json()).toMatchObject({ + status: 'active', + credential_status: 'probe_failed', + }); + expect((await rowFor('example.com'))?.status).toBe('active'); + + const lease = await app.fetch( + new Request('http://localhost/internal/email/domain-credential', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-void-internal': await hmacSignState('example.com', JWT_SECRET), + }, + body: JSON.stringify({ domain: 'example.com' }), + }), + { ...env, ...noToken }, + ); + expect(lease.status).toBe(502); + expect(JSON.stringify(await lease.json())).not.toContain('token'); + + const sync = await req('/email/domains/example.com/sync', {}, noToken); + expect(sync.status).toBe(503); + expect(await sync.json()).toMatchObject({ + message: expect.stringContaining('platform credential unavailable'), + }); + expect(stub.calls).toEqual([]); + }); + + test('status, sync and remove answer 404 for an unknown domain', async () => { + expect((await req('/email/domains/nope.test')).status).toBe(404); + expect((await req('/email/domains/nope.test/sync', {})).status).toBe(404); + expect((await req('/email/domains/nope.test/remove', {})).status).toBe(404); + }); + + test('the proxy leases the runtime token for a platform row', async () => { + const project = await seedProject(); + await addPlatformDomain(project.id); + const response = await app.fetch( + new Request('http://localhost/internal/email/domain-credential', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-void-internal': await hmacSignState('example.com', JWT_SECRET), + }, + body: JSON.stringify({ domain: 'example.com' }), + }), + env, + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ kind: 'api_token', token: CF_API_TOKEN }); + }); +}); diff --git a/platform/packages/api/test/integration/operator-email.test.ts b/platform/packages/api/test/integration/operator-email.test.ts new file mode 100644 index 000000000..357ba002d --- /dev/null +++ b/platform/packages/api/test/integration/operator-email.test.ts @@ -0,0 +1,424 @@ +import { eq } from 'drizzle-orm'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, + vi, +} from 'vite-plus/test'; +import { + EMAIL_DOMAIN_MANAGEMENT_SETTING, + EMAIL_RECIPIENT_POLICY_MIRROR_KEY, + EMAIL_RECIPIENT_POLICY_SETTING, +} from '../../src/email/settings.ts'; +import type { ApiEnv } from '../../src/env.ts'; +import { app } from '../../src/index.ts'; +import { operatorEvents, platformSettings } from '../../src/schema.ts'; +import { + clearTables, + createOperatorToken, + createTestProject, + createTestUser, + setupEnv, + teardownEnv, + type TestEnv, +} from './helpers.ts'; + +let env: TestEnv; +let token: string; + +beforeAll(async () => { + env = await setupEnv(); +}); +afterAll(async () => { + await teardownEnv(); +}); +beforeEach(async () => { + await clearTables(env.db); + await env.ROUTING.delete(EMAIL_RECIPIENT_POLICY_MIRROR_KEY); + const admin = await createTestUser(env.db, 'email-admin', 'github', undefined, 'free', 'admin'); + token = await createOperatorToken(env.db, admin); +}); +afterEach(() => { + vi.restoreAllMocks(); +}); + +function req( + path: string, + body?: Record, + overrides?: Partial, +) { + return app.fetch( + new Request(`http://localhost/operator/v1${path}`, { + method: body ? 'POST' : 'GET', + headers: { + Authorization: `Bearer ${token}`, + ...(body ? { 'Content-Type': 'application/json' } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }), + { ...env, ...overrides }, + ); +} + +async function setting(key: string): Promise { + const rows = await env.db + .select({ value: platformSettings.value }) + .from(platformSettings) + .where(eq(platformSettings.key, key)); + return rows[0]?.value ?? null; +} + +async function events() { + return env.db.select().from(operatorEvents); +} + +describe('operator email recipient policy', () => { + test('defaults to verified-only and repairs a missing mirror on read', async () => { + const response = await req('/email/policy'); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ policy: { mode: 'verified', domains: [] } }); + expect(await env.ROUTING.get(EMAIL_RECIPIENT_POLICY_MIRROR_KEY)).toBe( + JSON.stringify({ mode: 'verified', domains: [] }), + ); + }); + + test('previews without writing, then applies to the database and the KV mirror', async () => { + const preview = await req('/email/policy', { + mode: 'domains', + domains: 'Corp.Example, example.net corp.example', + dryRun: true, + }); + expect(preview.status).toBe(200); + const previewBody = (await preview.json()) as Record; + expect(previewBody).toMatchObject({ + dryRun: true, + action: 'email.policy.set', + target: { mode: 'domains', domains: ['corp.example', 'example.net'] }, + previous: { mode: 'verified', domains: [] }, + }); + expect(String(previewBody.effect)).toContain("platform's own mail domain"); + const anyPreview = await req('/email/policy', { mode: 'any', dryRun: true }); + expect(String(((await anyPreview.json()) as Record).effect)).toContain( + "platform's own mail domain", + ); + expect(await setting(EMAIL_RECIPIENT_POLICY_SETTING)).toBeNull(); + expect(await events()).toHaveLength(0); + + const response = await req('/email/policy', { + mode: 'domains', + domains: 'Corp.Example, example.net corp.example', + }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + action: 'email.policy.set', + policy: { mode: 'domains', domains: ['corp.example', 'example.net'] }, + }); + expect(await setting(EMAIL_RECIPIENT_POLICY_SETTING)).toEqual({ + mode: 'domains', + domains: ['corp.example', 'example.net'], + }); + expect(await env.ROUTING.get(EMAIL_RECIPIENT_POLICY_MIRROR_KEY)).toBe( + JSON.stringify({ mode: 'domains', domains: ['corp.example', 'example.net'] }), + ); + const recorded = await events(); + expect(recorded).toHaveLength(1); + expect(recorded[0]).toMatchObject({ action: 'email/policy', status: 'success' }); + expect(JSON.parse(recorded[0]!.target)).toEqual({ + mode: 'domains', + domains: 'corp.example, example.net corp.example', + }); + + const read = await req('/email/policy'); + expect(await read.json()).toEqual({ + policy: { mode: 'domains', domains: ['corp.example', 'example.net'] }, + }); + }); + + test('`any` and `verified` drop the domain list', async () => { + await req('/email/policy', { mode: 'domains', domains: 'corp.example' }); + const response = await req('/email/policy', { mode: 'any', domains: 'ignored.example' }); + expect(response.status).toBe(200); + expect(await setting(EMAIL_RECIPIENT_POLICY_SETTING)).toEqual({ mode: 'any', domains: [] }); + expect(await env.ROUTING.get(EMAIL_RECIPIENT_POLICY_MIRROR_KEY)).toBe( + JSON.stringify({ mode: 'any', domains: [] }), + ); + }); + + test.each([ + ['an unknown mode', { mode: 'open' }], + ['`domains` without any domain', { mode: 'domains' }], + ['`domains` with an invalid domain', { mode: 'domains', domains: 'not a domain' }], + ])('rejects %s with 400 and records a failed event', async (_label, body) => { + const response = await req('/email/policy', body); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ error: 'invalid_argument' }); + expect(await setting(EMAIL_RECIPIENT_POLICY_SETTING)).toBeNull(); + expect(await env.ROUTING.get(EMAIL_RECIPIENT_POLICY_MIRROR_KEY)).toBeNull(); + const recorded = await events(); + expect(recorded).toHaveLength(1); + expect(recorded[0]).toMatchObject({ action: 'email/policy', status: 'failed' }); + }); + + test('a mirror write failure commits the policy but leaves the proxy on the previous, stricter policy until republished', async () => { + await req('/email/policy', { mode: 'domains', domains: 'corp.example' }); + const put = vi.spyOn(env.ROUTING, 'put').mockRejectedValueOnce(new Error('kv down')); + const response = await req('/email/policy', { mode: 'any' }); + expect(response.status).toBe(503); + expect(await response.json()).toMatchObject({ error: 'mirror_failed' }); + expect(await setting(EMAIL_RECIPIENT_POLICY_SETTING)).toEqual({ mode: 'any', domains: [] }); + expect(await env.ROUTING.get(EMAIL_RECIPIENT_POLICY_MIRROR_KEY)).toBe( + JSON.stringify({ mode: 'domains', domains: ['corp.example'] }), + ); + put.mockRestore(); + await req('/email/policy'); + expect(await env.ROUTING.get(EMAIL_RECIPIENT_POLICY_MIRROR_KEY)).toBe( + JSON.stringify({ mode: 'any', domains: [] }), + ); + }); + + test('a failed tightening changes nothing', async () => { + await req('/email/policy', { mode: 'any' }); + const put = vi.spyOn(env.ROUTING, 'put').mockRejectedValueOnce(new Error('kv down')); + const response = await req('/email/policy', { mode: 'verified' }); + expect(response.status).toBe(503); + expect(await setting(EMAIL_RECIPIENT_POLICY_SETTING)).toEqual({ mode: 'any', domains: [] }); + expect(await env.ROUTING.get(EMAIL_RECIPIENT_POLICY_MIRROR_KEY)).toBe( + JSON.stringify({ mode: 'any', domains: [] }), + ); + put.mockRestore(); + }); + + test('publishes with exactly one KV write', async () => { + const put = vi.spyOn(env.ROUTING, 'put'); + const del = vi.spyOn(env.ROUTING, 'delete'); + + expect((await req('/email/policy', { mode: 'domains', domains: 'a.example' })).status).toBe( + 200, + ); + expect(put).toHaveBeenCalledTimes(1); + expect(del).not.toHaveBeenCalled(); + + expect((await req('/email/policy', { mode: 'verified' })).status).toBe(200); + expect(put).toHaveBeenCalledTimes(2); + expect(del).not.toHaveBeenCalled(); + }); + + test('a list edited both ways publishes as a widening', async () => { + await req('/email/policy', { mode: 'domains', domains: 'a.example,b.example' }); + const put = vi.spyOn(env.ROUTING, 'put').mockRejectedValueOnce(new Error('kv down')); + const response = await req('/email/policy', { + mode: 'domains', + domains: 'b.example,c.example', + }); + expect(response.status).toBe(503); + expect(await setting(EMAIL_RECIPIENT_POLICY_SETTING)).toEqual({ + mode: 'domains', + domains: ['b.example', 'c.example'], + }); + expect(await env.ROUTING.get(EMAIL_RECIPIENT_POLICY_MIRROR_KEY)).toBe( + JSON.stringify({ mode: 'domains', domains: ['a.example', 'b.example'] }), + ); + put.mockRestore(); + await req('/email/policy'); + expect(await env.ROUTING.get(EMAIL_RECIPIENT_POLICY_MIRROR_KEY)).toBe( + JSON.stringify({ mode: 'domains', domains: ['b.example', 'c.example'] }), + ); + }); + + test('a grown list is a widening', async () => { + await req('/email/policy', { mode: 'domains', domains: 'a.example' }); + const put = vi.spyOn(env.ROUTING, 'put').mockRejectedValueOnce(new Error('kv down')); + const response = await req('/email/policy', { + mode: 'domains', + domains: 'a.example,b.example', + }); + expect(response.status).toBe(503); + expect(await setting(EMAIL_RECIPIENT_POLICY_SETTING)).toEqual({ + mode: 'domains', + domains: ['a.example', 'b.example'], + }); + expect(await env.ROUTING.get(EMAIL_RECIPIENT_POLICY_MIRROR_KEY)).toBe( + JSON.stringify({ mode: 'domains', domains: ['a.example'] }), + ); + put.mockRestore(); + await req('/email/policy'); + expect(await env.ROUTING.get(EMAIL_RECIPIENT_POLICY_MIRROR_KEY)).toBe( + JSON.stringify({ mode: 'domains', domains: ['a.example', 'b.example'] }), + ); + }); + + test('is unavailable when the platform does not enable email', async () => { + const response = await req('/email/policy', undefined, { + PLATFORM_CAPABILITIES: JSON.stringify({ + ai: true, + customDomains: true, + email: false, + githubActions: true, + githubOAuth: true, + googleOAuth: false, + managedBuilds: false, + sandbox: false, + }), + }); + expect(response.status).toBe(501); + }); +}); + +describe('operator email settings', () => { + test('defaults to self-serve domain management', async () => { + const response = await req('/email/settings'); + expect(await response.json()).toEqual({ domains: { management: 'self-serve' } }); + }); + + test('previews and applies the admin mode', async () => { + const preview = await req('/email/settings', { domains: 'admin', dryRun: true }); + expect(await preview.json()).toMatchObject({ + dryRun: true, + action: 'email.settings.set', + target: { domains: 'admin' }, + previous: { domains: 'self-serve' }, + }); + expect(await setting(EMAIL_DOMAIN_MANAGEMENT_SETTING)).toBeNull(); + + const response = await req('/email/settings', { domains: 'admin' }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ domains: { management: 'admin' } }); + expect(await setting(EMAIL_DOMAIN_MANAGEMENT_SETTING)).toBe('admin'); + expect(await (await req('/email/settings')).json()).toEqual({ + domains: { management: 'admin' }, + }); + const recorded = await events(); + expect(recorded).toHaveLength(1); + expect(recorded[0]).toMatchObject({ action: 'email/settings', status: 'success' }); + expect(JSON.parse(recorded[0]!.target)).toEqual({ domains: 'admin' }); + }); + + test('rejects an unknown management mode', async () => { + const response = await req('/email/settings', { domains: 'nobody' }); + expect(response.status).toBe(400); + expect(await setting(EMAIL_DOMAIN_MANAGEMENT_SETTING)).toBeNull(); + }); +}); + +describe('operator email limits', () => { + test('sets both caps by project slug and reports the previous overrides', async () => { + const owner = await createTestUser(env.db, 'limit-owner'); + const project = await createTestProject(env.db, owner.id, 'limit-project'); + await env.ROUTING.put(`email-limit:${project.id}`, '500'); + + const preview = await req(`/email/projects/${project.slug}/limit`, { + monthly: '1000', + burst: '25', + dryRun: true, + }); + expect(await preview.json()).toMatchObject({ + dryRun: true, + action: 'email.limit.set', + target: { projectId: project.id, slug: project.slug, monthly: 1000, burst: 25 }, + previous: { monthly: 500, burst: null }, + }); + expect(await env.ROUTING.get(`email-limit:${project.id}`)).toBe('500'); + expect(await env.ROUTING.get(`email-burst-limit:${project.id}`)).toBeNull(); + + const response = await req(`/email/projects/${project.slug}/limit`, { + monthly: '1000', + burst: '25', + }); + expect(response.status).toBe(200); + expect(await env.ROUTING.get(`email-limit:${project.id}`)).toBe('1000'); + expect(await env.ROUTING.get(`email-burst-limit:${project.id}`)).toBe('25'); + const recorded = await events(); + expect(recorded).toHaveLength(1); + expect(JSON.parse(recorded[0]!.target)).toEqual({ + project: project.slug, + monthly: '1000', + burst: '25', + }); + }); + + test('reports a partial cap write with 207 and a failed audit event', async () => { + const owner = await createTestUser(env.db, 'limit-partial-owner'); + const project = await createTestProject(env.db, owner.id, 'limit-partial'); + const burstKey = `email-burst-limit:${project.id}`; + await env.ROUTING.put(burstKey, '3'); + const originalPut = env.ROUTING.put.bind(env.ROUTING); + vi.spyOn(env.ROUTING, 'put').mockImplementation(async (key, value, options) => { + if (key === burstKey) { + throw new Error('burst storage down'); + } + return originalPut(key, value, options); + }); + vi.spyOn(console, 'error').mockImplementation(() => {}); + + const response = await req(`/email/projects/${project.id}/limit`, { + monthly: '1000', + burst: '25', + }); + expect(response.status).toBe(207); + expect(await response.json()).toMatchObject({ + ok: false, + partial: true, + applied: ['monthly'], + failed: ['burst'], + }); + expect(await env.ROUTING.get(`email-limit:${project.id}`)).toBe('1000'); + expect(await env.ROUTING.get(burstKey)).toBe('3'); + expect((await events()).at(-1)).toMatchObject({ + action: `email/projects/${project.id}/limit`, + status: 'failed', + }); + }); + + test('returns kv_failed when routing storage refuses every cap', async () => { + const owner = await createTestUser(env.db, 'limit-failed-owner'); + const project = await createTestProject(env.db, owner.id, 'limit-failed'); + vi.spyOn(env.ROUTING, 'put').mockRejectedValue(new Error('storage down')); + vi.spyOn(console, 'error').mockImplementation(() => {}); + + const response = await req(`/email/projects/${project.id}/limit`, { + monthly: '1000', + burst: '25', + }); + expect(response.status).toBe(502); + expect(await response.json()).toMatchObject({ + error: 'kv_failed', + details: { failed: ['monthly', 'burst'] }, + }); + expect(await env.ROUTING.get(`email-limit:${project.id}`)).toBeNull(); + expect(await env.ROUTING.get(`email-burst-limit:${project.id}`)).toBeNull(); + }); + + test('sets one cap by project id and leaves the other override untouched', async () => { + const owner = await createTestUser(env.db, 'limit-owner-2'); + const project = await createTestProject(env.db, owner.id, 'limit-project-2'); + await env.ROUTING.put(`email-burst-limit:${project.id}`, '3'); + const response = await req(`/email/projects/${project.id}/limit`, { monthly: '42' }); + expect(response.status).toBe(200); + expect(await env.ROUTING.get(`email-limit:${project.id}`)).toBe('42'); + expect(await env.ROUTING.get(`email-burst-limit:${project.id}`)).toBe('3'); + }); + + test.each([ + ['neither cap', {}], + ['zero', { monthly: '0' }], + ['a non-integer', { burst: '1.5' }], + ['a negative', { monthly: '-5' }], + ['a value over the cap', { monthly: '1000001' }], + ])('rejects %s with 400', async (_label, body) => { + const owner = await createTestUser(env.db, 'limit-owner-3'); + const project = await createTestProject(env.db, owner.id, 'limit-project-3'); + const response = await req(`/email/projects/${project.id}/limit`, body); + expect(response.status).toBe(400); + expect(await env.ROUTING.get(`email-limit:${project.id}`)).toBeNull(); + expect(await env.ROUTING.get(`email-burst-limit:${project.id}`)).toBeNull(); + }); + + test('answers 404 for an unknown project', async () => { + const response = await req('/email/projects/no-such-project/limit', { monthly: '10' }); + expect(response.status).toBe(404); + }); +}); diff --git a/platform/packages/api/test/unit/email-domains.test.ts b/platform/packages/api/test/unit/email-domains.test.ts index b98082164..95303fe5d 100644 --- a/platform/packages/api/test/unit/email-domains.test.ts +++ b/platform/packages/api/test/unit/email-domains.test.ts @@ -39,6 +39,7 @@ describe('email domain credential encryption', () => { it('round-trips the customer token under its fixed AAD name', async () => { const encrypted = await encryptEmailDomainToken(env, 'proj_1', 'acme.com', 'token-plaintext'); const decrypted = await decryptEmailDomainToken(env, 'proj_1', 'acme.com', { + credential_kind: 'api_token', token_ciphertext: encrypted.ciphertext, token_iv: encrypted.iv, token_key_version: encrypted.keyVersion, @@ -77,6 +78,7 @@ describe('email domain credential encryption', () => { it('binds the ciphertext to the project and the domain', async () => { const encrypted = await encryptEmailDomainToken(env, 'proj_1', 'acme.com', 'token-plaintext'); const row = { + credential_kind: 'api_token' as const, token_ciphertext: encrypted.ciphertext, token_iv: encrypted.iv, token_key_version: encrypted.keyVersion, @@ -88,6 +90,32 @@ describe('email domain credential encryption', () => { 'Failed to decrypt project secret', ); }); + + it("resolves a platform row to the runtime token without touching the row's triple", async () => { + const row = { + credential_kind: 'platform' as const, + token_ciphertext: null, + token_iv: null, + token_key_version: null, + }; + await expect( + decryptEmailDomainToken({ ...env, CF_API_TOKEN: 'runtime-token' }, 'proj_1', 'acme.com', row), + ).resolves.toBe('runtime-token'); + await expect(decryptEmailDomainToken(env, 'proj_1', 'acme.com', row)).rejects.toThrow( + 'CF_API_TOKEN is not configured', + ); + }); + + it('refuses a stored-credential row whose triple is missing', async () => { + await expect( + decryptEmailDomainToken(env, 'proj_1', 'acme.com', { + credential_kind: 'api_token', + token_ciphertext: null, + token_iv: null, + token_key_version: null, + }), + ).rejects.toThrow('missing its stored credential'); + }); }); describe('generateEmailRelaySecret', () => { diff --git a/platform/packages/api/test/unit/email-relay-engine.test.ts b/platform/packages/api/test/unit/email-relay-engine.test.ts index ce1da8f71..867340f4d 100644 --- a/platform/packages/api/test/unit/email-relay-engine.test.ts +++ b/platform/packages/api/test/unit/email-relay-engine.test.ts @@ -180,6 +180,61 @@ describe('setupEmailDomain', () => { expect(calls[8].args).toEqual(['zone_1', RELAY_WORKER_BASE_NAME]); }); + it("runs a platform row on the runtime token with no user-token verify, pinned to the platform's account", async () => { + const { client, calls } = makeFakeClient({ zones: { 'acme.com': ZONE } }); + const seenCredentials: Array = []; + + const result = await setupEmailDomain( + { + ...setupInput(), + row: { domain: 'acme.com', credential_kind: 'platform' }, + credential: 'runtime-token-dummy', + requireAccountId: 'acct_1', + }, + depsFor(client, { seenCredentials }), + ); + + expect(result.status).toBe('active'); + expect(result.token_id).toBeNull(); + expect(result.token_expires_on).toBeNull(); + expect(seenCredentials).toEqual(['runtime-token-dummy']); + expect(methods(calls)[0]).toBe('findZone'); + expect(methods(calls)).not.toContain('verifyToken'); + }); + + it('fails at zone_lookup, before any write, when the zone is outside the required account', async () => { + const { client, calls } = makeFakeClient({ + zones: { 'acme.com': { ...ZONE, accountId: 'acct_customer' } }, + }); + + const result = await setupEmailDomain( + { + ...setupInput(), + row: { domain: 'acme.com', credential_kind: 'platform' }, + requireAccountId: 'acct_platform', + }, + depsFor(client), + ); + + expect(result.status).toBe('failed'); + expect(result.setup_state).toEqual({ failed_step: 'zone_lookup' }); + expect(result.error).toContain('acct_customer'); + expect(result.error).toContain('acct_platform'); + expect(result.error).toContain('--token-stdin'); + expect(result.zone_id).toBeNull(); + expect(result.account_id).toBeNull(); + expect(methods(calls)).toEqual(['findZone']); + }); + + it('leaves an unpinned row free to register a zone in any account', async () => { + const { client } = makeFakeClient({ + zones: { 'acme.com': { ...ZONE, accountId: 'acct_customer' } }, + }); + const result = await setupEmailDomain(setupInput(), depsFor(client)); + expect(result.status).toBe('active'); + expect(result.account_id).toBe('acct_customer'); + }); + it('walks the labels up to the registrable zone and takes the subdomain path', async () => { const { client, calls } = makeFakeClient({ zones: { 'acme.com': ZONE } }); diff --git a/platform/packages/api/test/unit/email-settings.test.ts b/platform/packages/api/test/unit/email-settings.test.ts new file mode 100644 index 000000000..d3bf6f1d3 --- /dev/null +++ b/platform/packages/api/test/unit/email-settings.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { + EmailSettingsError, + MAX_POLICY_DOMAINS, + normalizeEmailRecipientPolicy, + serializeEmailRecipientPolicyMirror, + widensEmailRecipientPolicy, +} from '../../src/email/settings.ts'; + +describe('normalizeEmailRecipientPolicy', () => { + it('accepts a comma/whitespace-separated string and normalizes it', () => { + expect( + normalizeEmailRecipientPolicy({ + mode: 'domains', + domains: ' Corp.Example,\nexample.net corp.example. ', + }), + ).toEqual({ mode: 'domains', domains: ['corp.example', 'example.net'] }); + }); + + it('accepts an array and dedupes case-insensitively', () => { + expect( + normalizeEmailRecipientPolicy({ + mode: 'domains', + domains: ['A.Example', 'a.example', 'b.example'], + }), + ).toEqual({ mode: 'domains', domains: ['a.example', 'b.example'] }); + }); + + it('drops the list for `verified` and `any`', () => { + expect(normalizeEmailRecipientPolicy({ mode: 'any', domains: 'x.example' })).toEqual({ + mode: 'any', + domains: [], + }); + expect(normalizeEmailRecipientPolicy({ mode: 'verified' })).toEqual({ + mode: 'verified', + domains: [], + }); + }); + + it.each([ + ['a non-object', 'domains'], + ['a missing mode', {}], + ['an unknown mode', { mode: 'open' }], + ['`domains` without a list', { mode: 'domains' }], + ['`domains` with only blanks', { mode: 'domains', domains: ' , ' }], + ['a non-string entry', { mode: 'domains', domains: [42] }], + ['a single-label domain', { mode: 'domains', domains: 'localhost' }], + ['a domain with spaces', { mode: 'domains', domains: ['not a domain'] }], + ['an over-long domain', { mode: 'domains', domains: `${'a'.repeat(254)}.example` }], + [ + 'more than the cap', + { + mode: 'domains', + domains: Array.from({ length: MAX_POLICY_DOMAINS + 1 }, (_, i) => `d${i}.example`), + }, + ], + ])('rejects %s', (_label, input) => { + expect(() => normalizeEmailRecipientPolicy(input)).toThrow(EmailSettingsError); + }); +}); + +describe('widensEmailRecipientPolicy', () => { + it.each([ + [ + 'verified → domains', + { mode: 'verified', domains: [] }, + { mode: 'domains', domains: ['a.example'] }, + true, + ], + [ + 'domains → any', + { mode: 'domains', domains: ['a.example'] }, + { mode: 'any', domains: [] }, + true, + ], + [ + 'any → domains', + { mode: 'any', domains: [] }, + { mode: 'domains', domains: ['a.example'] }, + false, + ], + [ + 'domains → verified', + { mode: 'domains', domains: ['a.example'] }, + { mode: 'verified', domains: [] }, + false, + ], + [ + 'grown domains list', + { mode: 'domains', domains: ['a.example'] }, + { mode: 'domains', domains: ['a.example', 'b.example'] }, + true, + ], + [ + 'shrunk domains list', + { mode: 'domains', domains: ['a.example', 'b.example'] }, + { mode: 'domains', domains: ['a.example'] }, + false, + ], + [ + 'edited domains list', + { mode: 'domains', domains: ['a.example', 'b.example'] }, + { mode: 'domains', domains: ['b.example', 'c.example'] }, + true, + ], + [ + 'pure shrink retaining a different entry', + { mode: 'domains', domains: ['a.example', 'b.example'] }, + { mode: 'domains', domains: ['b.example'] }, + false, + ], + [ + 'replaced domains list', + { mode: 'domains', domains: ['a.example'] }, + { mode: 'domains', domains: ['b.example'] }, + true, + ], + [ + 'unchanged domains list', + { mode: 'domains', domains: ['a.example'] }, + { mode: 'domains', domains: ['a.example'] }, + false, + ], + ['any → any', { mode: 'any', domains: [] }, { mode: 'any', domains: [] }, false], + ] as const)('%s: %s', (_label, prev, next, expected) => { + expect(widensEmailRecipientPolicy(prev, next)).toBe(expected); + }); +}); + +describe('serializeEmailRecipientPolicyMirror', () => { + it('is the exact wire shape the proxy parses', () => { + expect(serializeEmailRecipientPolicyMirror({ mode: 'domains', domains: ['a.example'] })).toBe( + '{"mode":"domains","domains":["a.example"]}', + ); + }); +}); diff --git a/platform/packages/proxy/src/email-policy.ts b/platform/packages/proxy/src/email-policy.ts new file mode 100644 index 000000000..4016cb29a --- /dev/null +++ b/platform/packages/proxy/src/email-policy.ts @@ -0,0 +1,140 @@ +/** + * The shared-sender recipient gate (design doc 0078, "Recipient policy"). + * + * Two KV reads decide whether a recipient may be mailed from a project's + * `+tag@` address: the project's verified + * destinations (`email-allowlist:`) and one installation-wide + * policy (`email-recipient-policy`), both mirrored by the api. The policy + * can only widen the allowlist; an absent or unreadable policy mirror is the + * default `verified` mode, so a failed publish never loosens the gate. + * + * The custom-domain lane does not use this gate at all (the customer's own + * Email Sending onboarding lifts Cloudflare's destination check there). + */ + +export type RecipientPolicyMode = 'verified' | 'domains' | 'any'; + +export type RecipientPolicy = Readonly<{ + mode: RecipientPolicyMode; + /** Lowercased recipient domains; only populated for `domains`. */ + domains: ReadonlySet; +}>; + +export const RECIPIENT_POLICY_KEY = 'email-recipient-policy'; + +/** Mirrors the api's `MAX_POLICY_DOMAINS`; a longer list is not the api's. */ +const MAX_POLICY_DOMAINS = 100; + +export const VERIFIED_ONLY: RecipientPolicy = Object.freeze({ + mode: 'verified', + domains: new Set(), +}); + +/** + * Reads `email-recipient-policy`. `cacheTtl` bounds per-colo convergence at + * ~60s, like the allowlist. Anything that is not `{ mode, domains[] }` with + * a known mode reads as `verified`. An unreadable mirror — a rejected read + * included — is `verified`. + */ +export async function readRecipientPolicy(kv: KVNamespace): Promise { + let raw: string | null; + try { + raw = await kv.get(RECIPIENT_POLICY_KEY, { cacheTtl: 60 }); + } catch (err) { + console.error('[email-policy] recipient policy read failed; enforcing verified:', err); + return VERIFIED_ONLY; + } + if (!raw) { + return VERIFIED_ONLY; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return VERIFIED_ONLY; + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return VERIFIED_ONLY; + } + const { mode, domains } = parsed as { mode?: unknown; domains?: unknown }; + // The api writes exactly `{ mode: 'any', domains: [] }` or + // `{ mode: 'domains', domains: [1..100 non-empty lowercase strings] }`. + // Anything else is not the api's mirror and must not widen the gate. + if (!Array.isArray(domains) || domains.length > MAX_POLICY_DOMAINS) { + return VERIFIED_ONLY; + } + if (mode === 'any') { + return domains.length === 0 ? { mode: 'any', domains: new Set() } : VERIFIED_ONLY; + } + if (mode === 'domains') { + if (domains.length === 0 || !domains.every((d) => typeof d === 'string' && d !== '')) { + return VERIFIED_ONLY; + } + return { mode: 'domains', domains: new Set(domains.map((d) => d.toLowerCase())) }; + } + return VERIFIED_ONLY; +} + +/** + * Reads `email-allowlist:` — a JSON array of lowercased verified + * addresses the api writes on every destination CRUD, on the platform + * confirmation click, and as the repair for a stale key. A missing key reads + * as empty, so a brand-new project has every recipient refused. + */ +export async function readAllowlist(kv: KVNamespace, projectId: string): Promise> { + const raw = await kv.get(`email-allowlist:${projectId}`, { cacheTtl: 60 }); + if (!raw) { + return new Set(); + } + try { + const arr = JSON.parse(raw) as unknown; + if (!Array.isArray(arr)) { + return new Set(); + } + const out = new Set(); + for (const v of arr) { + if (typeof v === 'string') { + out.add(v.toLowerCase()); + } + } + return out; + } catch { + return new Set(); + } +} + +/** + * `recipient` is already lowercased by the caller. `platformDomains` are the + * installation's own mail domains — `EMAIL_SENDER_DOMAIN` and, on inbound, + * the domain Cloudflare accepted the message on. The policy never widens + * delivery to addresses on them, so one tenant cannot use `any` or a listed + * platform domain to mail another tenant's `+tag@` inbox — or form a + * reply loop with it — without that recipient's own consent. + */ +export function recipientAllowed( + policy: RecipientPolicy, + allowlist: ReadonlySet, + recipient: string, + platformDomains: ReadonlyArray = [], +): boolean { + if (allowlist.has(recipient)) { + return true; + } + const at = recipient.lastIndexOf('@'); + const domain = at > 0 ? recipient.slice(at + 1) : ''; + if (domain !== '' && platformDomains.some((d) => d.toLowerCase() === domain)) { + return false; + } + if (policy.mode === 'any') { + return true; + } + return policy.mode === 'domains' && domain !== '' && policy.domains.has(domain); +} + +/** The per-recipient `UNVERIFIED_DESTINATION` message for a refused recipient. */ +export function recipientRefusalMessage(policy: RecipientPolicy, recipient: string): string { + const base = `recipient '${recipient}' is not a verified destination for this project`; + return policy.mode === 'domains' + ? `${base} and its domain is not in the platform's recipient policy` + : base; +} diff --git a/platform/packages/proxy/src/email-router.ts b/platform/packages/proxy/src/email-router.ts index 7e4034023..191c4cad6 100644 --- a/platform/packages/proxy/src/email-router.ts +++ b/platform/packages/proxy/src/email-router.ts @@ -26,6 +26,13 @@ import { isReservedEmailSlug } from '@voidcloud/api/reserved-slugs'; import { EmailMessage } from 'cloudflare:email'; import { validate as isValidEmailSyntax } from 'email-validator'; import { dropEmailDomainLease, leaseBearer, leaseEmailDomainCredential } from './email-lease.ts'; +import { + VERIFIED_ONLY, + readAllowlist, + readRecipientPolicy, + recipientAllowed, + type RecipientPolicy, +} from './email-policy.ts'; import { MAX_MIME_BYTES, readEmailDomainMirror, @@ -587,28 +594,6 @@ function monthTtlSeconds(now: Date): number { return Math.max(60, seconds); } -async function readAllowlist(kv: KVNamespace, projectId: string): Promise> { - const raw = await kv.get(`email-allowlist:${projectId}`, { cacheTtl: 60 }); - if (!raw) { - return new Set(); - } - try { - const arr = JSON.parse(raw) as unknown; - if (!Array.isArray(arr)) { - return new Set(); - } - const out = new Set(); - for (const v of arr) { - if (typeof v === 'string') { - out.add(v.toLowerCase()); - } - } - return out; - } catch { - return new Set(); - } -} - async function getBurstLimit(kv: KVNamespace, projectId: string): Promise { const raw = await kv.get(`email-burst-limit:${projectId}`, { cacheTtl: 60 }); if (raw) { @@ -646,6 +631,10 @@ interface OutboundGate { /** Relay lane (custom-domain sends): the recipient allowlist does not gate; quotas still apply. */ skipAllowlist: boolean; allowlist: Set; + /** The installation's recipient policy (email-policy.ts); `VERIFIED_ONLY` when the allowlist is skipped. */ + policy: RecipientPolicy; + /** The platform mail domains — the policy never widens delivery to them. */ + platformDomains: ReadonlyArray; burstKey: string; burstLimit: number; burstCurrent: number; @@ -849,7 +838,7 @@ export async function routeInbound( // per-project allowlist is the security boundary. let gate: OutboundGate; try { - gate = await loadOutboundGate(env, routing.projectId); + gate = await loadOutboundGate(env, routing.projectId, { receivedDomain }); } catch (e) { console.error( `[email-router] failed to load outbound gate for ${sanitize(routing.projectId ?? '', 64)}:`, @@ -863,6 +852,8 @@ export async function routeInbound( projectId: routing.projectId, skipAllowlist: false, allowlist: new Set(), + policy: VERIFIED_ONLY, + platformDomains: [], burstKey: '', burstLimit: 0, burstCurrent: 0, @@ -1068,6 +1059,8 @@ export async function routeRelayed( projectId, skipAllowlist: true, allowlist: new Set(), + policy: VERIFIED_ONLY, + platformDomains: [], burstKey: '', burstLimit: 0, burstCurrent: 0, @@ -1291,7 +1284,7 @@ function envelopeRawStream(bytes: Uint8Array): ReadableStream { async function loadOutboundGate( env: Env, projectId: string | undefined, - opts?: { skipAllowlist?: boolean }, + opts?: { skipAllowlist?: boolean; receivedDomain?: string }, ): Promise { const skipAllowlist = opts?.skipAllowlist ?? false; if (!projectId) { @@ -1300,6 +1293,8 @@ async function loadOutboundGate( projectId: undefined, skipAllowlist, allowlist: new Set(), + policy: VERIFIED_ONLY, + platformDomains: [], burstKey: '', burstLimit: 0, burstCurrent: 0, @@ -1309,11 +1304,12 @@ async function loadOutboundGate( quotaExhausted: true, }; } - const allowlist = skipAllowlist ? new Set() : await readAllowlist(env.ROUTING, projectId); const now = new Date(); const burstKey = `email-burst:${projectId}:${minuteBucket(now)}`; const monthKey = `email-sent:${projectId}:${monthBucket(now)}`; - const [burstLimit, monthLimit, burstRaw, monthRaw] = await Promise.all([ + const [allowlist, policy, burstLimit, monthLimit, burstRaw, monthRaw] = await Promise.all([ + skipAllowlist ? new Set() : readAllowlist(env.ROUTING, projectId), + skipAllowlist ? VERIFIED_ONLY : readRecipientPolicy(env.ROUTING), getBurstLimit(env.ROUTING, projectId), getMonthlyLimit(env.ROUTING, projectId), env.ROUTING.get(burstKey), @@ -1323,6 +1319,14 @@ async function loadOutboundGate( projectId, skipAllowlist, allowlist, + policy, + platformDomains: [ + ...new Set( + [readSenderDomain(env), opts?.receivedDomain].flatMap((domain) => + domain ? [domain.toLowerCase()] : [], + ), + ), + ], burstKey, burstLimit, burstCurrent: readCounter(burstRaw), @@ -1782,9 +1786,12 @@ function checkOutboundGate(gate: OutboundGate, target: string, kind: 'forward' | return false; } const lowered = target.toLowerCase(); - if (!gate.skipAllowlist && !gate.allowlist.has(lowered)) { + if ( + !gate.skipAllowlist && + !recipientAllowed(gate.policy, gate.allowlist, lowered, gate.platformDomains) + ) { console.error( - `[email-router] ${kind} dropped: recipient not in project allowlist (target=${sanitize(target, 320)})`, + `[email-router] ${kind} dropped: recipient not in project allowlist or recipient policy (target=${sanitize(target, 320)})`, ); return false; } diff --git a/platform/packages/proxy/src/email.ts b/platform/packages/proxy/src/email.ts index 5ee43cdbe..b90b41286 100644 --- a/platform/packages/proxy/src/email.ts +++ b/platform/packages/proxy/src/email.ts @@ -4,6 +4,12 @@ import { Hono } from 'hono'; import type { Context } from 'hono'; import { asString } from './body.ts'; import { dropEmailDomainLease, leaseBearer, leaseEmailDomainCredential } from './email-lease.ts'; +import { + readAllowlist, + readRecipientPolicy, + recipientAllowed, + recipientRefusalMessage, +} from './email-policy.ts'; import type { Env, ProxyEnv } from './types.ts'; const app = new Hono(); @@ -926,18 +932,21 @@ app.post('/send', async (context) => { ); } - // Per-project verified-destinations allowlist. KV mirror of the - // email_destinations table maintained by the API on every CRUD; cacheTtl - // bounds convergence at ~60s per colo. A cache miss (allowlist key - // missing entirely) reads as empty, so a brand-new project with no - // verified destinations gets every recipient rejected — matches the - // design intent. - const allowlist = await readAllowlist(context.env.ROUTING, projectId); + // Per-project verified-destinations allowlist, widened by the + // installation's recipient policy (email-policy.ts). Both are KV mirrors + // the API maintains; cacheTtl bounds convergence at ~60s per colo. A + // missing allowlist reads as empty and a missing policy as `verified`, so + // a brand-new project with no verified destinations gets every recipient + // rejected — matches the design intent. + const [allowlist, policy] = await Promise.all([ + readAllowlist(context.env.ROUTING, projectId), + readRecipientPolicy(context.env.ROUTING), + ]); const allowed: Array = []; const deliveries: Array = []; for (const r of recipients) { - if (allowlist.has(r)) { + if (recipientAllowed(policy, allowlist, r, [senderDomain])) { allowed.push(r); } else { deliveries.push({ @@ -945,7 +954,7 @@ app.post('/send', async (context) => { ok: false, error: { code: 'UNVERIFIED_DESTINATION', - message: `recipient '${r}' is not a verified destination for this project`, + message: recipientRefusalMessage(policy, r), }, }); } @@ -1565,35 +1574,6 @@ function logSend(projectId: string, requestId: string, deliveries: Array` from ROUTING KV. The API writes a JSON - * array of lowercased verified addresses on every destination CRUD, on the - * platform confirmation click, and — as the repair for a write it swallowed — - * on a destinations listing that finds the key stale. Never on deploy. - * cacheTtl bounds per-colo convergence at ~60s. - */ -async function readAllowlist(kv: KVNamespace, projectId: string): Promise> { - const raw = await kv.get(`email-allowlist:${projectId}`, { cacheTtl: 60 }); - if (!raw) { - return new Set(); - } - try { - const arr = JSON.parse(raw) as unknown; - if (!Array.isArray(arr)) { - return new Set(); - } - const out = new Set(); - for (const v of arr) { - if (typeof v === 'string') { - out.add(v.toLowerCase()); - } - } - return out; - } catch { - return new Set(); - } -} - /** * The api's `email_domains` row projected into ROUTING KV at * `email-domain:` (design doc 0078, "Custom domains on diff --git a/platform/packages/proxy/test/unit/email-policy.test.ts b/platform/packages/proxy/test/unit/email-policy.test.ts new file mode 100644 index 000000000..abce87f02 --- /dev/null +++ b/platform/packages/proxy/test/unit/email-policy.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it, vi } from 'vite-plus/test'; +import { + RECIPIENT_POLICY_KEY, + VERIFIED_ONLY, + readAllowlist, + readRecipientPolicy, + recipientAllowed, + recipientRefusalMessage, + type RecipientPolicy, +} from '../../src/email-policy.ts'; + +function kvWith(entries: Record): KVNamespace { + const store = new Map(Object.entries(entries)); + return { + get: async (key: string) => store.get(key) ?? null, + } as unknown as KVNamespace; +} + +const allowlist = new Set(['dev@example.com']); +const domainsPolicy: RecipientPolicy = { mode: 'domains', domains: new Set(['acme.test']) }; +const anyPolicy: RecipientPolicy = { mode: 'any', domains: new Set() }; + +describe('recipientAllowed', () => { + it.each([ + // mode, allowlisted, same-domain, other → expected + ['verified', VERIFIED_ONLY, [true, false, false]], + ['domains', domainsPolicy, [true, true, false]], + ['any', anyPolicy, [true, true, true]], + ] as const)('%s mode decision table', (_mode, policy, expected) => { + expect([ + recipientAllowed(policy, allowlist, 'dev@example.com'), + recipientAllowed(policy, allowlist, 'someone@acme.test'), + recipientAllowed(policy, allowlist, 'someone@other.test'), + ]).toEqual([...expected]); + }); + + it("never widens delivery to the platform's own mail domain — allowlist only there", () => { + const platform = 'mail.example.test'; + for (const policy of [anyPolicy, { mode: 'domains', domains: new Set([platform]) } as const]) { + expect(recipientAllowed(policy, new Set(), `other+tag@${platform}`, [platform])).toBe(false); + // Platform domains are compared case-insensitively on their side. + expect( + recipientAllowed(policy, new Set(), `other+tag@${platform}`, ['MAIL.Example.TEST']), + ).toBe(false); + expect( + recipientAllowed(policy, new Set([`other+tag@${platform}`]), `other+tag@${platform}`, [ + platform, + ]), + ).toBe(true); + } + // Other domains are unaffected by the platform-domain rule. + expect(recipientAllowed(anyPolicy, new Set(), 'x@elsewhere.test', [platform])).toBe(true); + expect(recipientAllowed(anyPolicy, new Set(), `x@${platform}`)).toBe(true); + }); + + it('matches the recipient domain after the LAST @ only', () => { + expect(recipientAllowed(domainsPolicy, new Set(), 'a@b@acme.test')).toBe(true); + expect(recipientAllowed(domainsPolicy, new Set(), 'acme.test')).toBe(false); + expect(recipientAllowed(domainsPolicy, new Set(), '@acme.test')).toBe(false); + expect(recipientAllowed(domainsPolicy, new Set(), 'x@sub.acme.test')).toBe(false); + }); +}); + +describe('recipientRefusalMessage', () => { + it('names the policy only under `domains`', () => { + expect(recipientRefusalMessage(VERIFIED_ONLY, 'x@y.test')).toBe( + "recipient 'x@y.test' is not a verified destination for this project", + ); + expect(recipientRefusalMessage(domainsPolicy, 'x@y.test')).toBe( + "recipient 'x@y.test' is not a verified destination for this project and its domain is not in the platform's recipient policy", + ); + }); +}); + +describe('readRecipientPolicy', () => { + it('reads a `domains` policy and lowercases its entries', async () => { + const policy = await readRecipientPolicy( + kvWith({ + [RECIPIENT_POLICY_KEY]: JSON.stringify({ mode: 'domains', domains: ['Acme.TEST'] }), + }), + ); + expect(policy.mode).toBe('domains'); + expect([...policy.domains]).toEqual(['acme.test']); + }); + + it('reads `any`', async () => { + const policy = await readRecipientPolicy( + kvWith({ [RECIPIENT_POLICY_KEY]: JSON.stringify({ mode: 'any', domains: [] }) }), + ); + expect(policy.mode).toBe('any'); + }); + + it('falls back to verified-only when the KV read rejects', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const kv = { + get: async () => { + throw new Error('kv unavailable'); + }, + } as unknown as KVNamespace; + await expect(readRecipientPolicy(kv)).resolves.toBe(VERIFIED_ONLY); + expect(error).toHaveBeenCalledWith( + '[email-policy] recipient policy read failed; enforcing verified:', + expect.any(Error), + ); + error.mockRestore(); + }); + + it.each([ + ['absent', undefined], + ['malformed JSON', '{nope'], + ['a JSON array', JSON.stringify(['acme.test'])], + ['null', 'null'], + ['an unknown mode', JSON.stringify({ mode: 'open', domains: [] })], + ['`any` without a list', JSON.stringify({ mode: 'any' })], + ['`any` with a non-array list', JSON.stringify({ mode: 'any', domains: 'corrupt' })], + ['`any` with a non-empty list', JSON.stringify({ mode: 'any', domains: ['acme.test'] })], + ['`domains` without a list', JSON.stringify({ mode: 'domains' })], + ['`domains` with an empty list', JSON.stringify({ mode: 'domains', domains: [] })], + ['`domains` with only non-strings', JSON.stringify({ mode: 'domains', domains: [1, null] })], + [ + '`domains` with a non-string beside a valid entry', + JSON.stringify({ mode: 'domains', domains: ['acme.test', 42] }), + ], + [ + '`domains` over the api cap', + JSON.stringify({ + mode: 'domains', + domains: Array.from({ length: 101 }, (_, i) => `d${i}.test`), + }), + ], + ])('falls back to verified-only when the mirror is %s', async (_label, raw) => { + const policy = await readRecipientPolicy( + kvWith(raw === undefined ? {} : { [RECIPIENT_POLICY_KEY]: raw }), + ); + expect(policy).toBe(VERIFIED_ONLY); + }); +}); + +describe('readAllowlist', () => { + it('lowercases string entries and skips the rest', async () => { + const set = await readAllowlist( + kvWith({ 'email-allowlist:p1': JSON.stringify(['Dev@Example.com', 42, null]) }), + 'p1', + ); + expect([...set]).toEqual(['dev@example.com']); + }); + + it.each([ + ['absent', undefined], + ['not JSON', 'nope'], + ['not an array', JSON.stringify({ dev: true })], + ])('reads as empty when the key is %s', async (_label, raw) => { + const set = await readAllowlist( + kvWith(raw === undefined ? {} : { 'email-allowlist:p1': raw }), + 'p1', + ); + expect(set.size).toBe(0); + }); +}); diff --git a/platform/packages/proxy/test/unit/email-router.test.ts b/platform/packages/proxy/test/unit/email-router.test.ts index d880f8b8e..7509342ed 100644 --- a/platform/packages/proxy/test/unit/email-router.test.ts +++ b/platform/packages/proxy/test/unit/email-router.test.ts @@ -1778,6 +1778,132 @@ describe('routeInbound — per-project gating on forward/reply (HIGH H1)', () => expect(forwardCalls).toHaveLength(1); }); + it('widens the gate to a same-domain forward under the `domains` recipient policy', async () => { + const { env } = makeEnv({ + kvSeed: withAllowlist(['admin@example.com'], { + 'email-recipient-policy': JSON.stringify({ mode: 'domains', domains: ['corp.test'] }), + }), + emailReturn: [ + { type: 'forward', to: 'anyone@corp.test' }, + { type: 'forward', to: 'anyone@other.test' }, + ], + }); + const { message, forwardCalls } = createFakeMessage({ to: `myslug@${PROD_DOMAIN}` }); + const { ctx } = createCtx(); + await routeInbound(message, env, ctx); + expect(forwardCalls.map((call) => call.to)).toEqual(['anyone@corp.test']); + }); + + it('still enacts an allowlisted forward when the recipient-policy read rejects', async () => { + const { env } = makeEnv({ + kvSeed: withAllowlist(['admin@example.com']), + kvGetFailures: ['email-recipient-policy'], + emailReturn: [{ type: 'forward', to: 'admin@example.com' }], + }); + const { message, forwardCalls } = createFakeMessage({ to: `myslug@${PROD_DOMAIN}` }); + const { ctx } = createCtx(); + await routeInbound(message, env, ctx); + expect(forwardCalls.map((call) => call.to)).toEqual(['admin@example.com']); + expect(errorSpy).toHaveBeenCalledWith( + '[email-policy] recipient policy read failed; enforcing verified:', + expect.any(Error), + ); + }); + + it('under `any`, still drops a reply that claims another project as sender', async () => { + const { env } = makeEnv({ + kvSeed: withAllowlist([], { + 'email-recipient-policy': JSON.stringify({ mode: 'any', domains: [] }), + }), + emailReturn: [ + { + type: 'reply', + from: `otherproject+support@${PROD_DOMAIN}`, + to: 'anyone@elsewhere.test', + raw: replyRaw(`otherproject+support@${PROD_DOMAIN}`, 'r'), + }, + { + type: 'reply', + from: `myslug+support@${PROD_DOMAIN}`, + to: 'anyone@elsewhere.test', + raw: replyRaw(`myslug+support@${PROD_DOMAIN}`, 'r'), + }, + ], + }); + const { message, replyCalls } = createFakeMessage({ to: `myslug+tag@${PROD_DOMAIN}` }); + const { ctx } = createCtx(); + await routeInbound(message, env, ctx); + // The sender pin does not move with the policy: only the project's own + // sender goes out, to a recipient the policy now permits. + expect(replyCalls).toHaveLength(1); + }); + + it("never widens a forward to another tenant's inbox on the platform's own mail domain", async () => { + const { env } = makeEnv({ + kvSeed: withAllowlist([`consented+tag@${PROD_DOMAIN}`], { + 'email-recipient-policy': JSON.stringify({ mode: 'any', domains: [] }), + }), + emailReturn: [ + { type: 'forward', to: `otherproject+intake@${PROD_DOMAIN}` }, + { type: 'forward', to: `consented+tag@${PROD_DOMAIN}` }, + { type: 'forward', to: 'anyone@elsewhere.test' }, + ], + }); + const { message, forwardCalls } = createFakeMessage({ to: `myslug@${PROD_DOMAIN}` }); + const { ctx } = createCtx(); + await routeInbound(message, env, ctx); + expect(forwardCalls.map((call) => call.to)).toEqual([ + `consented+tag@${PROD_DOMAIN}`, + 'anyone@elsewhere.test', + ]); + }); + + it("uses the received domain when EMAIL_SENDER_DOMAIN is absent, so `any` cannot reach another tenant's inbox", async () => { + const { env } = makeEnv({ + senderDomain: null, + kvSeed: withAllowlist([], { + 'email-recipient-policy': JSON.stringify({ mode: 'any', domains: [] }), + }), + emailReturn: [{ type: 'forward', to: `project-b+x@${PROD_DOMAIN}` }], + }); + const { message, forwardCalls } = createFakeMessage({ to: `myslug@${PROD_DOMAIN}` }); + const { ctx } = createCtx(); + await routeInbound(message, env, ctx); + expect(forwardCalls).toHaveLength(0); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('recipient policy')); + }); + + it('protects both the configured sender domain and a distinct received domain', async () => { + const receivedDomain = 'example.test'; + const configuredDomain = 'mail.example.test'; + const { env } = makeEnv({ + senderDomain: configuredDomain, + kvSeed: withAllowlist([], { + 'email-recipient-policy': JSON.stringify({ mode: 'any', domains: [] }), + }), + emailReturn: [ + { type: 'forward', to: `b+x@${receivedDomain}` }, + { type: 'forward', to: `b+x@${configuredDomain}` }, + { type: 'forward', to: 'someone@other.test' }, + ], + }); + const { message, forwardCalls } = createFakeMessage({ to: `myslug@${receivedDomain}` }); + const { ctx } = createCtx(); + await routeInbound(message, env, ctx); + expect(forwardCalls.map((call) => call.to)).toEqual(['someone@other.test']); + }); + + it('keeps the gate closed for the same forward under the default `verified` policy', async () => { + const { env } = makeEnv({ + kvSeed: withAllowlist(['admin@example.com']), + emailReturn: [{ type: 'forward', to: 'anyone@corp.test' }], + }); + const { message, forwardCalls } = createFakeMessage({ to: `myslug@${PROD_DOMAIN}` }); + const { ctx } = createCtx(); + await routeInbound(message, env, ctx); + expect(forwardCalls).toHaveLength(0); + }); + it('fails CLOSED when the routing entry has no projectId — refused before dispatch', async () => { // No projectId → no per-project guard is available, so forward/reply // must be refused rather than falling back to CF's account-wide flag. diff --git a/platform/packages/proxy/test/unit/email.test.ts b/platform/packages/proxy/test/unit/email.test.ts index 1fdc7616d..ae4cd3033 100644 --- a/platform/packages/proxy/test/unit/email.test.ts +++ b/platform/packages/proxy/test/unit/email.test.ts @@ -1605,6 +1605,102 @@ describe('POST /email/send — allowlist fails closed', () => { }); }); +describe('POST /email/send — recipient policy', () => { + const policyKv = (policy: unknown, allowlist: Array = ['dev@example.com']) => + createMockKv({ + 'email-slug:proj_1': 'acme', + 'email-allowlist:proj_1': JSON.stringify(allowlist), + 'email-recipient-policy': typeof policy === 'string' ? policy : JSON.stringify(policy), + }); + + it('still delivers to an allowlisted recipient when the policy read rejects', async () => { + const kv = policyKv({ mode: 'any', domains: [] }); + kv.get.mockImplementation(async (key: string) => { + if (key === 'email-recipient-policy') { + throw new Error('policy KV unavailable'); + } + return kv.store.get(key) ?? null; + }); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const sender = createSender(); + const res = await postSend( + createMockEnv(kv, { SEND_EMAIL: sender.binding }), + await sendBody({ recipients: ['dev@example.com'] }), + ); + expect((await readSend(res)).ok).toBe(true); + expect(sender.recipients()).toEqual(['dev@example.com']); + expect(error).toHaveBeenCalledWith( + '[email-policy] recipient policy read failed; enforcing verified:', + expect.any(Error), + ); + }); + + it('under `domains`, reaches a same-domain recipient the allowlist lacks and refuses others', async () => { + const kv = policyKv({ mode: 'domains', domains: ['acme.test'] }); + const sender = createSender(); + const env = createMockEnv(kv, { SEND_EMAIL: sender.binding }); + const res = await postSend( + env, + await sendBody({ recipients: ['x@acme.test', 'dev@example.com', 'x@other.test'] }), + ); + const body = await readSend(res); + expect(body.ok).toBe(false); + expect(body.deliveries?.find((d) => d.recipient === 'x@acme.test')?.ok).toBe(true); + expect(body.deliveries?.find((d) => d.recipient === 'dev@example.com')?.ok).toBe(true); + const other = body.deliveries?.find((d) => d.recipient === 'x@other.test'); + expect(other?.ok).toBe(false); + expect(other?.error?.code).toBe('UNVERIFIED_DESTINATION'); + expect(other?.error?.message).toContain("platform's recipient policy"); + expect(sender.recipients().sort()).toEqual(['dev@example.com', 'x@acme.test']); + }); + + it('under `any`, sends to every recipient with an empty allowlist', async () => { + const kv = policyKv({ mode: 'any', domains: [] }, []); + const sender = createSender(); + const env = createMockEnv(kv, { SEND_EMAIL: sender.binding }); + const res = await postSend(env, await sendBody({ recipients: ['a@x.test', 'b@y.test'] })); + expect((await readSend(res)).ok).toBe(true); + expect(sender.recipients().sort()).toEqual(['a@x.test', 'b@y.test']); + }); + + it("does not widen `any` to another tenant's address on the platform sender domain", async () => { + const consented = `consented+tag@${PROD_DOMAIN}`; + const refused = `otherproject+tag@${PROD_DOMAIN}`; + const kv = policyKv({ mode: 'any', domains: [] }, [consented]); + const sender = createSender(); + const env = createMockEnv(kv, { SEND_EMAIL: sender.binding }); + const res = await postSend( + env, + await sendBody({ recipients: [refused, consented, 'someone@other.test'] }), + ); + const body = await readSend(res); + expect(body.deliveries?.find((delivery) => delivery.recipient === refused)).toMatchObject({ + ok: false, + error: { code: 'UNVERIFIED_DESTINATION' }, + }); + expect(sender.recipients().sort()).toEqual([consented, 'someone@other.test'].sort()); + }); + + it.each([ + ['malformed JSON', 'not json'], + ['an unknown mode', { mode: 'open', domains: [] }], + ['`domains` with an empty list', { mode: 'domains', domains: [] }], + ['a bare array', ['acme.test']], + ])('falls back to verified-only when the mirror holds %s', async (_label, policy) => { + const kv = policyKv(policy, []); + const sender = createSender(); + const env = createMockEnv(kv, { SEND_EMAIL: sender.binding }); + const res = await postSend(env, await sendBody({ recipients: ['x@acme.test'] })); + const body = await readSend(res); + expect(body.ok).toBe(false); + expect(body.deliveries?.[0].error?.code).toBe('UNVERIFIED_DESTINATION'); + expect(body.deliveries?.[0].error?.message).toBe( + "recipient 'x@acme.test' is not a verified destination for this project", + ); + expect(sender.calls).toHaveLength(0); + }); +}); + describe('POST /email/send — quota', () => { it('charges both counters by the ALLOWED recipient count and scopes the burst key', async () => { const kv = happyPathKv({