diff --git a/docs/guide/email/receiving.md b/docs/guide/email/receiving.md index 77c392ea0..eb122ffd1 100644 --- a/docs/guide/email/receiving.md +++ b/docs/guide/email/receiving.md @@ -94,6 +94,8 @@ deployed `email/` handlers without per-project DNS setup once the platform has v handlers once its inbound readiness is `ready`. Use `void email domain status` to check current provider routing and any remaining setup steps. +A project with an [email alias](../../reference/cli/email.md#void-email-alias) also receives at `+anything@`. Routing ignores which prefix was used: with the slug `acme-store` and the alias `acme`, both `acme-store+support@…` and `acme+support@…` reach `email/support.ts`. + For direct deployments, follow [Your own Cloudflare account](./domains.md#your-own-cloudflare-account) to configure inbound addresses. The local dev server provides the outbound email inbox but has no inbound email trigger. Test inbound handlers with `createInboundTestHarness` below. diff --git a/docs/guide/email/sending.md b/docs/guide/email/sending.md index fc5b61d6b..3d6efc5b2 100644 --- a/docs/guide/email/sending.md +++ b/docs/guide/email/sending.md @@ -84,7 +84,7 @@ You can send to at most 50 recipients across `to`, `cc`, and `bcc`. Use standard `Address` accepts either a string (`"hello@acme.dev"` or `"Name "`) or an object (`{ email, name? }`). Unicode display names are supported. -On the platform the sender is pinned to your project. `from` must be your project's own platform address — `@` or `+@`, optionally with a display name — or any address on a domain registered with `void email domain add` (see [Your own domain on the platform](./domains.md#your-own-domain-on-the-platform)). For example, if your platform's mail domain is `mail.example.com`, you can use `Acme `. Anything else is rejected with `INVALID_FROM`. Omit `from` and Void fills in `+noreply@` for you. +On the platform the sender is pinned to your project. `from` must be your project's own platform address — `@` or `+@`, optionally with a display name — or any address on a domain registered with `void email domain add` (see [Your own domain on the platform](./domains.md#your-own-domain-on-the-platform)). For example, if your platform's mail domain is `mail.example.com`, you can use `Acme `. Anything else is rejected with `INVALID_FROM`. Omit `from` and Void fills in `+noreply@` for you. If the project owner set an [email alias](../../reference/cli/email.md#void-email-alias), `@` and `+@` are accepted too; the default stays the slug address. On your own Cloudflare account, `from` defaults to `email.from` from `void.config.ts` and must be on a domain your account can send from; Cloudflare rejects any other sender and `sendEmail` reports it as `INVALID_FROM`. diff --git a/docs/guide/platform/administration/email.md b/docs/guide/platform/administration/email.md index 760d64426..9e026f987 100644 --- a/docs/guide/platform/administration/email.md +++ b/docs/guide/platform/administration/email.md @@ -10,6 +10,8 @@ These commands apply to a platform that enables email. Every project can send fr Shared inbound mail uses one exact routing rule per receiving project and enables Cloudflare’s per-zone plus addressing. Creating a project or deploying an app that only sends mail consumes no inbound rule. Existing rules and the apex catch-all remain in place. Cloudflare normally allows [200 routing rules per domain](https://developers.cloudflare.com/email-service/platform/limits/); remove unused rules or request a higher limit if the shared domain reaches capacity. An unrelated rule for a project’s base address or a plus address blocks its setup until that conflict is removed. +A project owner can give the shared address one permanent, shorter name with `void email alias `. A receiving project with an alias uses two rules, one for the slug and one for the alias, because the slug address keeps working. Setting an alias is refused while a conflicting rule exists or the domain is at its rule limit, and the alias is released only when the project is deleted. + If recipient creation is interrupted, inspect its retained intent: ```sh diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 0e874dabd..47394f55c 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -73,6 +73,7 @@ Use this page as a command reference. If you are setting up a project for the fi | `void email destinations` | List verified recipient addresses | | `void email allow
` | Add a recipient and send a verification email | | `void email disallow
` | Remove a recipient from the allowlist | +| `void email alias [name]` | Show the shared mail address, or give it a shorter permanent name | | `void email domain` | Send and receive at your own domain on a Cloudflare zone | | `void init` | Set up a new or existing project, including coding agents | | `void migrate` | Convert legacy `void.json` and root Wrangler JSON/JSONC into `void.config.ts` | diff --git a/docs/reference/cli/email.md b/docs/reference/cli/email.md index 4d053e860..846c8d4a5 100644 --- a/docs/reference/cli/email.md +++ b/docs/reference/cli/email.md @@ -50,6 +50,19 @@ void email disallow
[--project ] Remove one recipient from the project's destination list. New sends to that address are refused immediately; previously admitted attempts may finish. No deploy is involved. +## `void email alias` {#void-email-alias} + +``` +void email alias [--project ] +void email alias [--yes] [--project ] +``` + +With no name, show the project's address on the platform's shared mail domain and whether inbound mail reaches it. + +With a name, give that address a shorter name: `void email alias acme` makes `acme@` and `acme+@` reach the project and lets it send from them. Only the project owner can set it. The name follows the same rules as a project slug, must not be used by any other project's slug or alias, and names of five characters or fewer need a plan that allows short names. + +The alias is permanent: it cannot be changed or removed, and it is freed only when the project is deleted. The slug address keeps working for sending and receiving, so nothing that already uses it breaks. The CLI asks for confirmation; pass `--yes` to skip the prompt, which is required outside an interactive terminal. Inbound mail at the alias starts once the project has `email/` handlers deployed and its recipient rule is ready; run `void email alias` to check. Redeploy with the current `void` before you share the alias: an app built with an older version sees `message.to` with the alias still in front (`acme+support@…` instead of `support@…`). + ## `void email domain` {#void-email-domain} ``` diff --git a/packages/void/src/cli/cli.ts b/packages/void/src/cli/cli.ts index 4663ba024..7aebf6ec7 100644 --- a/packages/void/src/cli/cli.ts +++ b/packages/void/src/cli/cli.ts @@ -321,7 +321,7 @@ if (helpPath) { }, { name: 'email', - hint: 'usage | logs | destinations | allow | disallow | domain | status | setup', + hint: 'usage | logs | destinations | allow | disallow | alias | domain | status | setup', description: 'Inspect email activity and manage verified recipients', }, { diff --git a/packages/void/src/cli/client.ts b/packages/void/src/cli/client.ts index 339ae2d7a..44f6663e0 100644 --- a/packages/void/src/cli/client.ts +++ b/packages/void/src/cli/client.ts @@ -438,12 +438,24 @@ export type EmailSettingsResponse = { projectSlug?: string; }; recipientPolicy?: { mode: 'verified' | 'domains' | 'any'; domains: Array }; - sharedInbound?: { - recipient: string; - status: string; - reason: string | null; - observedAt: string | null; - } | null; + /** A second, permanent name on the shared mail domain; the slug keeps working. */ + emailAlias?: string | null; + /** The platform's shared mail domain, or null when it has none. */ + sharedDomain?: string | null; + sharedInbound?: EmailSharedInbound | null; +}; + +export type EmailSharedInbound = { + recipient: string; + status: string; + reason: string | null; + observedAt: string | null; +}; + +export type EmailAliasResponse = { + emailAlias: string; + address: string; + sharedInbound: EmailSharedInbound | null; }; export type EmailDomainResponse = { @@ -1923,6 +1935,14 @@ export class PlatformClient { ); } + async setEmailAlias(projectId: string, alias: string): Promise { + return this.json( + `${this.baseUrl}/projects/${projectId}/email/alias`, + 'Failed to set the email alias', + { method: 'PUT', body: { alias }, permission: 'settings' }, + ); + } + async listEmailDomains(projectId: string): Promise> { return this.json( `${this.baseUrl}/projects/${projectId}/email/domains`, diff --git a/packages/void/src/cli/email-args.ts b/packages/void/src/cli/email-args.ts index 38cfb1c37..ac039e777 100644 --- a/packages/void/src/cli/email-args.ts +++ b/packages/void/src/cli/email-args.ts @@ -12,6 +12,11 @@ export const emailSubcommands: Array = [ { name: 'destinations', description: 'List verified recipient addresses for outbound mail' }, { name: 'allow', arg: '
', description: 'Add a recipient and trigger CF verification' }, { name: 'disallow', arg: '
', description: 'Remove a verified recipient' }, + { + name: 'alias', + arg: '[name]', + description: 'Show the shared mail address, or set a shorter permanent name for it', + }, // The verb is collected as the arg so `domain list` completes in the interactive prompt; // `domain add` then errors naming the missing , like a typed invocation. { @@ -38,6 +43,7 @@ const EMAIL_SUBCOMMANDS = [ 'destinations', 'allow', 'disallow', + 'alias', 'domain', 'status', 'setup', @@ -57,6 +63,8 @@ export interface EmailArgs { address?: string; /** `cloudflare` is the developer's own account; `void` (the default) is the Void platform. */ platform?: 'cloudflare' | 'void'; + /** `void email alias --yes` sets the permanent alias without a prompt. */ + yes?: boolean; } /** @@ -85,7 +93,7 @@ export function parseEmailArgs(args: Array): EmailArgs { const subcommand = args[0] as EmailArgs['subcommand']; if (!subcommand || !(EMAIL_SUBCOMMANDS as ReadonlyArray).includes(subcommand)) { throw new Error( - 'email: Usage: void email [args] [--project ] [--limit ] [--platform cloudflare].', + 'email: Usage: void email [args] [--project ] [--limit ] [--platform cloudflare].', ); } @@ -95,6 +103,7 @@ export function parseEmailArgs(args: Array): EmailArgs { const projectValues: Array = []; const platformValues: Array = []; const positional: Array = []; + let yes = false; const rest = args.slice(1); for (let i = 0; i < rest.length; i++) { @@ -127,6 +136,8 @@ export function parseEmailArgs(args: Array): EmailArgs { } platformValues.push(value); i = next; + } else if (rest[i] === '--yes' && subcommand === 'alias') { + yes = true; } else if (rest[i] === '--backend' || rest[i]?.startsWith('--backend=')) { throw new Error("email: Unknown option '--backend'."); } else if (rest[i] === '--subdomain' || rest[i]?.startsWith('--subdomain=')) { @@ -197,7 +208,7 @@ export function parseEmailArgs(args: Array): EmailArgs { } } - return { subcommand, projectSlug, limit, address, platform }; + return { subcommand, projectSlug, limit, address, platform, ...(yes ? { yes } : {}) }; } /** diff --git a/packages/void/src/cli/email.ts b/packages/void/src/cli/email.ts index 0d209db2b..2c58bf1c6 100644 --- a/packages/void/src/cli/email.ts +++ b/packages/void/src/cli/email.ts @@ -14,6 +14,7 @@ import { PlatformClient, type EmailDomainResponse, type EmailOperationResponse, + type EmailSharedInbound, } from './client.ts'; import { createDeployWranglerRunner, @@ -49,7 +50,7 @@ export async function runEmailCommand(root: string, args: EmailArgs): Promise, + projectId: string, + name: string | undefined, + opts: { yes: boolean; isInteractive?: boolean } = { yes: false }, +): Promise { + if (!name) { + const settings = await client.getEmailSettings(projectId); + const shared = settings.sharedInbound; + const domain = + settings.sharedDomain ?? + (shared ? shared.recipient.slice(shared.recipient.lastIndexOf('@') + 1) : null); + const name = settings.emailAlias ?? settings.domains.projectSlug; + if (!domain || !name) { + log.info('This platform has no shared mail domain.'); + return; + } + const address = `${name}@${domain}`; + const lines = [`Address: ${address}`, `Inbound: ${inboundLine(shared, address)}`]; + if (settings.emailAlias && settings.domains.projectSlug) { + lines.push(`Also: ${settings.domains.projectSlug}@${domain} (the project slug)`); + } + note(lines.join('\n'), 'Shared mail address'); + if (!settings.emailAlias) { + log.info('Run `void email alias ` to give this address a shorter, permanent name.'); + } + return; + } + if (!opts.yes) { + if (!(opts.isInteractive ?? (process.stdin.isTTY && process.stdout.isTTY))) { + log.error('email: the alias is permanent. Pass --yes to set it without a prompt.'); + process.exit(1); + } + const ok = await confirm({ + message: `Set the permanent email alias "${name}"? It cannot be changed or removed later; the slug address keeps working.`, + initialValue: false, + }); + if (isCancel(ok) || !ok) { + log.info('Cancelled. No alias was set.'); + return; + } + } + const result = await client.setEmailAlias(projectId, name); + printSuccess(`Email alias set: ${result.address}`); + const status = result.sharedInbound?.status; + if (status && status !== 'ready') { + const other = + result.sharedInbound!.recipient === result.address + ? '' + : ` at ${result.sharedInbound!.recipient}`; + log.info( + `Inbound mail${other} is ${status}${result.sharedInbound?.reason ? `: ${result.sharedInbound.reason}` : ''}. It starts working once the project has an email handler deployed and routing is ready; check with \`void email alias\`.`, + ); + } +} + +/** + * The inbound status covers every name of the project, so name the address + * it belongs to when that is not the one shown (the slug row can be the one + * not ready yet). + */ +function inboundLine(shared: EmailSharedInbound | null | undefined, address: string): string { + if (!shared) { + return 'not set up on this platform'; + } + return shared.recipient === address ? shared.status : `${shared.status} (${shared.recipient})`; +} + async function emailDestinations(client: PlatformClient, projectId: string): Promise { const rows = await client.listEmailDestinations(projectId); if (rows.length === 0) { diff --git a/packages/void/src/cli/help.ts b/packages/void/src/cli/help.ts index 0e0b79dbd..e51d88bdb 100644 --- a/packages/void/src/cli/help.ts +++ b/packages/void/src/cli/help.ts @@ -871,6 +871,10 @@ const HELP_TREE: HelpTree = { { command: 'destinations', description: 'List verified recipient addresses' }, { command: 'allow
', description: 'Add a recipient and trigger CF verification' }, { command: 'disallow
', description: 'Remove a recipient from the allowlist' }, + { + command: 'alias [name]', + description: 'Show the shared mail address, or set a shorter permanent name for it', + }, { command: 'domain ', description: 'Send and receive at your own domain on a Cloudflare zone', @@ -915,6 +919,15 @@ const HELP_TREE: HelpTree = { 'Remove a recipient from the verified allowlist.', 'void email disallow
[options]', ), + alias: { + summary: + 'Show the project address on the shared mail domain, or give it a shorter name. The alias is permanent; the slug address keeps working.', + usage: ['void email alias [options]', 'void email alias [options]'], + options: [ + { command: '--project ', description: 'Project name override' }, + { command: '--yes', description: 'Set the alias without asking (it cannot be changed)' }, + ], + }, // The one nested group: verbs parse in email-args.ts (`domain `). // Void-platform-only; custom inbound requires a zone apex. domain: { diff --git a/packages/void/src/router/compile.ts b/packages/void/src/router/compile.ts index 9edb14652..a83eccda6 100644 --- a/packages/void/src/router/compile.ts +++ b/packages/void/src/router/compile.ts @@ -1216,11 +1216,14 @@ ${envValidateCall}${remoteEnvSwap}${isrDeploymentStamp} const userEnv = __filte ? `import { createMessageProxy as __createMessageProxy, enactEmailActions as __enactEmailActions, isForwardableEmailMessage as __isForwardableEmailMessage } from ${JSON.stringify(runtimeEmailPath)};\nimport { EMAIL_PROTOCOL_VERSION as __EMAIL_PROTOCOL_VERSION, encodeEmailActions as __encodeEmailActions, readEmailEnvelope as __readEmailEnvelope, verifyEmailReceiptThroughProxy as __verifyEmailReceiptThroughProxy } from "void/_email-protocol";\n` : ''; // Custom-domain receipts (`viaDomain`) carry no `+` prefix, so they get no slug. + // On the shared domain the prefix is the slug or the project's email alias. The + // platform router already matched it to this project, so strip the name the + // recipient actually used. const emailHandler = hasEmail ? `async function handleEmail(envelope, env, ctx) { ${envValidateCall}${remoteEnvSwap} const userEnv = __filterInternalBindings(env); const __relayed = typeof envelope.viaDomain === "string" && envelope.viaDomain.length > 0; - const __slug = __relayed ? undefined : (typeof env.__VOID_PROJECT_SLUG === "string" && env.__VOID_PROJECT_SLUG.length > 0 ? env.__VOID_PROJECT_SLUG : undefined); + const __slug = __relayed || typeof env.__VOID_PROJECT_SLUG !== "string" || env.__VOID_PROJECT_SLUG.length === 0 ? undefined : (String(envelope.to || "").split("@")[0].split("+")[0] || env.__VOID_PROJECT_SLUG); const { message, getActions } = __createMessageProxy(envelope, { slug: __slug }); await __withRawEnv(env, () => withRuntimeEnv(env, () => __emailHandler(message, userEnv, ctx, { slug: __slug }))); const __actions = getActions(); diff --git a/packages/void/test/unit/cli-help.test.ts b/packages/void/test/unit/cli-help.test.ts index e2a44231e..ffc973737 100644 --- a/packages/void/test/unit/cli-help.test.ts +++ b/packages/void/test/unit/cli-help.test.ts @@ -185,6 +185,21 @@ describe('rendered help advertises the flags the parser honours', () => { } }); + it('lists `email alias [name]` and shows both usages with --project and --yes', () => { + expect(render(['email'])).toContain('alias [name]'); + expect(hasHelpPage(['email', 'alias'])).toBe(true); + expect(getHelpPath(['email', 'alias', 'short', '--yes', '--help'])).toEqual(['email', 'alias']); + + const page = render(['email', 'alias']); + expect(page).toContain('void email alias [options]'); + expect(page).toContain('void email alias [options]'); + expect(page).toContain('--project '); + expect(page).toContain('--yes'); + expect(page).toContain('permanent'); + expect(page).not.toContain('--limit'); + expect(page).not.toContain('--platform'); + }); + // `status` / `setup` read the local project + wrangler session, never a Void project, // so their pages carry `--platform cloudflare` and no `--project`. it('shows --platform cloudflare, and no --project, on `email status` and `email setup`', () => { diff --git a/packages/void/test/unit/email-alias-cli.test.ts b/packages/void/test/unit/email-alias-cli.test.ts new file mode 100644 index 000000000..ed037c99b --- /dev/null +++ b/packages/void/test/unit/email-alias-cli.test.ts @@ -0,0 +1,282 @@ +import { confirm, log, note } from '@voidzero-dev/vite-plus-prompts'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import type { + EmailAliasResponse, + EmailSettingsResponse, + PlatformClient, +} from '../../src/cli/client.ts'; +import { emailAlias } from '../../src/cli/email.ts'; +import * as output from '../../src/cli/output.ts'; + +/** + * `void email alias [name]`. Without a name it shows the shared mail address; + * with one it sets a permanent alias, which needs `--yes` or a confirmed prompt. + */ + +/** What the mocked `confirm` returns for Ctrl-C; the mocked `isCancel` knows only this. */ +const CANCEL = Symbol('cancel'); + +vi.mock('@voidzero-dev/vite-plus-prompts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + note: vi.fn(), + confirm: vi.fn(), + isCancel: (value: unknown) => value === CANCEL, + }; +}); + +const DOMAIN = 'mail.void.test'; + +function settings(overrides: Partial = {}): EmailSettingsResponse { + return { + domains: { management: 'self-serve', projectSlug: 'my-long-project' }, + emailAlias: null, + sharedInbound: { + recipient: `my-long-project@${DOMAIN}`, + status: 'ready', + reason: null, + observedAt: null, + }, + ...overrides, + }; +} + +function aliasResponse(overrides: Partial = {}): EmailAliasResponse { + return { + emailAlias: 'short', + address: `short@${DOMAIN}`, + sharedInbound: { + recipient: `short@${DOMAIN}`, + status: 'ready', + reason: null, + observedAt: null, + }, + ...overrides, + }; +} + +function client( + body: EmailSettingsResponse = settings(), + result: EmailAliasResponse = aliasResponse(), +): Pick { + return { + getEmailSettings: vi.fn(async () => body), + setEmailAlias: vi.fn(async () => result), + }; +} + +type LogSpy = ReturnType; + +let successSpy: LogSpy; +let infoSpy: LogSpy; +let errorSpy: LogSpy; + +beforeEach(() => { + successSpy = vi.spyOn(output, 'printSuccess').mockImplementation(() => {}); + infoSpy = vi.spyOn(log, 'info').mockImplementation(() => {}); + errorSpy = vi.spyOn(log, 'error').mockImplementation(() => {}); + vi.mocked(note).mockClear(); + vi.mocked(confirm).mockReset(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function printed(spy: LogSpy): string { + return (spy.mock.calls as Array>).map((call) => String(call[0])).join('\n'); +} + +function notedBlocks(): string { + return vi + .mocked(note) + .mock.calls.map((call) => String(call[0])) + .join('\n'); +} + +describe('void email alias (no name)', () => { + it('shows the alias address and the slug address that keeps working', async () => { + const api = client( + settings({ + emailAlias: 'short', + sharedInbound: { + recipient: `short@${DOMAIN}`, + status: 'ready', + reason: null, + observedAt: null, + }, + }), + ); + + await emailAlias(api, 'prj_1', undefined); + + const block = notedBlocks(); + expect(block).toContain(`Address: short@${DOMAIN}`); + expect(block).toContain('Inbound: ready'); + expect(block).toContain(`Also: my-long-project@${DOMAIN} (the project slug)`); + expect(printed(infoSpy)).not.toContain('void email alias '); + expect(api.setEmailAlias).not.toHaveBeenCalled(); + }); + + it('shows the alias as the address when the slug row is the one not ready', async () => { + const api = client( + settings({ + emailAlias: 'short', + sharedInbound: { + recipient: `my-long-project@${DOMAIN}`, + status: 'pending', + reason: 'no email handler deployed', + observedAt: null, + }, + }), + ); + + await emailAlias(api, 'prj_1', undefined); + + const block = notedBlocks(); + expect(block).toContain(`Address: short@${DOMAIN}`); + expect(block).toContain(`Inbound: pending (my-long-project@${DOMAIN})`); + expect(block).toContain(`Also: my-long-project@${DOMAIN} (the project slug)`); + }); + + it('shows the slug address and hints at an alias when none is set', async () => { + const api = client(); + + await emailAlias(api, 'prj_1', undefined, { yes: true }); + + const block = notedBlocks(); + expect(block).toContain(`Address: my-long-project@${DOMAIN}`); + expect(block).not.toContain('Also:'); + expect(printed(infoSpy)).toContain('Run `void email alias `'); + // `--yes` without a name has nothing to set. + expect(api.setEmailAlias).not.toHaveBeenCalled(); + }); + + it('says so when the platform has no shared mail domain', async () => { + const api = client(settings({ sharedDomain: null, sharedInbound: null })); + + await emailAlias(api, 'prj_1', undefined); + + expect(printed(infoSpy)).toBe('This platform has no shared mail domain.'); + expect(note).not.toHaveBeenCalled(); + }); + + it('shows the alias on a platform that sends from the shared domain but has no inbound route', async () => { + const api = client( + settings({ emailAlias: 'short', sharedDomain: DOMAIN, sharedInbound: null }), + ); + + await emailAlias(api, 'prj_1', undefined); + + const block = notedBlocks(); + expect(block).toContain(`Address: short@${DOMAIN}`); + expect(block).toContain('Inbound: not set up on this platform'); + expect(block).toContain(`Also: my-long-project@${DOMAIN} (the project slug)`); + }); +}); + +describe('void email alias ', () => { + it('sets the alias with --yes without asking, and prints the address', async () => { + const api = client(); + + await emailAlias(api, 'prj_1', 'short', { yes: true }); + + expect(confirm).not.toHaveBeenCalled(); + expect(api.setEmailAlias).toHaveBeenCalledWith('prj_1', 'short'); + expect(printed(successSpy)).toBe(`Email alias set: short@${DOMAIN}`); + expect(infoSpy).not.toHaveBeenCalled(); + }); + + it('says when inbound mail at the new alias is not ready yet', async () => { + const api = client( + settings(), + aliasResponse({ + sharedInbound: { + recipient: `short@${DOMAIN}`, + status: 'pending', + reason: 'no email handler deployed', + observedAt: null, + }, + }), + ); + + await emailAlias(api, 'prj_1', 'short', { yes: true }); + + expect(printed(successSpy)).toBe(`Email alias set: short@${DOMAIN}`); + const text = printed(infoSpy); + expect(text).toContain('Inbound mail is pending: no email handler deployed.'); + expect(text).toContain('`void email alias`'); + }); + + it('names the slug address when its rule is the one not ready after setting the alias', async () => { + const api = client( + settings(), + aliasResponse({ + sharedInbound: { + recipient: `my-long-project@${DOMAIN}`, + status: 'blocked', + reason: 'an unrelated rule conflicts', + observedAt: null, + }, + }), + ); + + await emailAlias(api, 'prj_1', 'short', { yes: true }); + + expect(printed(infoSpy)).toContain( + `Inbound mail at my-long-project@${DOMAIN} is blocked: an unrelated rule conflicts.`, + ); + }); + + it('exits 1 without a prompt or a request when not interactive and --yes is missing', async () => { + const api = client(); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit'); + }) as never); + + await expect( + emailAlias(api, 'prj_1', 'short', { yes: false, isInteractive: false }), + ).rejects.toThrow('process.exit'); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(printed(errorSpy)).toContain('Pass --yes'); + expect(confirm).not.toHaveBeenCalled(); + expect(api.setEmailAlias).not.toHaveBeenCalled(); + }); + + it('does not set the alias when the prompt is declined', async () => { + const api = client(); + vi.mocked(confirm).mockResolvedValue(false); + + await emailAlias(api, 'prj_1', 'short', { yes: false, isInteractive: true }); + + expect(confirm).toHaveBeenCalledTimes(1); + const prompt = vi.mocked(confirm).mock.calls[0]![0]; + expect(prompt.message).toContain('"short"'); + expect(prompt.message).toContain('cannot be changed'); + expect(prompt.initialValue).toBe(false); + expect(api.setEmailAlias).not.toHaveBeenCalled(); + expect(printed(infoSpy)).toBe('Cancelled. No alias was set.'); + }); + + it('does not set the alias when the prompt is cancelled', async () => { + const api = client(); + vi.mocked(confirm).mockResolvedValue(CANCEL as never); + + await emailAlias(api, 'prj_1', 'short', { yes: false, isInteractive: true }); + + expect(api.setEmailAlias).not.toHaveBeenCalled(); + expect(printed(infoSpy)).toBe('Cancelled. No alias was set.'); + }); + + it('sets the alias when the prompt is accepted', async () => { + const api = client(); + vi.mocked(confirm).mockResolvedValue(true); + + await emailAlias(api, 'prj_1', 'short', { yes: false, isInteractive: true }); + + expect(api.setEmailAlias).toHaveBeenCalledWith('prj_1', 'short'); + expect(printed(successSpy)).toBe(`Email alias set: short@${DOMAIN}`); + }); +}); diff --git a/packages/void/test/unit/email-args.test.ts b/packages/void/test/unit/email-args.test.ts index 9a46fa45d..d4f88024c 100644 --- a/packages/void/test/unit/email-args.test.ts +++ b/packages/void/test/unit/email-args.test.ts @@ -53,6 +53,7 @@ describe('parseEmailArgs', () => { it('errors on unknown subcommand', () => { expect(() => parseEmailArgs(['nope'])).toThrow(/email: Usage:/); + expect(() => parseEmailArgs(['nope'])).toThrow('|disallow|alias|domain|'); }); it('errors when allow is missing the address', () => { @@ -241,6 +242,69 @@ describe('parseEmailArgs', () => { ); }); + it('parses alias with and without a name', () => { + expect(parseEmailArgs(['alias'])).toEqual({ + subcommand: 'alias', + projectSlug: undefined, + limit: undefined, + address: undefined, + platform: undefined, + }); + expect(parseEmailArgs(['alias', 'short', '--project', 'my-app'])).toEqual({ + subcommand: 'alias', + projectSlug: 'my-app', + limit: undefined, + address: 'short', + platform: undefined, + }); + }); + + it('parses alias --yes before or after the name', () => { + expect(parseEmailArgs(['alias', 'short', '--yes'])).toEqual({ + subcommand: 'alias', + projectSlug: undefined, + limit: undefined, + address: 'short', + platform: undefined, + yes: true, + }); + expect(parseEmailArgs(['alias', '--yes', 'short'])).toMatchObject({ + address: 'short', + yes: true, + }); + // Without a name there is nothing to confirm; the flag is harmless. + expect(parseEmailArgs(['alias', '--yes'])).toMatchObject({ + address: undefined, + yes: true, + }); + }); + + it('errors when alias receives more than one name', () => { + expect(() => parseEmailArgs(['alias', 'one', 'two'])).toThrow( + 'email: `void email alias` takes exactly one positional argument (got 2: "one", "two").', + ); + }); + + it('rejects --platform cloudflare on alias', () => { + expect(() => parseEmailArgs(['alias', '--platform', 'cloudflare'])).toThrow( + 'email: --platform cloudflare applies to `void email status` and `void email setup` only (`alias` is a Void platform command).', + ); + }); + + // `--yes` is an alias-only flag: elsewhere it is not read as a flag at all, so it + // never sets `yes` and counts as a positional like any other unknown word. + it('does not accept --yes on the other subcommands', () => { + expect(() => parseEmailArgs(['allow', 'a@x.dev', '--yes'])).toThrow( + 'email: `void email allow` takes exactly one positional argument (got 2: "a@x.dev", "--yes").', + ); + expect(() => parseEmailArgs(['disallow', 'a@x.dev', '--yes'])).toThrow( + /takes exactly one positional argument/, + ); + const usage = parseEmailArgs(['usage', '--yes']); + expect(usage.yes).toBeUndefined(); + expect(usage.address).toBe('--yes'); + }); + it('rejects --backend', () => { for (const flags of [['--backend'], ['--backend', 'cloudflare'], ['--backend=cloudflare']]) { expect(() => parseEmailArgs(['status', ...flags])).toThrow( diff --git a/packages/void/test/unit/email-own-account.test.ts b/packages/void/test/unit/email-own-account.test.ts index 63a0d51df..295a7a9d2 100644 --- a/packages/void/test/unit/email-own-account.test.ts +++ b/packages/void/test/unit/email-own-account.test.ts @@ -629,3 +629,74 @@ describe('generated handleEmail on a verified custom-domain receipt', () => { expect(actions).toMatchObject([{ type: 'reply', from: 'acme+noreply@mail.void.cloud' }]); }); }); + +/** + * A project with an email alias receives on `@` as well as + * `@`. The platform router already matched the prefix to this + * project, so the generated `handleEmail` strips the name the recipient used, + * not the injected `__VOID_PROJECT_SLUG`. + */ +describe('generated handleEmail on a project email alias', () => { + function sharedEnvelope(to: string) { + return { + from: 'sender@example.com', + to, + headers: {}, + raw: new TextEncoder().encode(INBOUND_MIME), + rawSize: INBOUND_MIME.length, + }; + } + + it('strips the alias prefix the same way it strips the slug prefix', async () => { + const seen: Array<{ to: string; slug: unknown }> = []; + const handleEmail = buildHandleEmail(async (message, _env, _ctx, opts) => { + seen.push({ to: message.to, slug: opts?.slug }); + }); + const env = { __VOID_PROJECT_SLUG: 'proj' }; + + await handleEmail(sharedEnvelope('alias+orders@mail.void.cloud'), env, {}); + await handleEmail(sharedEnvelope('proj+orders@mail.void.cloud'), env, {}); + + expect(seen).toEqual([ + { to: 'orders@mail.void.cloud', slug: 'alias' }, + { to: 'orders@mail.void.cloud', slug: 'proj' }, + ]); + }); + + it('keys a bare alias address on the alias, so it reads as an empty tag', async () => { + const seen: Array<{ to: string; slug: unknown }> = []; + const handleEmail = buildHandleEmail(async (message, _env, _ctx, opts) => { + seen.push({ to: message.to, slug: opts?.slug }); + }); + + await handleEmail(sharedEnvelope('alias@mail.void.cloud'), { __VOID_PROJECT_SLUG: 'proj' }, {}); + + // Keyed on `proj`, the dispatcher would read `alias` as an address. + expect(seen).toEqual([{ to: 'alias@mail.void.cloud', slug: 'alias' }]); + }); + + it('still strips nothing when no slug is injected', async () => { + const seen: Array<{ to: string; slug: unknown }> = []; + const handleEmail = buildHandleEmail(async (message, _env, _ctx, opts) => { + seen.push({ to: message.to, slug: opts?.slug }); + }); + + await handleEmail(sharedEnvelope('alias+orders@mail.void.cloud'), {}, {}); + + expect(seen).toEqual([{ to: 'alias+orders@mail.void.cloud', slug: undefined }]); + }); + + it('keeps the slug shared-sender reply default when the alias received the message', async () => { + const handleEmail = buildHandleEmail(async (message) => { + await replyEmail(message, { text: 'ack' }); + }); + + const actions = (await handleEmail( + sharedEnvelope('alias+orders@mail.void.cloud'), + { __VOID_PROJECT_SLUG: 'proj', __VOID_EMAIL_DOMAIN: 'mail.void.cloud' }, + {}, + )) as Array<{ type: string; from: string }>; + + expect(actions).toMatchObject([{ type: 'reply', from: 'proj+noreply@mail.void.cloud' }]); + }); +}); diff --git a/platform/meta/operations.md b/platform/meta/operations.md index 0b06d397a..e176aa07d 100644 --- a/platform/meta/operations.md +++ b/platform/meta/operations.md @@ -186,6 +186,8 @@ preserve staged secret state until the operation completes. Outbound-only domains (`domain-add --outbound-only`) need a release that knows the `inbound_mode` column. After a platform rollback to an earlier release, do not run `domain-add`, `domain-sync`, or `sync` for them: the older release treats them as normal domains and would enable Email Routing, which replaces the apex MX records and takes over the provider's mail. Sending keeps working without these commands. Upgrade again first. +Project email aliases (`projects.email_alias`, migration 0062) need a release that knows the column. After a rollback to an earlier release, alias addresses stop sending and receiving and the next shared-recipient reconcile removes their routing rules; slug addresses keep working. The column and its values stay, and the first reconcile after upgrading again recreates the alias rules. While the older release runs, creating a project whose slug equals an existing alias (CLI, API, or OAuth deploy) fails with a 500 instead of `slug already taken`, because the 0062 trigger still refuses it; pick another slug or upgrade again. + PostgreSQL is authoritative for consent, policy, suspension, and recipient-attempt quotas. Cache publication retries independently. `void email logs` exposes receipt and action metadata: provider acceptance is not proof of delivery, and an unknown result must not be retried with a new idempotency key. Removing one domain or project must preserve sibling routes and unresolved cleanup records. Before production enablement, complete the [email acceptance checks](./release-acceptance.md#email-acceptance), including live Cloudflare routing, forwarding/reply, scoped-token, rotation, and failure-recovery checks. diff --git a/platform/packages/api/drizzle/0062_project_email_alias.sql b/platform/packages/api/drizzle/0062_project_email_alias.sql new file mode 100644 index 000000000..209fe8e1f --- /dev/null +++ b/platform/packages/api/drizzle/0062_project_email_alias.sql @@ -0,0 +1,40 @@ +ALTER TABLE "projects" ADD COLUMN "email_alias" text;--> statement-breakpoint +CREATE UNIQUE INDEX "idx_projects_email_alias" ON "projects" USING btree ("email_alias") WHERE "projects"."email_alias" IS NOT NULL;--> statement-breakpoint +-- A slug and an email alias both name a mailbox on the shared mail domain, so +-- one name may belong to only one project. The unique indexes cover slug vs +-- slug and alias vs alias; this trigger covers slug vs alias in both +-- directions, including projects that an older runtime creates. The advisory +-- lock orders concurrent claims so each check sees the other's commit. +CREATE FUNCTION "void_project_mailbox_name_guard"() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + IF TG_OP = 'UPDATE' + AND NEW.slug IS NOT DISTINCT FROM OLD.slug + AND NEW.email_alias IS NOT DISTINCT FROM OLD.email_alias THEN + RETURN NEW; + END IF; + -- Aliases never start with "preview-", so preview projects cannot collide. + IF NEW.email_alias IS NULL AND NEW.slug LIKE 'preview-%' THEN + RETURN NEW; + END IF; + PERFORM pg_advisory_xact_lock(5110511); + IF NEW.email_alias = NEW.slug + OR EXISTS ( + SELECT 1 FROM "projects" + WHERE "id" <> NEW.id AND "slug" = NEW.email_alias + ) + OR EXISTS ( + SELECT 1 FROM "projects" + WHERE "id" <> NEW.id AND "email_alias" = NEW.slug + ) THEN + RAISE EXCEPTION USING + ERRCODE = '23505', + MESSAGE = 'A project slug or email alias already uses this name'; + END IF; + RETURN NEW; +END; +$$; +--> statement-breakpoint +CREATE TRIGGER "void_project_mailbox_name_guard" +BEFORE INSERT OR UPDATE OF "slug", "email_alias" ON "projects" +FOR EACH ROW EXECUTE FUNCTION "void_project_mailbox_name_guard"(); diff --git a/platform/packages/api/drizzle/meta/0062_snapshot.json b/platform/packages/api/drizzle/meta/0062_snapshot.json new file mode 100644 index 000000000..0f974e4b5 --- /dev/null +++ b/platform/packages/api/drizzle/meta/0062_snapshot.json @@ -0,0 +1,6999 @@ +{ + "id": "9fc2492c-a3b5-494d-a5f5-a04e70a2656f", + "prevId": "90973306-0586-467f-88b6-6ebded4418eb", + "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.deployment_operations": { + "name": "deployment_operations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_active_id": { + "name": "original_active_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cli_version": { + "name": "cli_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_digest": { + "name": "input_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_prefix": { + "name": "input_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_dispatch_at": { + "name": "next_dispatch_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "events": { + "name": "events", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "last_seq": { + "name": "last_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "inputs_deleted": { + "name": "inputs_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_deployment_operations_key": { + "name": "idx_deployment_operations_key", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployment_operations_dispatch": { + "name": "idx_deployment_operations_dispatch", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_dispatch_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployment_operations_project_id_projects_id_fk": { + "name": "deployment_operations_project_id_projects_id_fk", + "tableFrom": "deployment_operations", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "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 + }, + "billing_user_id": { + "name": "billing_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "initiated_by_user_id": { + "name": "initiated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "proxy_token_gen": { + "name": "proxy_token_gen", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "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_destination_cleanups": { + "name": "email_destination_cleanups", + "schema": "", + "columns": { + "cf_destination_id": { + "name": "cf_destination_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "lease_started_at": { + "name": "lease_started_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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_email_destination_cleanups_due": { + "name": "idx_email_destination_cleanups_due", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "email_destination_cleanups_status_check": { + "name": "email_destination_cleanups_status_check", + "value": "\"email_destination_cleanups\".\"status\" IN ('pending', 'deleting', 'completed')" + }, + "email_destination_cleanups_attempts_check": { + "name": "email_destination_cleanups_attempts_check", + "value": "\"email_destination_cleanups\".\"attempts\" >= 0" + } + }, + "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": true + }, + "authorized_by_user_id": { + "name": "authorized_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": "no action", + "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'" + }, + "email_delivery_status": { + "name": "email_delivery_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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": { + "invitations_email_delivery_status_check": { + "name": "invitations_email_delivery_status_check", + "value": "\"invitations\".\"email_delivery_status\" IS NULL OR \"invitations\".\"email_delivery_status\" IN ('sent', 'failed', 'unavailable')" + } + }, + "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_clients": { + "name": "oauth_clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "operator": { + "name": "operator", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_grants": { + "name": "oauth_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session": { + "name": "session", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[\"profile\"]'::jsonb" + }, + "token_hash": { + "name": "token_hash", + "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": { + "oauth_grants_token": { + "name": "oauth_grants_token", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_grants_user": { + "name": "oauth_grants_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_grants_expiry": { + "name": "oauth_grants_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_grants_client_id_oauth_clients_id_fk": { + "name": "oauth_grants_client_id_oauth_clients_id_fk", + "tableFrom": "oauth_grants", + "tableTo": "oauth_clients", + "columnsFrom": ["client_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "oauth_grants_user_id_users_id_fk": { + "name": "oauth_grants_user_id_users_id_fk", + "tableFrom": "oauth_grants", + "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.oauth_requests": { + "name": "oauth_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "browser_hash": { + "name": "browser_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_expires_at": { + "name": "code_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_requests_code": { + "name": "oauth_requests_code", + "columns": [ + { + "expression": "code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_requests_expiry": { + "name": "oauth_requests_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_requests_client_id_oauth_clients_id_fk": { + "name": "oauth_requests_client_id_oauth_clients_id_fk", + "tableFrom": "oauth_requests", + "tableTo": "oauth_clients", + "columnsFrom": ["client_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "oauth_requests_user_id_users_id_fk": { + "name": "oauth_requests_user_id_users_id_fk", + "tableFrom": "oauth_requests", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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.preview_tickets": { + "name": "preview_tickets", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "preview_tickets_project_id_projects_id_fk": { + "name": "preview_tickets_project_id_projects_id_fk", + "tableFrom": "preview_tickets", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "preview_tickets_grant_id_oauth_grants_id_fk": { + "name": "preview_tickets_grant_id_oauth_grants_id_fk", + "tableFrom": "preview_tickets", + "tableTo": "oauth_grants", + "columnsFrom": ["grant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_access_events": { + "name": "project_access_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invitation_email_snapshot": { + "name": "invitation_email_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_role": { + "name": "previous_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_role": { + "name": "new_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_project_access_events_project_created": { + "name": "idx_project_access_events_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_project_access_events_subject_created": { + "name": "idx_project_access_events_subject_created", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_access_events_project_id_projects_id_fk": { + "name": "project_access_events_project_id_projects_id_fk", + "tableFrom": "project_access_events", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "project_access_events_previous_role_check": { + "name": "project_access_events_previous_role_check", + "value": "\"project_access_events\".\"previous_role\" IS NULL OR \"project_access_events\".\"previous_role\" IN ('reader', 'collaborator', 'admin')" + }, + "project_access_events_new_role_check": { + "name": "project_access_events_new_role_check", + "value": "\"project_access_events\".\"new_role\" IS NULL OR \"project_access_events\".\"new_role\" IN ('reader', 'collaborator', 'admin')" + }, + "project_access_events_action_check": { + "name": "project_access_events_action_check", + "value": "\"project_access_events\".\"action\" IN ('invited', 'invitation_resent', 'member_added', 'accepted', 'declined', 'invitation_revoked', 'role_changed', 'member_removed', 'left')" + } + }, + "isRLSEnabled": false + }, + "public.project_cleanup_jobs": { + "name": "project_cleanup_jobs", + "schema": "", + "columns": { + "target": { + "name": "target", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_cleanup_jobs_due": { + "name": "project_cleanup_jobs_due", + "columns": [ + { + "expression": "next_attempt_at", + "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.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_invitations": { + "name": "project_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_user_id": { + "name": "target_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_email_snapshot": { + "name": "target_email_snapshot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "invited_by_user_id": { + "name": "invited_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accepted_by_user_id": { + "name": "accepted_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_by_user_id": { + "name": "revoked_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_project_invitations_project_status": { + "name": "idx_project_invitations_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_project_invitations_target_status": { + "name": "idx_project_invitations_target_status", + "columns": [ + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_project_invitations_pending_target": { + "name": "idx_project_invitations_pending_target", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"project_invitations\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_invitations_project_id_projects_id_fk": { + "name": "project_invitations_project_id_projects_id_fk", + "tableFrom": "project_invitations", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_invitations_target_user_id_users_id_fk": { + "name": "project_invitations_target_user_id_users_id_fk", + "tableFrom": "project_invitations", + "tableTo": "users", + "columnsFrom": ["target_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "project_invitations_role_check": { + "name": "project_invitations_role_check", + "value": "\"project_invitations\".\"role\" IN ('reader', 'collaborator', 'admin')" + }, + "project_invitations_status_check": { + "name": "project_invitations_status_check", + "value": "\"project_invitations\".\"status\" IN ('pending', 'accepted', 'declined', 'revoked', 'expired')" + } + }, + "isRLSEnabled": false + }, + "public.project_members": { + "name": "project_members", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_by_user_id": { + "name": "added_by_user_id", + "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_members_user_project": { + "name": "idx_project_members_user_project", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_members_project_id_projects_id_fk": { + "name": "project_members_project_id_projects_id_fk", + "tableFrom": "project_members", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_members_user_id_users_id_fk": { + "name": "project_members_user_id_users_id_fk", + "tableFrom": "project_members", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_members_project_id_user_id_pk": { + "name": "project_members_project_id_user_id_pk", + "columns": ["project_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "project_members_role_check": { + "name": "project_members_role_check", + "value": "\"project_members\".\"role\" IN ('reader', 'collaborator', 'admin')" + } + }, + "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_ownership_periods": { + "name": "project_ownership_periods", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "changed_by_user_id": { + "name": "changed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_project_ownership_periods_owner_time": { + "name": "idx_project_ownership_periods_owner_time", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starts_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ends_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_project_ownership_periods_project_time": { + "name": "idx_project_ownership_periods_project_time", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starts_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_project_ownership_periods_open": { + "name": "idx_project_ownership_periods_open", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"project_ownership_periods\".\"ends_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_ownership_periods_project_id_projects_id_fk": { + "name": "project_ownership_periods_project_id_projects_id_fk", + "tableFrom": "project_ownership_periods", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "project_ownership_periods_time_check": { + "name": "project_ownership_periods_time_check", + "value": "\"project_ownership_periods\".\"ends_at\" IS NULL OR \"project_ownership_periods\".\"ends_at\" >= \"project_ownership_periods\".\"starts_at\"" + } + }, + "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.project_zero_trust": { + "name": "project_zero_trust", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "desired_protected": { + "name": "desired_protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "effective_protected": { + "name": "effective_protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ready'" + }, + "bypass_application_id": { + "name": "bypass_application_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bypass_application_name": { + "name": "bypass_application_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "protected_application_id": { + "name": "protected_application_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "protected_application_name": { + "name": "protected_application_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "protected_application_audience": { + "name": "protected_application_audience", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_cursor": { + "name": "routing_cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reconciled_operation": { + "name": "reconciled_operation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "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_zero_trust_status": { + "name": "idx_project_zero_trust_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_zero_trust_project_id_projects_id_fk": { + "name": "project_zero_trust_project_id_projects_id_fk", + "tableFrom": "project_zero_trust", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "project_zero_trust_status_check": { + "name": "project_zero_trust_status_check", + "value": "\"project_zero_trust\".\"status\" IN ('ready', 'reconciling', 'error')" + }, + "project_zero_trust_ready_check": { + "name": "project_zero_trust_ready_check", + "value": "\"project_zero_trust\".\"status\" <> 'ready' OR \"project_zero_trust\".\"desired_protected\" = \"project_zero_trust\".\"effective_protected\"" + } + }, + "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 + }, + "preview_client_id": { + "name": "preview_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_workspace_id": { + "name": "preview_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_expires_at": { + "name": "preview_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'void'" + }, + "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 + }, + "proxy_token_gen": { + "name": "proxy_token_gen", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "email_alias": { + "name": "email_alias", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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_email_alias": { + "name": "idx_projects_email_alias", + "columns": [ + { + "expression": "email_alias", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"projects\".\"email_alias\" IS NOT NULL", + "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": {} + }, + "projects_preview_workspace": { + "name": "projects_preview_workspace", + "columns": [ + { + "expression": "preview_client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "preview_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "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" + }, + "projects_preview_client_id_oauth_clients_id_fk": { + "name": "projects_preview_client_id_oauth_clients_id_fk", + "tableFrom": "projects", + "tableTo": "oauth_clients", + "columnsFrom": ["preview_client_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": { + "projects_preview_identity": { + "name": "projects_preview_identity", + "value": "(\"projects\".\"preview_client_id\" IS NULL AND \"projects\".\"preview_workspace_id\" IS NULL AND \"projects\".\"preview_expires_at\" IS NULL) OR (\"projects\".\"preview_client_id\" IS NOT NULL AND \"projects\".\"preview_workspace_id\" IS NOT NULL AND \"projects\".\"preview_expires_at\" IS NOT NULL AND \"projects\".\"slug\" LIKE 'preview-%')" + } + }, + "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 + }, + "public.email_cache_outbox": { + "name": "email_cache_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_kind": { + "name": "resource_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_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": { + "email_cache_outbox_version_uq": { + "name": "email_cache_outbox_version_uq", + "columns": [ + { + "expression": "resource_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_cache_outbox_pending_idx": { + "name": "email_cache_outbox_pending_idx", + "columns": [ + { + "expression": "published_at", + "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.email_credentials": { + "name": "email_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "managed_by": { + "name": "managed_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manager_user_id": { + "name": "manager_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "zone_id": { + "name": "zone_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "iv": { + "name": "iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_version": { + "name": "key_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_id": { + "name": "token_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_on": { + "name": "expires_on", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'healthy'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_credentials_scope_idx": { + "name": "email_credentials_scope_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "zone_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "email_credentials_material_check": { + "name": "email_credentials_material_check", + "value": "(\"email_credentials\".\"kind\" = 'platform' AND \"email_credentials\".\"ciphertext\" IS NULL AND \"email_credentials\".\"iv\" IS NULL AND \"email_credentials\".\"key_version\" IS NULL) OR (\"email_credentials\".\"kind\" = 'api_token' AND \"email_credentials\".\"ciphertext\" IS NOT NULL AND \"email_credentials\".\"iv\" IS NOT NULL AND \"email_credentials\".\"key_version\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.email_domain_routes": { + "name": "email_domain_routes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "addressing": { + "name": "addressing", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "managed_by": { + "name": "managed_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by_user_id": { + "name": "assigned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "desired_state": { + "name": "desired_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "inbound_mode": { + "name": "inbound_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'managed'" + }, + "inbound_status": { + "name": "inbound_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "inbound_reason": { + "name": "inbound_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbound_observed_at": { + "name": "inbound_observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "outbound_status": { + "name": "outbound_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "outbound_reason": { + "name": "outbound_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outbound_observed_at": { + "name": "outbound_observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "desired_generation": { + "name": "desired_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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_domain_routes_domain_uq": { + "name": "email_domain_routes_domain_uq", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_domain_routes_connection_idx": { + "name": "email_domain_routes_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_domain_routes_project_idx": { + "name": "email_domain_routes_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_domain_routes_connection_id_email_zone_connections_id_fk": { + "name": "email_domain_routes_connection_id_email_zone_connections_id_fk", + "tableFrom": "email_domain_routes", + "tableTo": "email_zone_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "email_domain_routes_assignment_check": { + "name": "email_domain_routes_assignment_check", + "value": "(\"email_domain_routes\".\"addressing\" = 'custom' AND \"email_domain_routes\".\"project_id\" IS NOT NULL) OR (\"email_domain_routes\".\"addressing\" = 'shared' AND \"email_domain_routes\".\"project_id\" IS NULL)" + }, + "email_domain_routes_inbound_mode_check": { + "name": "email_domain_routes_inbound_mode_check", + "value": "\"email_domain_routes\".\"inbound_mode\" = 'managed' OR (\"email_domain_routes\".\"inbound_mode\" = 'none' AND \"email_domain_routes\".\"addressing\" = 'custom')" + } + }, + "isRLSEnabled": false + }, + "public.email_operation_steps": { + "name": "email_operation_steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ordinal": { + "name": "ordinal", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_identity": { + "name": "resource_identity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_digest": { + "name": "request_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "outcome": { + "name": "outcome", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_detail": { + "name": "failure_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "email_operation_steps_order_uq": { + "name": "email_operation_steps_order_uq", + "columns": [ + { + "expression": "operation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ordinal", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_operation_steps_operation_id_email_operations_id_fk": { + "name": "email_operation_steps_operation_id_email_operations_id_fk", + "tableFrom": "email_operation_steps", + "tableTo": "email_operations", + "columnsFrom": ["operation_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_operations": { + "name": "email_operations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "route_id": { + "name": "route_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "route_generation": { + "name": "route_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "actor_kind": { + "name": "actor_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_scope": { + "name": "actor_scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_digest": { + "name": "request_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "desired_generation": { + "name": "desired_generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "current_step": { + "name": "current_step", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_detail": { + "name": "failure_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_operations_key_uq": { + "name": "email_operations_key_uq", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_operations_work_idx": { + "name": "email_operations_work_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_operations_connection_idx": { + "name": "email_operations_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_operations_connection_id_email_zone_connections_id_fk": { + "name": "email_operations_connection_id_email_zone_connections_id_fk", + "tableFrom": "email_operations", + "tableTo": "email_zone_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "email_operations_route_id_email_domain_routes_id_fk": { + "name": "email_operations_route_id_email_domain_routes_id_fk", + "tableFrom": "email_operations", + "tableTo": "email_domain_routes", + "columnsFrom": ["route_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_shared_recipients": { + "name": "email_shared_recipients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "route_id": { + "name": "route_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient": { + "name": "recipient", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rule_name": { + "name": "rule_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rule_id": { + "name": "rule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "create_started_at": { + "name": "create_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "removing": { + "name": "removing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lease_started_at": { + "name": "lease_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "observed_at": { + "name": "observed_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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_shared_recipients_project_address_uq": { + "name": "email_shared_recipients_project_address_uq", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recipient", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_shared_recipients_rule_name_uq": { + "name": "email_shared_recipients_rule_name_uq", + "columns": [ + { + "expression": "rule_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_shared_recipients_reconcile_idx": { + "name": "email_shared_recipients_reconcile_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_zone_connections": { + "name": "email_zone_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "zone_id": { + "name": "zone_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "zone_name": { + "name": "zone_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "managed_by": { + "name": "managed_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manager_user_id": { + "name": "manager_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ingress_worker_name": { + "name": "ingress_worker_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ingress_version": { + "name": "ingress_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "desired_generation": { + "name": "desired_generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "active_secret_generation": { + "name": "active_secret_generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "staged_secret_generation": { + "name": "staged_secret_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "secret_ciphertext": { + "name": "secret_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_iv": { + "name": "secret_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_key_version": { + "name": "secret_key_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "staged_secret_ciphertext": { + "name": "staged_secret_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "staged_secret_iv": { + "name": "staged_secret_iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "staged_secret_key_version": { + "name": "staged_secret_key_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_secret_hash": { + "name": "previous_secret_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_secret_until": { + "name": "previous_secret_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "inbound_status": { + "name": "inbound_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "inbound_reason": { + "name": "inbound_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbound_observed_at": { + "name": "inbound_observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "management_status": { + "name": "management_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'probe_failed'" + }, + "management_reason": { + "name": "management_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "management_observed_at": { + "name": "management_observed_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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_zone_connections_zone_uq": { + "name": "email_zone_connections_zone_uq", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "zone_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_zone_connections_ingress_uq": { + "name": "email_zone_connections_ingress_uq", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ingress_worker_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_zone_connections_manager_idx": { + "name": "email_zone_connections_manager_idx", + "columns": [ + { + "expression": "manager_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "email_zone_connections_manager_check": { + "name": "email_zone_connections_manager_check", + "value": "(\"email_zone_connections\".\"managed_by\" = 'owner' AND \"email_zone_connections\".\"manager_user_id\" IS NOT NULL) OR (\"email_zone_connections\".\"managed_by\" = 'admin')" + } + }, + "isRLSEnabled": false + }, + "public.email_action_recipients": { + "name": "email_action_recipients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "action_id": { + "name": "action_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient": { + "name": "recipient", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_reference": { + "name": "provider_reference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reserved_at": { + "name": "reserved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempt_started_at": { + "name": "attempt_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_email_action_recipients_project_reserved": { + "name": "idx_email_action_recipients_project_reserved", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reserved_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_email_action_recipients_expiring": { + "name": "idx_email_action_recipients_expiring", + "columns": [ + { + "expression": "reserved_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"email_action_recipients\".\"state\" = 'reserved'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_action_recipients_action_id_email_actions_id_fk": { + "name": "email_action_recipients_action_id_email_actions_id_fk", + "tableFrom": "email_action_recipients", + "tableTo": "email_actions", + "columnsFrom": ["action_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_action_recipients_action_recipient_uq": { + "name": "email_action_recipients_action_recipient_uq", + "nullsNotDistinct": false, + "columns": ["action_id", "recipient"] + } + }, + "policies": {}, + "checkConstraints": { + "email_action_recipients_state_check": { + "name": "email_action_recipients_state_check", + "value": "\"email_action_recipients\".\"state\" IN ('rejected', 'reserved', 'attempt_started', 'provider_accepted', 'failed', 'outcome_unknown', 'cancelled')" + } + }, + "isRLSEnabled": false + }, + "public.email_actions": { + "name": "email_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_domain": { + "name": "sender_domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "receipt_id": { + "name": "receipt_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_digest": { + "name": "payload_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "policy_version": { + "name": "policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_email_actions_project_created": { + "name": "idx_email_actions_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": {} + } + }, + "foreignKeys": { + "email_actions_receipt_id_email_receipts_id_fk": { + "name": "email_actions_receipt_id_email_receipts_id_fk", + "tableFrom": "email_actions", + "tableTo": "email_receipts", + "columnsFrom": ["receipt_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_actions_project_key_uq": { + "name": "email_actions_project_key_uq", + "nullsNotDistinct": false, + "columns": ["project_id", "idempotency_key"] + } + }, + "policies": {}, + "checkConstraints": { + "email_actions_kind_check": { + "name": "email_actions_kind_check", + "value": "\"email_actions\".\"kind\" IN ('direct', 'forward', 'reply')" + } + }, + "isRLSEnabled": false + }, + "public.email_monthly_usage": { + "name": "email_monthly_usage", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "month": { + "name": "month", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_attempts": { + "name": "recipient_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "inbound_receipts": { + "name": "inbound_receipts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_monthly_usage_project_month_uq": { + "name": "email_monthly_usage_project_month_uq", + "nullsNotDistinct": false, + "columns": ["project_id", "month"] + } + }, + "policies": {}, + "checkConstraints": { + "email_monthly_usage_nonnegative_check": { + "name": "email_monthly_usage_nonnegative_check", + "value": "\"email_monthly_usage\".\"recipient_attempts\" >= 0" + }, + "email_monthly_usage_inbound_nonnegative_check": { + "name": "email_monthly_usage_inbound_nonnegative_check", + "value": "\"email_monthly_usage\".\"inbound_receipts\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.email_project_controls": { + "name": "email_project_controls", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "monthly_limit": { + "name": "monthly_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 200 + }, + "burst_limit": { + "name": "burst_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": { + "email_project_controls_project_id_projects_id_fk": { + "name": "email_project_controls_project_id_projects_id_fk", + "tableFrom": "email_project_controls", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_receipts": { + "name": "email_receipts", + "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": "'received'" + }, + "payload_digest": { + "name": "payload_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "final_reject": { + "name": "final_reject", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_email_receipts_project_created": { + "name": "idx_email_receipts_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": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_unresolved_attempts": { + "name": "email_unresolved_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_domain": { + "name": "sender_domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_email_unresolved_attempts_project": { + "name": "idx_email_unresolved_attempts_project", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_email_unresolved_attempts_domain": { + "name": "idx_email_unresolved_attempts_domain", + "columns": [ + { + "expression": "sender_domain", + "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 63d800f45..04a06d852 100644 --- a/platform/packages/api/drizzle/meta/_journal.json +++ b/platform/packages/api/drizzle/meta/_journal.json @@ -386,6 +386,13 @@ "when": 1792665459659, "tag": "0061_email_inbound_mode", "breakpoints": true + }, + { + "idx": 62, + "version": "7", + "when": 1792672482613, + "tag": "0062_project_email_alias", + "breakpoints": true } ] } diff --git a/platform/packages/api/src/db.ts b/platform/packages/api/src/db.ts index 064aca994..4bfffc878 100644 --- a/platform/packages/api/src/db.ts +++ b/platform/packages/api/src/db.ts @@ -26,6 +26,7 @@ export { createProject, getProject, getProjectBySlug, + getProjectByEmailAlias, getOwnedProjectsByUserId, getAccessibleProjectsByUserId, getAccessibleProjectBySlug, diff --git a/platform/packages/api/src/db/projects.ts b/platform/packages/api/src/db/projects.ts index 3edbf9bec..e6b34ade1 100644 --- a/platform/packages/api/src/db/projects.ts +++ b/platform/packages/api/src/db/projects.ts @@ -70,6 +70,12 @@ export async function getProjectBySlug(db: Db, slug: string): Promise { + const [row] = await db.select().from(projects).where(eq(projects.email_alias, alias)).limit(1); + return row ?? null; +} + export async function getOwnedProjectsByUserId( db: Db, userId: string, diff --git a/platform/packages/api/src/deploy/execute.ts b/platform/packages/api/src/deploy/execute.ts index 1eae1e224..a7e6c728f 100644 --- a/platform/packages/api/src/deploy/execute.ts +++ b/platform/packages/api/src/deploy/execute.ts @@ -341,10 +341,14 @@ export async function handleManifestOnlyDeploy(context: Context): Promis projectId, incomingManifest, ); + // Only the slug gates a deploy: an alias rule that fails must not block + // a deploy that would pass without the alias, which cannot be removed. const readiness = await getSharedEmailRecipientReadiness( context.get('db'), context.env, projectId, + false, + project.slug, ); if (readiness?.status !== 'ready') { return context.json( diff --git a/platform/packages/api/src/email/admission.ts b/platform/packages/api/src/email/admission.ts index 8b2e24c39..24166b929 100644 --- a/platform/packages/api/src/email/admission.ts +++ b/platform/packages/api/src/email/admission.ts @@ -22,6 +22,7 @@ import { DEFAULT_EMAIL_MONTHLY_LIMIT, MAX_EMAIL_LIMIT, } from './limits.ts'; +import { projectMailboxNames } from './mailbox.ts'; import { getEmailRecipientPolicy } from './settings.ts'; export type EmailActionState = @@ -386,9 +387,10 @@ export async function admitEmailAction( let senderAllowed = false; let verifiedOnlyTransport = input.transport === 'native_forward'; if (shared) { + const name = input.from.toLowerCase().split('@')[0]!.split('+')[0]!; senderAllowed = !isReservedEmailSlug(project.slug) && - input.from.toLowerCase().split('@')[0]!.split('+')[0] === project.slug.toLowerCase(); + projectMailboxNames(project).some((candidate) => candidate.toLowerCase() === name); } else { const domain = await resolveEmailDomain(tx, senderDomain); senderAllowed = diff --git a/platform/packages/api/src/email/ingress-resolution.ts b/platform/packages/api/src/email/ingress-resolution.ts index 87b6e838e..49d3a93d1 100644 --- a/platform/packages/api/src/email/ingress-resolution.ts +++ b/platform/packages/api/src/email/ingress-resolution.ts @@ -1,4 +1,4 @@ -import { and, eq } from 'drizzle-orm'; +import { and, eq, or } from 'drizzle-orm'; import { Hono, type Context } from 'hono'; import { constantTimeEqual, hmacVerifyState } from '../auth/hmac.ts'; import type { ApiEnv } from '../env.ts'; @@ -202,17 +202,26 @@ app.post('/resolve-ingress', async (context) => { } let projectId = route.project_id; let slug: string; + // The shared-domain name the recipient used: the project's slug or its email alias. + let mailbox: string | undefined; + let alias: string | null = null; if (route.addressing === 'shared') { const local = recipient.slice(0, recipient.lastIndexOf('@')); - const slugPart = local.split('+', 1)[0]; - if (!slugPart) { + const namePart = local.split('+', 1)[0]; + if (!namePart) { return context.json({ error: 'unknown shared recipient' }, 404); } + // Slugs and aliases share one namespace, so at most one project matches. const project = ( await db - .select({ id: projects.id, slug: projects.slug }) + .select({ id: projects.id, slug: projects.slug, alias: projects.email_alias }) .from(projects) - .where(and(eq(projects.slug, slugPart), eq(projects.status, 'active'))) + .where( + and( + or(eq(projects.slug, namePart), eq(projects.email_alias, namePart)), + eq(projects.status, 'active'), + ), + ) .limit(1) )[0]; if (!project) { @@ -220,7 +229,12 @@ app.post('/resolve-ingress', async (context) => { } projectId = project.id; slug = project.slug; - if ((await getSharedEmailRecipientReadiness(db, context.env, projectId))?.status !== 'ready') { + mailbox = namePart; + alias = project.alias; + if ( + (await getSharedEmailRecipientReadiness(db, context.env, projectId, false, namePart)) + ?.status !== 'ready' + ) { return context.json({ error: 'shared recipient routing is not ready' }, 503); } } else { @@ -289,6 +303,9 @@ app.post('/resolve-ingress', async (context) => { return context.json({ projectId, slug, + ...(mailbox ? { mailbox } : {}), + // Replies may come from either name, whichever one the mail arrived at. + ...(alias ? { alias } : {}), deploymentId, workerName: getProjectDeploymentWorkerName(slug, deploymentId), addressing: route.addressing, diff --git a/platform/packages/api/src/email/mailbox.ts b/platform/packages/api/src/email/mailbox.ts new file mode 100644 index 000000000..2072c4bda --- /dev/null +++ b/platform/packages/api/src/email/mailbox.ts @@ -0,0 +1,17 @@ +/** + * Mailbox names on the shared mail domain. A project receives and sends at + * `[+tag]@`, where `` is its slug or, once set, its + * email alias. The slug keeps working after an alias is set, so addresses that + * are already in use do not break. The alias is the address the project shows. + */ +type MailboxProject = Readonly<{ slug: string; email_alias: string | null }>; + +/** Every name that addresses the project, slug first. */ +export function projectMailboxNames(project: MailboxProject): ReadonlyArray { + return project.email_alias ? [project.slug, project.email_alias] : [project.slug]; +} + +/** The name the project shows: its alias when it has one, else its slug. */ +export function projectMailbox(project: MailboxProject): string { + return project.email_alias ?? project.slug; +} diff --git a/platform/packages/api/src/email/shared-recipient-service.ts b/platform/packages/api/src/email/shared-recipient-service.ts index a7ac7ac5f..6bb53eb1b 100644 --- a/platform/packages/api/src/email/shared-recipient-service.ts +++ b/platform/packages/api/src/email/shared-recipient-service.ts @@ -1,5 +1,5 @@ import type { DeployManifest } from '@void/deploy-core'; -import { and, asc, eq, ne, sql } from 'drizzle-orm'; +import { and, asc, eq, ne, notInArray, sql } from 'drizzle-orm'; import type { Db, DbOrTx } from '../db.ts'; import type { ApiEnv } from '../env.ts'; import { generateId } from '../id.ts'; @@ -19,6 +19,7 @@ import { emailInstallationId, inspectEmailInboundRoute, } from './connection-service.ts'; +import { projectMailbox, projectMailboxNames } from './mailbox.ts'; type Bindings = ApiEnv['Bindings']; type Recipient = typeof emailSharedRecipients.$inferSelect; @@ -328,6 +329,53 @@ function ruleConflicts(rule: CfEmailRoutingRule, recipient: string) { }); } +/** + * Read-only check before a permanent email alias is saved: Cloudflare must be + * able to route it. No unrelated rule may own the address or its plus + * addresses, and the shared mail domain needs room for one more rule. + */ +export async function checkSharedEmailAliasRoutable( + db: Db, + env: Bindings, + slug: string, + alias: string, +): Promise { + const shared = await sharedRoute(db, env); + if (!shared) { + return; + } + const recipient = `${alias}@${shared.route.domain}`; + if (recipient.length > 90) { + throw new EmailOperationError( + 'Shared email address exceeds Cloudflare’s 90-character routing-rule limit; choose a shorter alias', + 400, + ); + } + const client = createCfCustomerClient(await credentialFor(env, db, shared.connection)); + const rules = await client.listRoutingRules(shared.connection.zone_id); + if (rules.some((rule) => ruleConflicts(rule, recipient))) { + throw new EmailOperationError( + `A Cloudflare Email Routing rule already routes ${recipient} or one of its plus addresses; choose another alias`, + 409, + ); + } + // A project that does not receive mail yet will need a slug rule too. + const slugRecipient = `${slug}@${shared.route.domain}`; + const needed = rules.some((rule) => rule.addresses.includes(slugRecipient)) ? 1 : 2; + if ( + rules.filter((rule) => + rule.addresses.some((address) => address.endsWith(`@${shared.route.domain}`)), + ).length + + needed > + 200 + ) { + throw new EmailOperationError( + 'Shared mail domain has no room for the alias under Cloudflare’s 200 routing-rule limit; ask a platform administrator to remove unused rules or request a higher limit', + 409, + ); + } +} + async function ownershipName(installation: string, projectId: string, recipient: string) { return `void-shared-${await digest([installation, projectId, recipient])}`; } @@ -409,8 +457,34 @@ export async function reconcileSharedEmailRecipient( if (!project || project.status !== 'active') { return; } - const recipient = `${project.slug}@${shared.route.domain}`; - await removeSharedEmailRecipients(db, env, projectId, recipient); + // One exact rule per mailbox name: the slug, plus the email alias once set. + const recipients = projectMailboxNames(project).map((name) => `${name}@${shared.route.domain}`); + await removeSharedEmailRecipients(db, env, projectId, recipients); + // Try every name before failing: a slug rule that keeps failing must not + // stop the alias row from being created, or the cron, which orders by the + // alias row, would pick this project first on every run. + let failure: { error: unknown } | undefined; + for (const recipient of recipients) { + try { + await reconcileProjectRecipient(db, env, project, shared, recipient, incomingManifest); + } catch (error) { + failure ??= { error }; + } + } + if (failure) { + throw failure.error; + } +} + +async function reconcileProjectRecipient( + db: Db, + env: Bindings, + project: typeof projects.$inferSelect, + shared: NonNullable>>, + recipient: string, + incomingManifest: Pick | undefined, +): Promise { + const projectId = project.id; const name = await ownershipName(shared.connection.installation_id, projectId, recipient); const claimed = await db.transaction(async (tx) => { const [lockedProject] = await tx @@ -422,6 +496,7 @@ export async function reconcileSharedEmailRecipient( !lockedProject || lockedProject.status !== 'active' || lockedProject.slug !== project.slug || + lockedProject.email_alias !== project.email_alias || (!incomingManifest && !(await hasActiveEmailHandlers(tx, projectId))) ) { return null; @@ -437,7 +512,7 @@ export async function reconcileSharedEmailRecipient( rule_name: name, }) .onConflictDoNothing(); - const [row] = await tx + let [row] = await tx .select() .from(emailSharedRecipients) .where( @@ -448,10 +523,42 @@ export async function reconcileSharedEmailRecipient( ), ) .for('update'); + // A removed row for a wanted address comes back: an older runtime, after a + // platform rollback, removes the alias rule it does not know about. + if (row?.status === 'removed') { + const [restored] = await tx + .update(emailSharedRecipients) + .set({ + status: 'pending', + removing: false, + rule_id: null, + create_started_at: null, + reason: null, + updated_at: sql`now()`, + }) + .where(eq(emailSharedRecipients.id, row.id)) + .returning(); + row = restored; + } else if ( + row?.removing && + !( + row.status === 'reconciling' && + Date.parse(row.lease_started_at ?? '') > Date.now() - LEASE_MS + ) + ) { + // A removal that stopped part way, also from an older runtime. Keep the + // recorded rule and creation intent: the checks below adopt a rule that + // still exists and refuse to guess about an unclear creation. + const [restored] = await tx + .update(emailSharedRecipients) + .set({ status: 'pending', removing: false, reason: null, updated_at: sql`now()` }) + .where(eq(emailSharedRecipients.id, row.id)) + .returning(); + row = restored; + } if ( !row || row.removing || - row.status === 'removed' || (row.status === 'reconciling' && Date.parse(row.lease_started_at ?? '') > Date.now() - LEASE_MS) ) { @@ -602,11 +709,17 @@ export async function reconcileSharedEmailRecipient( } } +/** + * Readiness of one mailbox name, or of the whole project when `name` is left + * out: then every name must be ready, and the result names the address the + * project shows unless another one is the problem. + */ export async function getSharedEmailRecipientReadiness( db: Db, env: Bindings, projectId: string, verifyProvider = false, + name?: string, ) { const shared = await sharedRoute(db, env); if (!shared) { @@ -616,7 +729,36 @@ export async function getSharedEmailRecipientReadiness( if (!project) { return null; } - const recipient = `${project.slug}@${shared.route.domain}`; + const names = name ? [name] : projectMailboxNames(project); + const results = []; + for (const candidate of names) { + results.push( + await recipientReadiness( + db, + env, + shared, + project, + `${candidate}@${shared.route.domain}`, + verifyProvider, + ), + ); + } + const shown = `${name ?? projectMailbox(project)}@${shared.route.domain}`; + return ( + results.find((result) => result.status !== 'ready') ?? + results.find((result) => result.recipient === shown)! + ); +} + +async function recipientReadiness( + db: Db, + env: Bindings, + shared: NonNullable>>, + project: typeof projects.$inferSelect, + recipient: string, + verifyProvider: boolean, +) { + const projectId = project.id; const [row] = await db .select() .from(emailSharedRecipients) @@ -684,7 +826,7 @@ export async function removeSharedEmailRecipients( db: Db, env: Bindings, projectId: string, - exceptRecipient?: string, + keepRecipients: ReadonlyArray = [], ): Promise { const rows = await db .select() @@ -693,7 +835,9 @@ export async function removeSharedEmailRecipients( and( eq(emailSharedRecipients.project_id, projectId), ne(emailSharedRecipients.status, 'removed'), - exceptRecipient ? ne(emailSharedRecipients.recipient, exceptRecipient) : undefined, + keepRecipients.length > 0 + ? notInArray(emailSharedRecipients.recipient, [...keepRecipients]) + : undefined, ), ); for (const initial of rows) { @@ -805,13 +949,15 @@ export async function reconcileSharedEmailRecipients(db: Db, env: Bindings, limi eq(deployments.status, 'live'), ), ) + // Order by the shown name's row, so a project whose new alias has no rule + // yet comes first. Each run reconciles every name of the project. .leftJoin( emailSharedRecipients, and( eq(emailSharedRecipients.project_id, projects.id), eq( emailSharedRecipients.recipient, - sql`${projects.slug} || '@' || ${env.EMAIL_SENDER_DOMAIN!.toLowerCase()}`, + sql`coalesce(${projects.email_alias}, ${projects.slug}) || '@' || ${env.EMAIL_SENDER_DOMAIN!.toLowerCase()}`, ), ), ) diff --git a/platform/packages/api/src/project-access.ts b/platform/packages/api/src/project-access.ts index 5bad26b0b..d8678f9ff 100644 --- a/platform/packages/api/src/project-access.ts +++ b/platform/packages/api/src/project-access.ts @@ -34,12 +34,13 @@ export const PROJECT_PERMISSION_MESSAGES = { 'Only the project owner can manage deploy tokens. Ask the owner to create or manage a token.', 'database-url': 'Only the project owner can view the database connection URL. Ask the owner to share the connection details you need.', + 'email-alias': 'Only the project owner can set the project email alias. Ask the owner to set it.', 'owner-suspended': "This project's owner account is suspended. Ask the owner to contact the platform administrator to restore access.", 'not-found': 'This project could not be found. Check the project ID or refresh your project list.', } as const satisfies Record< - ProjectCapability | 'tokens' | 'database-url' | 'owner-suspended' | 'not-found', + ProjectCapability | 'tokens' | 'database-url' | 'email-alias' | 'owner-suspended' | 'not-found', string >; diff --git a/platform/packages/api/src/project-names.ts b/platform/packages/api/src/project-names.ts new file mode 100644 index 000000000..5774ce27f --- /dev/null +++ b/platform/packages/api/src/project-names.ts @@ -0,0 +1,93 @@ +import { PLATFORM_HEALTH_HOSTNAME_LABEL } from '@void/platform'; +import { EMAIL_RESERVED_SLUGS } from './reserved-slugs.ts'; + +/** + * Slugs no project may claim. Three overlapping reasons: + * + * - hostname collisions on `*.void.app` (`www`, `api`, `dash`, `cname`, …), + * including the hostname dispatch keeps for platform health probes; + * - EMAIL sender identity: `EMAIL_RESERVED_SLUGS` (`../reserved-slugs.ts`). + * A project's outbound `from` is `[+tag]@`, + * so owning a slug owns that mailbox, and a user registering `noreply` + * could send mail indistinguishable from the platform's own. That set + * is folded in here rather than listed twice: this check only reaches + * NEW projects, so the email path enforces the same set on its own + * against a project that registered the slug before it was reserved; + * - certificate-authority domain-validation mailboxes on the shared mail + * domain (`admin`, `administrator`, `hostmaster`, `postmaster`, + * `webmaster`): a project receiving one could pass a CA email check and + * get a certificate for that domain. Reserved for new projects only. + */ +export const RESERVED_SLUGS = new Set([ + ...EMAIL_RESERVED_SLUGS, + PLATFORM_HEALTH_HOSTNAME_LABEL, + 'admin', + 'administrator', + 'api', + 'app', + 'assets', + 'auth', + 'billing', + 'blog', + 'cdn', + 'cname', + 'dash', + 'dashboard', + 'docs', + 'ftp', + 'gateway', + 'help', + 'hostmaster', + 'imap', + 'login', + 'mail', + 'ns1', + 'ns2', + 'ns3', + 'ns4', + 'oauth', + 'pop', + 'proxy', + 'register', + 'signup', + 'smtp', + 'sso', + 'staging', + 'static', + 'status', + 'support', + 'webmaster', + 'www', +]); + +/** + * Longest slug a project may claim. The slug is an EMAIL local-part prefix: + * the runtime's default sender is `+noreply@` and + * inbound mail arrives at `+@`. RFC 5321 § 4.5.3.1.1 caps a + * local-part at 64 octets and `+noreply` takes 8 of them, so a slug over 56 + * has no working default sender (the runtime's `VALID_SLUG` refuses it and + * `sendEmail` returns `INVALID_FROM`). 56 also sits inside the 63-octet DNS + * label `.void.app` needs, which nothing else enforced either. + */ +export const MAX_SLUG_LENGTH = 56; + +const PROJECT_NAME_SHAPE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/; + +/** + * The shape, length and reserved-name rules for a project slug, which an email + * alias shares because both name a mailbox on the shared mail domain. Returns + * the refusal message, or null when `name` is allowed. Short names also need + * the owner's plan to allow them; callers check that. + */ +export function projectNameError(name: string, label: 'slug' | 'alias'): string | null { + if (!PROJECT_NAME_SHAPE.test(name)) { + return `${label} must be lowercase alphanumeric with dashes`; + } + if (name.length > MAX_SLUG_LENGTH) { + return `${label} must be at most ${MAX_SLUG_LENGTH} characters`; + } + if (RESERVED_SLUGS.has(name) || name.startsWith('preview-')) { + return 'this name is reserved'; + } + return null; +} diff --git a/platform/packages/api/src/routes/email.ts b/platform/packages/api/src/routes/email.ts index 647d3cb7f..66b113828 100644 --- a/platform/packages/api/src/routes/email.ts +++ b/platform/packages/api/src/routes/email.ts @@ -1,12 +1,16 @@ -import { and, asc, eq, ne, sql } from 'drizzle-orm'; +import { and, asc, eq, isNull, ne, sql } from 'drizzle-orm'; import { Hono } from 'hono'; import type { Context } from 'hono'; +import type { ContentfulStatusCode } from 'hono/utils/http-status'; import { signConfirmToken, type ConfirmTokenPayload } from '../auth/confirm-token.ts'; import { capabilityMiddleware } from '../capabilities.ts'; import { deleteEmailDestination, getEmailDestinationByAddress, getEmailDestinationsByProjectId, + getProject, + getProjectByEmailAlias, + getProjectBySlug, getUser, promoteEmailDestinationWithOwnerAudit, queueUnreferencedEmailDestinationCleanup, @@ -17,6 +21,7 @@ import { } from '../db.ts'; import { getProjectEmailUsage, getProjectEmailLogs } from '../email/admission.ts'; import { emailDomainRoutes, emailZoneConnections } from '../email/connection-schema.ts'; +import { EmailOperationError } from '../email/connection-service.ts'; import { addEmailDomainForProject, getEmailDomainStatusForProject, @@ -32,14 +37,21 @@ import { getEmailRecipientPolicy, repairEmailRecipientPolicyMirror, } from '../email/settings.ts'; -import { getSharedEmailRecipientReadiness } from '../email/shared-recipient-service.ts'; +import { + checkSharedEmailAliasRoutable, + getSharedEmailRecipientReadiness, + reconcileSharedEmailRecipient, +} from '../email/shared-recipient-service.ts'; import type { ApiEnv } from '../env.ts'; -import { isForeignKeyViolation } from '../pg-errors.ts'; +import { hasSqlState, isForeignKeyViolation } from '../pg-errors.ts'; +import { resolvePlan } from '../plans.ts'; import { + PROJECT_PERMISSION_MESSAGES, ProjectAccessDeniedError, ProjectNotFoundError, projectAccessErrorBody, } from '../project-access.ts'; +import { projectNameError } from '../project-names.ts'; import { runEmailDestinationCleanup } from '../provisioning/email-destination-cleanup.ts'; import { addDestination, @@ -47,6 +59,7 @@ import { listDestinations, type CfDestinationAddress, } from '../provisioning/email-routing.ts'; +import { projects } from '../schema.ts'; import type { EmailDestination } from '../types.ts'; import { requireProjectRouteCapability } from './project-access.ts'; @@ -544,10 +557,109 @@ const app = new Hono() return context.json({ domains: { management, projectSlug: access.project.slug }, recipientPolicy: { mode: recipientPolicy.mode, domains: [...recipientPolicy.domains] }, + emailAlias: access.project.email_alias, + // Set even when the platform has no shared inbound route: projects can + // still send from the slug or alias on it. + sharedDomain: context.env.EMAIL_SENDER_DOMAIN?.toLowerCase() ?? null, sharedInbound, }); }) + // Give the project a shorter name on the shared mail domain. The alias is set + // once and never changes, so reply addresses built on it keep working. The + // slug keeps sending and receiving too. + .put('/:id/email/alias', async (context) => { + const projectId = context.req.param('id'); + const db = context.get('db'); + const access = await requireProjectRouteCapability(context, projectId, 'settings'); + if (access instanceof Response) { + return access; + } + if (access.role !== 'owner') { + return context.json( + { error: 'forbidden', message: PROJECT_PERMISSION_MESSAGES['email-alias'] }, + 403, + ); + } + if (!context.env.EMAIL_SENDER_DOMAIN) { + return context.json({ error: 'this platform has no shared mail domain' }, 409); + } + let body: { alias?: unknown }; + try { + body = await context.req.json(); + } catch { + return context.json({ error: 'alias is required' }, 400); + } + const alias = typeof body.alias === 'string' ? body.alias : ''; + if (!alias) { + return context.json({ error: 'alias is required' }, 400); + } + const nameError = projectNameError(alias, 'alias'); + if (nameError) { + return context.json({ error: nameError }, 400); + } + // A retry of a request that already applied gets the same answer. + if (access.project.email_alias === alias) { + return aliasResponse(context, alias); + } + if (access.project.email_alias) { + return context.json( + { + error: `this project already has the email alias ${access.project.email_alias}; it cannot be changed`, + }, + 409, + ); + } + if (alias === access.project.slug) { + return context.json({ error: 'alias must differ from the project slug' }, 400); + } + // The same plan rule as short project slugs. + if (alias.length <= 5 && !(await resolvePlan(db, access.owner.plan)).allowShortSlugs) { + return context.json( + { error: 'the selected plan does not permit short names (5 characters or fewer)' }, + 403, + ); + } + if ((await getProjectBySlug(db, alias)) ?? (await getProjectByEmailAlias(db, alias))) { + return context.json({ error: 'this name is already taken' }, 409); + } + try { + await checkSharedEmailAliasRoutable(db, context.env, access.project.slug, alias); + } catch (error) { + if (error instanceof EmailOperationError) { + return context.json({ error: error.message }, error.status as ContentfulStatusCode); + } + throw error; + } + let saved: boolean; + try { + saved = await db.transaction(async (tx) => { + const rows = await tx + .update(projects) + .set({ email_alias: alias }) + .where( + and( + eq(projects.id, projectId), + eq(projects.status, 'active'), + isNull(projects.email_alias), + ), + ) + .returning({ id: projects.id }); + return rows.length === 1; + }); + } catch (error) { + // The trigger and unique index refuse a name another project claimed meanwhile. + if (hasSqlState(error, '23505')) { + return context.json({ error: 'this name is already taken' }, 409); + } + throw error; + } + if (!saved && (await getProject(db, projectId))?.email_alias !== alias) { + return context.json({ error: 'the project changed; reload it and try again' }, 409); + } + return aliasResponse(context, alias); + }) + .get('/:id/email/domains', async (context) => { const projectId = context.req.param('id'); const access = await requireProjectRouteCapability(context, projectId, 'read'); @@ -692,6 +804,25 @@ export function parseCounter(raw: string | null): number { * Routing — we just want to keep obviously-bad input out of the rate * limiter / CF API call. */ +/** + * Answer for a saved alias. Creates the alias rule now when the project + * already receives mail; the scheduled repair retries a failure. + */ +async function aliasResponse(context: Context, alias: string): Promise { + const db = context.get('db'); + const projectId = context.req.param('id')!; + try { + await reconcileSharedEmailRecipient(db, context.env, projectId); + } catch (error) { + console.error('[email-alias] shared recipient reconcile failed', { projectId, error }); + } + return context.json({ + emailAlias: alias, + address: `${alias}@${context.env.EMAIL_SENDER_DOMAIN!.toLowerCase()}`, + sharedInbound: await getSharedEmailRecipientReadiness(db, context.env, projectId), + }); +} + function isValidEmail(s: string): boolean { return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s); } diff --git a/platform/packages/api/src/routes/oauth.ts b/platform/packages/api/src/routes/oauth.ts index bd86c057f..4b77afcde 100644 --- a/platform/packages/api/src/routes/oauth.ts +++ b/platform/packages/api/src/routes/oauth.ts @@ -26,10 +26,11 @@ import { platformBrowserPage, platformBrowserError } from '../browser-page.ts'; import { getPlatformCapabilities } from '../capabilities.ts'; import { consumeCliAuthCode, getProjectBySlug, getUser } from '../db.ts'; import { isDevBypass, type ApiEnv } from '../env.ts'; +import { MAX_SLUG_LENGTH } from '../project-names.ts'; import { oauthClients, oauthGrants, oauthRequests } from '../schema.ts'; import { isAdminSuspended } from '../suspension.ts'; import previews from './previews.ts'; -import { createUserProject, MAX_SLUG_LENGTH } from './projects.ts'; +import { createUserProject } from './projects.ts'; const app = new Hono(); const FLOW_SECONDS = 300; diff --git a/platform/packages/api/src/routes/projects.ts b/platform/packages/api/src/routes/projects.ts index c4f0c629e..c5660b336 100644 --- a/platform/packages/api/src/routes/projects.ts +++ b/platform/packages/api/src/routes/projects.ts @@ -1,6 +1,5 @@ import { tokenizeSql } from '@void/deploy-core'; import type { ProjectZeroTrustRouting } from '@void/edge'; -import { PLATFORM_HEALTH_HOSTNAME_LABEL } from '@void/platform'; import { sql as drizzleSql } from 'drizzle-orm'; import { Hono, type Context } from 'hono'; import { deployTokenTtlSecondsForBuildMinutes, signPrerenderToken } from '../auth/deploy-token.ts'; @@ -22,6 +21,7 @@ import { getBuild, getProjectBuilds, getProjectBySlug, + getProjectByEmailAlias, getAccessibleProjectsByUserId, getAccessibleProjectBySlug, getLatestDeploymentsByProjectIds, @@ -61,8 +61,10 @@ import { validateSubmittedExecutionPlans } from '../migrations/execution-plan.ts import { ensureTrackingTable, queryD1, runRemoteMigrations } from '../migrations/remote-runner.ts'; import { getProjectDeploymentWorkerName, getProjectQueueName } from '../naming.ts'; import { LOG_ROW_GROUP_BY } from '../observability/log-rows.ts'; +import { hasSqlState } from '../pg-errors.ts'; import { resolvePlan, resolvePlanLimits } from '../plans.ts'; import { PROJECT_PERMISSION_MESSAGES } from '../project-access.ts'; +import { MAX_SLUG_LENGTH, projectNameError } from '../project-names.ts'; import { encryptProjectSecret, decryptProjectSecret } from '../project-secrets.ts'; import { ProjectRequiresNormalizationError, requireNormalizedProject } from '../project-state.ts'; import { projectWithUrl } from '../project-url.ts'; @@ -93,7 +95,6 @@ import { proxyTokenRotationWarnings, requestRotationIfReplacingUntrustedCode, } from '../proxy-token-rotation.ts'; -import { EMAIL_RESERVED_SLUGS } from '../reserved-slugs.ts'; import { projectRouteKey } from '../routing.ts'; import { buildRoutingJson, @@ -115,76 +116,6 @@ import { rollbackCfDestination } from './email.ts'; import { requireProjectRouteCapability } from './project-access.ts'; import { getRequestsDataset, queryAnalyticsEngine, queryProjectRequestOverview } from './usage.ts'; -/** - * Slugs no project may claim. Three overlapping reasons: - * - * - hostname collisions on `*.void.app` (`www`, `api`, `dash`, `cname`, …), - * including the hostname dispatch keeps for platform health probes; - * - EMAIL sender identity: `EMAIL_RESERVED_SLUGS` (`../reserved-slugs.ts`). - * A project's outbound `from` is `[+tag]@`, - * so owning a slug owns that mailbox, and a user registering `noreply` - * could send mail indistinguishable from the platform's own. That set - * is folded in here rather than listed twice: this check only reaches - * NEW projects, so the email path enforces the same set on its own - * against a project that registered the slug before it was reserved; - * - certificate-authority domain-validation mailboxes on the shared mail - * domain (`admin`, `administrator`, `hostmaster`, `postmaster`, - * `webmaster`): a project receiving one could pass a CA email check and - * get a certificate for that domain. Reserved for new projects only. - */ -export const RESERVED_SLUGS = new Set([ - ...EMAIL_RESERVED_SLUGS, - PLATFORM_HEALTH_HOSTNAME_LABEL, - 'admin', - 'administrator', - 'api', - 'app', - 'assets', - 'auth', - 'billing', - 'blog', - 'cdn', - 'cname', - 'dash', - 'dashboard', - 'docs', - 'ftp', - 'gateway', - 'help', - 'hostmaster', - 'imap', - 'login', - 'mail', - 'ns1', - 'ns2', - 'ns3', - 'ns4', - 'oauth', - 'pop', - 'proxy', - 'register', - 'signup', - 'smtp', - 'sso', - 'staging', - 'static', - 'status', - 'support', - 'webmaster', - 'www', -]); - -/** - * Longest slug a project may claim. The slug is an EMAIL local-part prefix: - * the runtime's default sender is `+noreply@` and - * inbound mail arrives at `+@`. RFC 5321 § 4.5.3.1.1 caps a - * local-part at 64 octets and `+noreply` takes 8 of them, so a slug over 56 - * has no working default sender (the runtime's `VALID_SLUG` refuses it and - * `sendEmail` returns `INVALID_FROM`). 56 also sits inside the 63-octet DNS - * label `.void.app` needs, which nothing else enforced either. - */ -export const MAX_SLUG_LENGTH = 56; - const DESTRUCTIVE_PATTERNS = [ /\bDROP\s+TABLE\b/i, /\bDROP\s+VIEW\b/i, @@ -3422,19 +3353,12 @@ export async function createUserProject( return context.json({ error: 'slug is required' }, 400); } - // Validate slug format: lowercase alphanumeric + dashes, no leading/trailing dashes - if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(body.slug)) { - return context.json({ error: 'slug must be lowercase alphanumeric with dashes' }, 400); - } - - // See MAX_SLUG_LENGTH: the slug has to fit in front of `+noreply@` in a - // 64-octet SMTP local-part. The CLI treats this 400 as re-promptable. - if (body.slug.length > MAX_SLUG_LENGTH) { - return context.json({ error: `slug must be at most ${MAX_SLUG_LENGTH} characters` }, 400); - } - - if (RESERVED_SLUGS.has(body.slug) || body.slug.startsWith('preview-')) { - return context.json({ error: 'this name is reserved' }, 400); + // Lowercase alphanumeric with dashes, short enough to fit in front of `+noreply@` + // in a 64-octet SMTP local-part, and not reserved. The CLI treats this 400 as + // re-promptable. + const nameError = projectNameError(body.slug, 'slug'); + if (nameError) { + return context.json({ error: nameError }, 400); } // Preserve short-slug eligibility when an administrator copies or renames a plan. @@ -3447,8 +3371,11 @@ export async function createUserProject( } } - // Check for slug uniqueness - const existing = await getProjectBySlug(context.get('db'), body.slug); + // A slug is also a mailbox on the shared mail domain, so it may not equal + // another project's email alias either. + const existing = + (await getProjectBySlug(context.get('db'), body.slug)) ?? + (await getProjectByEmailAlias(context.get('db'), body.slug)); if (existing) { return context.json({ error: 'slug already taken' }, 409); } @@ -3540,11 +3467,9 @@ export async function createUserProject( err.status, ); } - // Unique constraint violation — slug was taken between check and insert - if ( - err instanceof Error && - err.message.includes('duplicate key value violates unique constraint') - ) { + // Unique constraint violation: the slug, or an alias with that name, was + // taken between check and insert. + if (hasSqlState(err, '23505')) { return context.json({ error: 'slug already taken' }, 409); } throw err; diff --git a/platform/packages/api/src/schema.ts b/platform/packages/api/src/schema.ts index 62c07a147..f6b9cae27 100644 --- a/platform/packages/api/src/schema.ts +++ b/platform/packages/api/src/schema.ts @@ -372,11 +372,17 @@ export const projects = pgTable( // Highest proxy token generation issued for this project. 0 = legacy token. proxy_token_gen: integer('proxy_token_gen').notNull().default(0), status: text('status').notNull().default('active').$type<'active' | 'deleting'>(), + // A second mailbox name on the shared mail domain, set once by the owner. It + // shares one namespace with slugs; a database trigger keeps them apart. + email_alias: text('email_alias'), created_at: timestamp('created_at', { mode: 'string' }).notNull().defaultNow(), }, (table) => [ unique().on(table.user_id, table.slug), uniqueIndex('idx_projects_slug').on(table.slug), + uniqueIndex('idx_projects_email_alias') + .on(table.email_alias) + .where(sql`${table.email_alias} IS NOT NULL`), index('idx_projects_user_id').on(table.user_id), uniqueIndex('projects_preview_workspace').on( table.preview_client_id, diff --git a/platform/packages/api/test/integration/api-email.test.ts b/platform/packages/api/test/integration/api-email.test.ts index e01f090fd..fd0357e67 100644 --- a/platform/packages/api/test/integration/api-email.test.ts +++ b/platform/packages/api/test/integration/api-email.test.ts @@ -43,12 +43,21 @@ import { revokeOwnerVerifiedDestinationsForUserChange } from '../../src/db.ts'; import * as dbModule from '../../src/db.ts'; import { emailMonthlyUsage } from '../../src/email/admission-schema.ts'; import { setProjectEmailLimits, setProjectEmailSuspended } from '../../src/email/admission.ts'; +import { emailSharedRecipients } from '../../src/email/connection-schema.ts'; +import { + ensureSharedEmailConnection, + resolveEmailDomain, + runEmailOperation, +} from '../../src/email/connection-service.ts'; +import type { ApiEnv } from '../../src/env.ts'; import app from '../../src/index.ts'; +import { PROJECT_PERMISSION_MESSAGES } from '../../src/project-access.ts'; import { reconcileEmailDestinationCleanups, runEmailDestinationCleanup, } from '../../src/provisioning/email-destination-cleanup.ts'; import { + deployments, emailDestinationCleanups, emailDestinations, platformSettings, @@ -56,10 +65,12 @@ import { projects, users, } from '../../src/schema.ts'; +import { callsByTag, stubCustomerCf } from './email-domain-cf-stub.ts'; import { cfError, cfSuccess, clearTables, + createTestDeployment, createTestProject, createTestUser, mockCfApi, @@ -2171,6 +2182,8 @@ describe('GET /projects/:id/email/settings', () => { expect(res.status).toBe(200); expect(await res.json()).toEqual({ domains: { management: 'self-serve', projectSlug: 'settings-project' }, + emailAlias: null, + sharedDomain: null, sharedInbound: null, recipientPolicy: { mode: 'verified', domains: [] }, }); @@ -2195,6 +2208,297 @@ describe('GET /projects/:id/email/settings', () => { }); }); +// --------------------------------------------------------------------------- +// PUT /projects/:id/email/alias +// --------------------------------------------------------------------------- + +describe('PUT /projects/:id/email/alias', () => { + async function setAlias( + projectId: string, + jwt: string, + body: unknown, + bindings: TestEnv | ApiEnv['Bindings'] = emailEnv(), + ): Promise { + return app.fetch( + new Request(`http://localhost/projects/${projectId}/email/alias`, { + method: 'PUT', + headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + bindings, + ); + } + + async function storedAlias(projectId: string): Promise { + const [row] = await env.db.select().from(projects).where(eq(projects.id, projectId)); + return row!.email_alias; + } + + async function seedAlias(projectId: string, alias: string): Promise { + await env.db.update(projects).set({ email_alias: alias }).where(eq(projects.id, projectId)); + } + + it('sets the alias for the owner and shows it in the settings', async () => { + const user = await createTestUser(env.db); + const project = await createTestProject(env.db, user.id, 'alias-project'); + // No shared inbound route is connected, so nothing reaches Cloudflare. + const fetchMock = mockCfApi(); + + const res = await setAlias(project.id, user.jwt, { alias: 'support-desk' }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + emailAlias: 'support-desk', + address: `support-desk@${MAIL_DOMAIN}`, + sharedInbound: null, + }); + expect(await storedAlias(project.id)).toBe('support-desk'); + expect(fetchMock).not.toHaveBeenCalled(); + + const settings = await app.fetch( + new Request(`http://localhost/projects/${project.id}/email/settings`, { + headers: { Authorization: `Bearer ${user.jwt}` }, + }), + emailEnv(), + ); + expect(settings.status).toBe(200); + expect(await settings.json()).toMatchObject({ + domains: { projectSlug: 'alias-project' }, + emailAlias: 'support-desk', + sharedDomain: MAIL_DOMAIN, + sharedInbound: null, + }); + }); + + it('refuses a non-owner admin with the owner-only message', async () => { + const { admin, project } = await createProjectWithAdmin('alias'); + + const res = await setAlias(project.id, admin.jwt, { alias: 'support-desk' }); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ + error: 'forbidden', + message: PROJECT_PERMISSION_MESSAGES['email-alias'], + }); + expect(await storedAlias(project.id)).toBeNull(); + }); + + it('returns 409 when the platform has no shared mail domain', async () => { + const user = await createTestUser(env.db); + const project = await createTestProject(env.db, user.id); + + // `env` as-is: EMAIL_SENDER_DOMAIN unset. + const res = await setAlias(project.id, user.jwt, { alias: 'support-desk' }, env); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ error: 'this platform has no shared mail domain' }); + expect(await storedAlias(project.id)).toBeNull(); + }); + + it('requires an alias string', async () => { + const user = await createTestUser(env.db); + const project = await createTestProject(env.db, user.id); + + for (const body of [{}, { alias: '' }, { alias: 42 }]) { + const res = await setAlias(project.id, user.jwt, body); + expect(res.status, JSON.stringify(body)).toBe(400); + expect(await res.json()).toEqual({ error: 'alias is required' }); + } + expect(await storedAlias(project.id)).toBeNull(); + }); + + it('applies the slug shape, length and reserved-name rules', async () => { + const user = await createTestUser(env.db); + const project = await createTestProject(env.db, user.id); + + const cases: Array<[string, string]> = [ + ['Support-Desk', 'alias must be lowercase alphanumeric with dashes'], + ['support_desk', 'alias must be lowercase alphanumeric with dashes'], + ['-support', 'alias must be lowercase alphanumeric with dashes'], + [`len-${'a'.repeat(53)}`, 'alias must be at most 56 characters'], + ['postmaster', 'this name is reserved'], + ['preview-x', 'this name is reserved'], + ]; + for (const [alias, error] of cases) { + const res = await setAlias(project.id, user.jwt, { alias }); + expect(res.status, alias).toBe(400); + expect(await res.json()).toEqual({ error }); + } + expect(await storedAlias(project.id)).toBeNull(); + }); + + it('refuses to change an alias once set', async () => { + const user = await createTestUser(env.db); + const project = await createTestProject(env.db, user.id); + await seedAlias(project.id, 'first-alias'); + + const res = await setAlias(project.id, user.jwt, { alias: 'second-alias' }); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ + error: 'this project already has the email alias first-alias; it cannot be changed', + }); + expect(await storedAlias(project.id)).toBe('first-alias'); + }); + + it('answers a retry of the same alias with the saved state', async () => { + const user = await createTestUser(env.db); + const project = await createTestProject(env.db, user.id); + mockCfApi(); + + const first = await setAlias(project.id, user.jwt, { alias: 'first-alias' }); + expect(first.status).toBe(200); + const retry = await setAlias(project.id, user.jwt, { alias: 'first-alias' }); + expect(retry.status).toBe(200); + expect(await retry.json()).toEqual({ + emailAlias: 'first-alias', + address: `first-alias@${MAIL_DOMAIN}`, + sharedInbound: null, + }); + expect(await storedAlias(project.id)).toBe('first-alias'); + }); + + it('refuses the project slug', async () => { + const user = await createTestUser(env.db); + const project = await createTestProject(env.db, user.id, 'alias-project'); + + const res = await setAlias(project.id, user.jwt, { alias: 'alias-project' }); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'alias must differ from the project slug' }); + expect(await storedAlias(project.id)).toBeNull(); + }); + + it('applies the short-name plan rule of project slugs', async () => { + const free = await createTestUser(env.db, 'free-owner'); + const freeProject = await createTestProject(env.db, free.id); + const refused = await setAlias(freeProject.id, free.jwt, { alias: 'desk' }); + expect(refused.status).toBe(403); + expect(await refused.json()).toEqual({ + error: 'the selected plan does not permit short names (5 characters or fewer)', + }); + expect(await storedAlias(freeProject.id)).toBeNull(); + + const paid = await createTestUser(env.db, 'paid-owner', 'github', undefined, 'solo'); + const paidProject = await createTestProject(env.db, paid.id); + const allowed = await setAlias(paidProject.id, paid.jwt, { alias: 'desk' }); + expect(allowed.status).toBe(200); + expect(await storedAlias(paidProject.id)).toBe('desk'); + }); + + it('refuses a name another project uses as its slug or alias', async () => { + const user = await createTestUser(env.db); + const project = await createTestProject(env.db, user.id); + await createTestProject(env.db, user.id, 'taken-slug'); + const aliased = await createTestProject(env.db, user.id); + await seedAlias(aliased.id, 'taken-alias'); + + for (const alias of ['taken-slug', 'taken-alias']) { + const res = await setAlias(project.id, user.jwt, { alias }); + expect(res.status, alias).toBe(409); + expect(await res.json()).toEqual({ error: 'this name is already taken' }); + } + expect(await storedAlias(project.id)).toBeNull(); + }); + + describe('with the shared inbound route connected', () => { + const SHARED_DOMAIN = 'mail.example.com'; + + function sharedEnv(): ApiEnv['Bindings'] { + return { + ...env, + HYPERDRIVE: { + ...env.HYPERDRIVE, + host: 'localhost', + ip: '127.0.0.1', + port: 5432, + user: 'test', + password: 'inert-test-password', + database: 'test', + connect() { + throw new Error('Tests use the injected database'); + }, + }, + CRON_FIRES_QUEUE: { + send: vi.fn(), + sendBatch: vi.fn(), + metrics: async () => ({ backlogCount: 0, backlogBytes: 0 }), + }, + PLATFORM_INSTALLATION_ID: 'installation-shared-test', + EMAIL_GATEWAY_ORIGIN: 'https://email-gateway.example.test', + EMAIL_SENDER_DOMAIN: SHARED_DOMAIN, + EMAIL_SHARED_ACCOUNT_ID: env.CF_ACCOUNT_ID, + EMAIL_SHARED_ZONE_ID: 'shared-zone', + EMAIL_SHARED_INGRESS_NAME: 'native-gateway', + } as ApiEnv['Bindings']; + } + + async function connectShared(config: NonNullable[0]> = {}) { + const provider = stubCustomerCf({ + zones: { + 'example.com': { id: 'shared-zone', name: 'example.com', accountId: env.CF_ACCOUNT_ID }, + }, + mx: { [SHARED_DOMAIN]: ['route1.mx.cloudflare.net'] }, + ...config, + }); + const operation = await ensureSharedEmailConnection(env.db, sharedEnv()); + await runEmailOperation(env.db, sharedEnv(), operation!); + expect((await resolveEmailDomain(env.db, SHARED_DOMAIN))?.route.inbound_status).toBe('ready'); + return provider; + } + + /** A live deployment that declares inbound email handlers. */ + async function deployEmailHandlers(projectId: string): Promise { + const deployment = await createTestDeployment(env.db, projectId); + await env.db + .update(deployments) + .set({ manifest: JSON.stringify({ version: 3, bindings: {}, emailProtocolVersion: 1 }) }) + .where(eq(deployments.id, deployment.id)); + } + + it('refuses an alias a Cloudflare rule already routes and saves nothing', async () => { + const provider = await connectShared({ + routingRules: [ + { + enabled: true, + addresses: [`support-desk+vip@${SHARED_DOMAIN}`], + workerName: 'other-worker', + }, + ], + }); + const user = await createTestUser(env.db); + const project = await createTestProject(env.db, user.id, 'alias-project'); + await deployEmailHandlers(project.id); + + const res = await setAlias(project.id, user.jwt, { alias: 'support-desk' }, sharedEnv()); + expect(res.status).toBe(409); + expect(((await res.json()) as { error: string }).error).toContain( + `already routes support-desk@${SHARED_DOMAIN}`, + ); + expect(await storedAlias(project.id)).toBeNull(); + expect(await env.db.select().from(emailSharedRecipients)).toEqual([]); + expect(callsByTag(provider.calls, 'literalRuleCreate')).toHaveLength(0); + }); + + it('adds an alias rule next to the slug rule when the project receives mail', async () => { + const provider = await connectShared(); + const user = await createTestUser(env.db); + const project = await createTestProject(env.db, user.id, 'alias-project'); + await deployEmailHandlers(project.id); + + const res = await setAlias(project.id, user.jwt, { alias: 'support-desk' }, sharedEnv()); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ + emailAlias: 'support-desk', + address: `support-desk@${SHARED_DOMAIN}`, + sharedInbound: { recipient: `support-desk@${SHARED_DOMAIN}`, status: 'ready' }, + }); + const rows = await env.db.select().from(emailSharedRecipients); + expect(rows.map((row) => row.recipient).sort()).toEqual([ + `alias-project@${SHARED_DOMAIN}`, + `support-desk@${SHARED_DOMAIN}`, + ]); + expect(rows.every((row) => row.status === 'ready')).toBe(true); + expect(callsByTag(provider.calls, 'literalRuleCreate')).toHaveLength(2); + }); + }); +}); + // --------------------------------------------------------------------------- // GET /projects/:id/email/usage // --------------------------------------------------------------------------- diff --git a/platform/packages/api/test/integration/api-projects.test.ts b/platform/packages/api/test/integration/api-projects.test.ts index b7fdc1ed5..09e69f1c3 100644 --- a/platform/packages/api/test/integration/api-projects.test.ts +++ b/platform/packages/api/test/integration/api-projects.test.ts @@ -2151,6 +2151,34 @@ describe('POST /projects', () => { expect(res.status).toBe(409); expect(await res.json()).toEqual({ error: 'slug already taken' }); }); + + // A slug is also a mailbox on the shared mail domain, so it may not take + // the name another project uses as its email alias. + it("rejects a slug equal to another project's email alias", async () => { + const user = await createTestUser(env.db); + const other = await createTestProject(env.db, user.id, 'alias-holder'); + await env.db + .update(projects) + .set({ email_alias: 'taken-alias' }) + .where(eq(projects.id, other.id)); + + const res = await app.fetch( + new Request('http://localhost/projects', { + method: 'POST', + headers: { + Authorization: `Bearer ${user.jwt}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ slug: 'taken-alias' }), + }), + env, + ); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ error: 'slug already taken' }); + expect(await env.db.select().from(projects).where(eq(projects.slug, 'taken-alias'))).toEqual( + [], + ); + }); }); describe('GET /projects/:id/deployments', () => { diff --git a/platform/packages/api/test/integration/email-admission.test.ts b/platform/packages/api/test/integration/email-admission.test.ts index 2a42f2cf4..6318091f5 100644 --- a/platform/packages/api/test/integration/email-admission.test.ts +++ b/platform/packages/api/test/integration/email-admission.test.ts @@ -636,6 +636,45 @@ describe('authoritative email admission', () => { expect((await getProjectEmailUsage(env.db, project.id)).attempts).toBe(1); }); + it('admits a shared sender under the project slug or its email alias only', async () => { + const project = await fixture(); + await env.db + .update(projects) + .set({ email_alias: 'acme-mail' }) + .where(eq(projects.id, project.id)); + const otherUser = await createTestUser(env.db); + const other = await createTestProject(env.db, otherUser.id, 'other'); + await env.db + .update(projects) + .set({ email_alias: 'other-mail' }) + .where(eq(projects.id, other.id)); + + for (const from of [ + 'acme-mail@mail.example.com', + 'acme-mail+news@mail.example.com', + 'acme+news@mail.example.com', + ]) { + const admitted = await admitEmailAction(env.db, { + ...input(project.id, `owned-${from}`), + from, + }); + expect(admitted.recipients).toMatchObject([{ state: 'reserved', errorCode: null }]); + } + for (const from of [ + 'other-mail@mail.example.com', + 'other-mail+news@mail.example.com', + 'acme-mailx@mail.example.com', + 'random+acme-mail@mail.example.com', + ]) { + const refused = await admitEmailAction(env.db, { + ...input(project.id, `refused-${from}`), + from, + }); + expect(refused.recipients).toMatchObject([{ state: 'rejected', errorCode: 'INVALID_FROM' }]); + } + expect((await getProjectEmailUsage(env.db, project.id)).attempts).toBe(3); + }); + it('records unsupported relayed forwarding without charging and retains native verified forwarding', async () => { const project = await fixture(); await env.db.insert(emailCredentials).values({ diff --git a/platform/packages/api/test/integration/email-connections.test.ts b/platform/packages/api/test/integration/email-connections.test.ts index 6165e47e3..fbd53f95f 100644 --- a/platform/packages/api/test/integration/email-connections.test.ts +++ b/platform/packages/api/test/integration/email-connections.test.ts @@ -18,6 +18,7 @@ import { emailDomainRoutes, emailOperationSteps, emailOperations, + emailSharedRecipients, emailZoneConnections, } from '../../src/email/connection-schema.ts'; import { @@ -3362,3 +3363,136 @@ describe('outbound-only email domains', () => { ]); }); }); + +describe('shared mail domain ingress', () => { + function sharedBindings(): ApiEnv['Bindings'] { + return { + ...bindings(), + EMAIL_SENDER_DOMAIN: 'mail.example.com', + EMAIL_SHARED_ACCOUNT_ID: env.CF_ACCOUNT_ID, + EMAIL_SHARED_ZONE_ID: 'platform-mail-zone', + EMAIL_SHARED_INGRESS_NAME: 'void-email-gateway', + } as ApiEnv['Bindings']; + } + + // A shared-domain project with slug `acme`, alias `acme-mail` and one + // recipient row per name, each `ready` unless `statuses` says otherwise. + async function aliasedProject( + statuses: { + slug?: 'ready' | 'pending' | 'blocked'; + alias?: 'ready' | 'pending' | 'blocked'; + } = {}, + ) { + stubCustomerCf({ + zones: { + 'example.com': { + id: 'platform-mail-zone', + name: 'example.com', + accountId: env.CF_ACCOUNT_ID, + }, + }, + mx: { 'mail.example.com': ['route1.mx.cloudflare.net'] }, + }); + const bootstrap = await ensureSharedEmailConnection(env.db, sharedBindings()); + await runEmailOperation(env.db, sharedBindings(), bootstrap!); + const owner = await createTestUser(env.db); + const project = await createTestProject(env.db, owner.id, 'acme'); + await env.db + .update(projects) + .set({ email_alias: 'acme-mail' }) + .where(eq(projects.id, project.id)); + const deployment = await createTestDeployment(env.db, project.id); + await env.db + .update(deployments) + .set({ manifest: JSON.stringify({ emailProtocolVersion: 1 }) }) + .where(eq(deployments.id, deployment.id)); + const { connection, route } = (await resolveEmailDomain(env.db, 'mail.example.com'))!; + for (const [name, status] of [ + ['acme', statuses.slug ?? 'ready'], + ['acme-mail', statuses.alias ?? 'ready'], + ] as const) { + await env.db.insert(emailSharedRecipients).values({ + id: `recipient-${name}`, + installation_id: connection.installation_id, + project_id: project.id, + route_id: route.id, + recipient: `${name}@mail.example.com`, + rule_name: `rule-name-${name}`, + rule_id: `rule-${name}`, + status, + }); + } + return { project, deployment, connection }; + } + + async function resolveIngress( + connection: typeof emailZoneConnections.$inferSelect, + to: string, + ): Promise { + const proof = await hmacSignState( + `email-ingress-v1\n${connection.account_id}\n${connection.zone_id}\n${connection.ingress_worker_name}\n${to}`, + env.EMAIL_SIGNING_SECRET, + ); + return app.fetch( + new Request('https://api/internal/email/resolve-ingress', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-void-internal': proof }, + body: JSON.stringify({ + accountId: connection.account_id, + zoneId: connection.zone_id, + ingressName: connection.ingress_worker_name, + to, + secret: await readConnectionSecret(sharedBindings(), connection), + }), + }), + sharedBindings(), + ); + } + + it.each([ + ['acme-mail@mail.example.com', 'acme-mail'], + ['acme-mail+billing@mail.example.com', 'acme-mail'], + ['acme@mail.example.com', 'acme'], + ['acme+billing@mail.example.com', 'acme'], + ])('resolves %s to the project and the mailbox it used', async (to, mailbox) => { + const { project, deployment, connection } = await aliasedProject(); + const response = await resolveIngress(connection, to); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + projectId: project.id, + slug: 'acme', + mailbox, + alias: 'acme-mail', + deploymentId: deployment.id, + addressing: 'shared', + domain: 'mail.example.com', + }); + }); + + it('checks readiness of the alias row alone when mail uses the alias', async () => { + const { connection } = await aliasedProject({ alias: 'pending' }); + const alias = await resolveIngress(connection, 'acme-mail+billing@mail.example.com'); + expect(alias.status).toBe(503); + expect(await alias.json()).toEqual({ error: 'shared recipient routing is not ready' }); + const slug = await resolveIngress(connection, 'acme@mail.example.com'); + expect(slug.status).toBe(200); + expect(await slug.json()).toMatchObject({ mailbox: 'acme' }); + }); + + it('checks readiness of the slug row alone when mail uses the slug', async () => { + const { connection } = await aliasedProject({ slug: 'blocked' }); + const slug = await resolveIngress(connection, 'acme+billing@mail.example.com'); + expect(slug.status).toBe(503); + expect(await slug.json()).toEqual({ error: 'shared recipient routing is not ready' }); + const alias = await resolveIngress(connection, 'acme-mail@mail.example.com'); + expect(alias.status).toBe(200); + expect(await alias.json()).toMatchObject({ mailbox: 'acme-mail' }); + }); + + it('does not resolve a name that is neither the slug nor the alias', async () => { + const { connection } = await aliasedProject(); + const response = await resolveIngress(connection, 'acme-other@mail.example.com'); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: 'unknown shared recipient' }); + }); +}); diff --git a/platform/packages/api/test/integration/email-shared-recipients.test.ts b/platform/packages/api/test/integration/email-shared-recipients.test.ts index bcfbbcbff..27d94273c 100644 --- a/platform/packages/api/test/integration/email-shared-recipients.test.ts +++ b/platform/packages/api/test/integration/email-shared-recipients.test.ts @@ -17,6 +17,7 @@ import { runEmailOperation, } from '../../src/email/connection-service.ts'; import { + checkSharedEmailAliasRoutable, getSharedEmailRecipientReadiness, reconcileSharedEmailRecipient, reconcileSharedEmailRecipients, @@ -904,3 +905,302 @@ describe('managed shared subdomain recipients', () => { expect(callsByTag(provider.calls, 'routingDns')).toHaveLength(1); }); }); + +async function setEmailAlias(projectId: string, alias: string) { + await env.db.update(projects).set({ email_alias: alias }).where(eq(projects.id, projectId)); +} +async function recipientRows(projectId: string) { + const rows = await env.db + .select() + .from(emailSharedRecipients) + .where(eq(emailSharedRecipients.project_id, projectId)); + return new Map(rows.map((row) => [row.recipient, row])); +} +const slugAddress = 'shared-project@mail.example.com'; +const aliasAddress = 'team-inbox@mail.example.com'; + +describe('project email alias recipients', () => { + it('adds an alias rule while the slug rule stays in place', async () => { + const { project, provider } = await setup(); + await reconcileSharedEmailRecipient(env.db, bindings(), project.id); + const slugRow = await ledger(); + await setEmailAlias(project.id, 'team-inbox'); + await reconcileSharedEmailRecipient(env.db, bindings(), project.id); + const rows = await recipientRows(project.id); + expect(rows.size).toBe(2); + expect(rows.get(slugAddress)).toMatchObject({ + id: slugRow.id, + rule_id: slugRow.rule_id, + status: 'ready', + }); + expect(rows.get(aliasAddress)).toMatchObject({ + status: 'ready', + rule_id: expect.any(String), + rule_name: expect.not.stringMatching(slugRow.rule_name), + }); + expect(callsByTag(provider.calls, 'literalRuleCreate')).toHaveLength(2); + expect(callsByTag(provider.calls, 'literalRuleDelete')).toHaveLength(0); + const addresses = [...provider.routingRuleStates.get('shared-zone')!.values()].map( + (rule) => (rule.matchers as Array<{ value: string }>)[0]!.value, + ); + expect(addresses.sort()).toEqual([slugAddress, aliasAddress].sort()); + }); + + it('recreates a removed alias or slug row on the next reconcile', async () => { + const { project, provider } = await setup(); + await setEmailAlias(project.id, 'team-inbox'); + await reconcileSharedEmailRecipient(env.db, bindings(), project.id); + // An older runtime after a rollback knows only the slug, so it removes the alias rule. + await removeSharedEmailRecipients(env.db, bindings(), project.id, [slugAddress]); + const removed = (await recipientRows(project.id)).get(aliasAddress)!; + expect(removed).toMatchObject({ status: 'removed' }); + expect(callsByTag(provider.calls, 'literalRuleDelete')).toHaveLength(1); + await reconcileSharedEmailRecipient(env.db, bindings(), project.id); + expect((await recipientRows(project.id)).get(aliasAddress)).toMatchObject({ + id: removed.id, + status: 'ready', + removing: false, + rule_id: expect.any(String), + }); + expect(callsByTag(provider.calls, 'literalRuleCreate')).toHaveLength(3); + await removeSharedEmailRecipients(env.db, bindings(), project.id, [aliasAddress]); + expect((await recipientRows(project.id)).get(slugAddress)).toMatchObject({ + status: 'removed', + }); + // The stub numbers new rules by count; an unrelated rule keeps the next + // slug rule from reusing the alias rule's ID. + provider.routingRuleStates.get('shared-zone')!.set('support', { + id: 'support', + name: null, + enabled: true, + matchers: [{ type: 'literal', field: 'to', value: 'support@mail.example.com' }], + actions: [{ type: 'worker', value: ['support-worker'] }], + }); + await reconcileSharedEmailRecipient(env.db, bindings(), project.id); + const rows = await recipientRows(project.id); + expect(rows.get(slugAddress)).toMatchObject({ status: 'ready', removing: false }); + expect(rows.get(aliasAddress)).toMatchObject({ status: 'ready' }); + expect(callsByTag(provider.calls, 'literalRuleCreate')).toHaveLength(4); + }); + + it('reports readiness for one mailbox name or for the whole project', async () => { + const { project } = await setup(); + await setEmailAlias(project.id, 'team-inbox'); + await reconcileSharedEmailRecipient(env.db, bindings(), project.id); + const readiness = (name?: string) => + getSharedEmailRecipientReadiness(env.db, bindings(), project.id, false, name); + expect(await readiness()).toMatchObject({ recipient: aliasAddress, status: 'ready' }); + expect(await readiness('shared-project')).toMatchObject({ + recipient: slugAddress, + status: 'ready', + }); + const block = (recipient: string, status: 'ready' | 'blocked') => + env.db + .update(emailSharedRecipients) + .set({ status, reason: status === 'blocked' ? 'rule drifted' : null }) + .where(eq(emailSharedRecipients.recipient, recipient)); + await block(slugAddress, 'blocked'); + expect(await readiness()).toMatchObject({ + recipient: slugAddress, + status: 'blocked', + reason: 'rule drifted', + }); + expect(await readiness('team-inbox')).toMatchObject({ + recipient: aliasAddress, + status: 'ready', + }); + await block(slugAddress, 'ready'); + await block(aliasAddress, 'blocked'); + expect(await readiness()).toMatchObject({ recipient: aliasAddress, status: 'blocked' }); + expect(await readiness('shared-project')).toMatchObject({ + recipient: slugAddress, + status: 'ready', + }); + expect(await readiness('team-inbox')).toMatchObject({ + recipient: aliasAddress, + status: 'blocked', + }); + }); + + it('lets the scheduled repair pick up a project whose alias has no row yet', async () => { + const { project, user } = await setup(); + const second = await createTestProject(env.db, user.id, 'second-project'); + await deployEmailHandlers(second.id); + await reconcileSharedEmailRecipient(env.db, bindings(), second.id); + // The slug row is now the newest, so only the missing alias row puts this project first. + await reconcileSharedEmailRecipient(env.db, bindings(), project.id); + await setEmailAlias(project.id, 'team-inbox'); + await reconcileSharedEmailRecipients(env.db, bindings(), 1); + expect((await recipientRows(project.id)).get(aliasAddress)).toMatchObject({ + status: 'ready', + }); + expect(await getSharedEmailRecipientReadiness(env.db, bindings(), project.id)).toMatchObject({ + recipient: aliasAddress, + status: 'ready', + }); + }); + + it('checks an alias can be routed before it is saved', async () => { + const { provider } = await setup({ + routingRules: [ + { + id: 'foreign', + enabled: true, + addresses: ['taken-alias+vip@mail.example.com'], + workerName: 'other-worker', + }, + ], + }); + await expect( + checkSharedEmailAliasRoutable(env.db, bindings(), 'shared-project', 'team-inbox'), + ).resolves.toBeUndefined(); + await expect( + checkSharedEmailAliasRoutable(env.db, bindings(), 'shared-project', 'taken-alias'), + ).rejects.toMatchObject({ status: 409, message: expect.stringContaining('already routes') }); + await expect( + checkSharedEmailAliasRoutable(env.db, bindings(), 'shared-project', 'a'.repeat(74)), + ).rejects.toMatchObject({ status: 400, message: expect.stringContaining('90-character') }); + const rules = provider.routingRuleStates.get('shared-zone')!; + for (let index = rules.size; index < 200; index++) { + rules.set(`filler-${index}`, { + id: `filler-${index}`, + name: null, + enabled: true, + matchers: [{ type: 'literal', field: 'to', value: `address-${index}@mail.example.com` }], + actions: [{ type: 'worker', value: ['other-worker'] }], + }); + } + await expect( + checkSharedEmailAliasRoutable(env.db, bindings(), 'shared-project', 'team-inbox'), + ).rejects.toMatchObject({ + status: 409, + message: expect.stringContaining('200 routing-rule limit'), + }); + expect(callsByTag(provider.calls, 'literalRuleCreate')).toHaveLength(0); + }); + + it('keeps room for the slug rule when the project does not receive mail yet', async () => { + const { project, provider } = await setup(); + await expect( + checkSharedEmailAliasRoutable(env.db, bindings(), 'shared-project', 'team-inbox'), + ).resolves.toBeUndefined(); + const rules = provider.routingRuleStates.get('shared-zone')!; + const fill = (count: number) => { + for (let index = 0; rules.size < count; index++) { + rules.set(`filler-${index}`, { + id: `filler-${index}`, + name: null, + enabled: true, + matchers: [{ type: 'literal', field: 'to', value: `address-${index}@mail.example.com` }], + actions: [{ type: 'worker', value: ['other-worker'] }], + }); + } + }; + // 199 rules and no slug rule: the slug and the alias need two more. + fill(199); + await expect( + checkSharedEmailAliasRoutable(env.db, bindings(), 'shared-project', 'team-inbox'), + ).rejects.toMatchObject({ status: 409, message: expect.stringContaining('no room') }); + // Once the slug rule exists (rule 199), one more fits. + rules.delete('filler-0'); + await reconcileSharedEmailRecipient(env.db, bindings(), project.id); + expect(rules.size).toBe(199); + await expect( + checkSharedEmailAliasRoutable(env.db, bindings(), 'shared-project', 'team-inbox'), + ).resolves.toBeUndefined(); + }); + + it('deploys when the slug rule is ready even though the alias rule is blocked', async () => { + const { project, user } = await setup({ + routingRules: [ + { + id: 'foreign', + enabled: true, + addresses: ['team-inbox+vip@mail.example.com'], + workerName: 'other-worker', + }, + ], + }); + await setEmailAlias(project.id, 'team-inbox'); + const response = await app.fetch( + new Request(`http://localhost/projects/${project.id}/deploy`, { + method: 'POST', + headers: { Authorization: `Bearer ${user.jwt}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + manifest: { version: 3, assetManifestVersion: 2, bindings: {}, emailProtocolVersion: 1 }, + assetManifest: {}, + hasWorker: true, + }), + }), + bindings(), + ); + const rows = await recipientRows(project.id); + expect(rows.get(slugAddress)).toMatchObject({ status: 'ready' }); + expect(rows.get(aliasAddress)).toMatchObject({ status: 'blocked' }); + expect(await response.text()).not.toContain('Email Routing rule'); + }); + + it('restores an alias row whose removal stopped part way', async () => { + const { project, provider } = await setup(); + await setEmailAlias(project.id, 'team-inbox'); + await reconcileSharedEmailRecipient(env.db, bindings(), project.id); + const ready = (await recipientRows(project.id)).get(aliasAddress)!; + // An older runtime claimed the alias row for removal, then Cloudflare failed. + await env.db + .update(emailSharedRecipients) + .set({ removing: true, status: 'blocked', reason: 'Cloudflare is unavailable' }) + .where(eq(emailSharedRecipients.id, ready.id)); + await reconcileSharedEmailRecipient(env.db, bindings(), project.id); + expect((await recipientRows(project.id)).get(aliasAddress)).toMatchObject({ + id: ready.id, + status: 'ready', + removing: false, + rule_id: ready.rule_id, + reason: null, + }); + expect(callsByTag(provider.calls, 'literalRuleCreate')).toHaveLength(2); + // The same, after the older runtime had already deleted the rule. + await env.db + .update(emailSharedRecipients) + .set({ removing: true, status: 'blocked', reason: 'lost response' }) + .where(eq(emailSharedRecipients.id, ready.id)); + provider.routingRuleStates.get('shared-zone')!.delete(ready.rule_id!); + await reconcileSharedEmailRecipient(env.db, bindings(), project.id); + expect((await recipientRows(project.id)).get(aliasAddress)).toMatchObject({ + id: ready.id, + status: 'ready', + removing: false, + }); + expect(callsByTag(provider.calls, 'literalRuleCreate')).toHaveLength(3); + }); + + it('leaves a row alone while a removal holds a live lease', async () => { + const { project } = await setup(); + await setEmailAlias(project.id, 'team-inbox'); + await reconcileSharedEmailRecipient(env.db, bindings(), project.id); + const ready = (await recipientRows(project.id)).get(aliasAddress)!; + await env.db + .update(emailSharedRecipients) + .set({ removing: true, status: 'reconciling', lease_started_at: sql`now()` }) + .where(eq(emailSharedRecipients.id, ready.id)); + await reconcileSharedEmailRecipient(env.db, bindings(), project.id); + expect((await recipientRows(project.id)).get(aliasAddress)).toMatchObject({ + removing: true, + status: 'reconciling', + }); + }); + + it('removes both the slug and alias rules when the project is deleted', async () => { + const { project, provider } = await setup(); + await setEmailAlias(project.id, 'team-inbox'); + await reconcileSharedEmailRecipient(env.db, bindings(), project.id); + await env.db.update(projects).set({ status: 'deleting' }).where(eq(projects.id, project.id)); + await removeSharedEmailRecipients(env.db, bindings(), project.id); + const rows = await recipientRows(project.id); + expect(rows.get(slugAddress)).toMatchObject({ status: 'removed' }); + expect(rows.get(aliasAddress)).toMatchObject({ status: 'removed' }); + expect(callsByTag(provider.calls, 'literalRuleDelete')).toHaveLength(2); + expect(provider.routingRuleStates.get('shared-zone')!.size).toBe(0); + await deleteProjectCascade(env.db, project.id); + }); +}); diff --git a/platform/packages/api/test/unit/admin-upgrade-compatibility.test.ts b/platform/packages/api/test/unit/admin-upgrade-compatibility.test.ts index 5fdc0231b..0ae022fcd 100644 --- a/platform/packages/api/test/unit/admin-upgrade-compatibility.test.ts +++ b/platform/packages/api/test/unit/admin-upgrade-compatibility.test.ts @@ -359,3 +359,47 @@ it('upgrades the 1.1.11 through 1.2.1 schema to outbound-only email routes', asy await db.close(); } }, 30_000); + +it('upgrades the 1.1.11 through 1.2.1 schema to project email aliases', async () => { + const root = resolve(import.meta.dirname, '../../drizzle'); + const journal = JSON.parse(readFileSync(resolve(root, 'meta/_journal.json'), 'utf8')) as { + entries: Array<{ idx: number; tag: string }>; + }; + const released = journal.entries.filter((entry) => entry.idx < 61); + expect(journal.entries.find((entry) => entry.idx === 62)?.tag).toBe('0062_project_email_alias'); + const db = new PGlite(); + const project = (id: string, slug: string) => + `INSERT INTO projects (id, user_id, slug) VALUES ('${id}', 'migration-user', '${slug}');`; + try { + for (const entry of released) { + await db.exec(readFileSync(resolve(root, entry.tag + '.sql'), 'utf8')); + } + await db.exec( + "INSERT INTO users (id, login) VALUES ('migration-user', 'migration-user');" + + project('brand', 'brand-feedback'), + ); + await db.exec(readFileSync(resolve(root, '0061_email_inbound_mode.sql'), 'utf8')); + await db.exec(readFileSync(resolve(root, '0062_project_email_alias.sql'), 'utf8')); + expect((await db.query('SELECT slug, email_alias FROM projects')).rows).toEqual([ + { slug: 'brand-feedback', email_alias: null }, + ]); + await db.exec("UPDATE projects SET email_alias = 'bfb' WHERE id = 'brand'"); + // The previous runtime still creates projects and previews without the column. + await db.exec(project('older-runtime', 'older-runtime') + project('preview', 'preview-1')); + // A slug and an alias may not share a name, in either direction or on one project. + await expect(db.exec(project('taken', 'bfb'))).rejects.toThrow( + 'A project slug or email alias already uses this name', + ); + await expect( + db.exec("UPDATE projects SET email_alias = 'older-runtime' WHERE id = 'brand'"), + ).rejects.toThrow('A project slug or email alias already uses this name'); + await expect( + db.exec("UPDATE projects SET email_alias = 'older-runtime' WHERE id = 'older-runtime'"), + ).rejects.toThrow('A project slug or email alias already uses this name'); + await expect( + db.exec("UPDATE projects SET email_alias = 'bfb' WHERE id = 'older-runtime'"), + ).rejects.toThrow('idx_projects_email_alias'); + } finally { + await db.close(); + } +}, 30_000); diff --git a/platform/packages/api/test/unit/reserved-slugs.test.ts b/platform/packages/api/test/unit/reserved-slugs.test.ts index 97d257684..f664be4f4 100644 --- a/platform/packages/api/test/unit/reserved-slugs.test.ts +++ b/platform/packages/api/test/unit/reserved-slugs.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vite-plus/test'; +import { RESERVED_SLUGS } from '../../src/project-names.ts'; import { EMAIL_RESERVED_SLUGS, isReservedEmailSlug } from '../../src/reserved-slugs.ts'; -import { RESERVED_SLUGS } from '../../src/routes/projects.ts'; describe('reserved slugs (real implementation)', () => { it('blocks cname slug (CNAME target)', () => { diff --git a/platform/packages/email-gateway/src/index.ts b/platform/packages/email-gateway/src/index.ts index 3a27bccd6..a771cf5b1 100644 --- a/platform/packages/email-gateway/src/index.ts +++ b/platform/packages/email-gateway/src/index.ts @@ -81,6 +81,8 @@ type Route = { workerName: string; deploymentId: string; slug: string; + mailbox?: string; + alias?: string; domain: string; addressing: 'shared' | 'custom'; limits?: Record; @@ -199,6 +201,8 @@ async function resolveIngress( typeof value.workerName !== 'string' || typeof value.deploymentId !== 'string' || typeof value.slug !== 'string' || + (value.mailbox !== undefined && typeof value.mailbox !== 'string') || + (value.alias !== undefined && typeof value.alias !== 'string') || typeof value.domain !== 'string' || (value.addressing !== 'shared' && value.addressing !== 'custom') ) { diff --git a/platform/packages/email-gateway/test/unit/gateway.test.ts b/platform/packages/email-gateway/test/unit/gateway.test.ts index 4d54237f6..f9bb16594 100644 --- a/platform/packages/email-gateway/test/unit/gateway.test.ts +++ b/platform/packages/email-gateway/test/unit/gateway.test.ts @@ -623,6 +623,63 @@ describe('dedicated email gateway', () => { expect(router.routeInbound).toHaveBeenCalledWith(message, expect.anything(), context, route); }); + it('passes a shared route reached through the project email alias to the native router', async () => { + const aliasRoute = { + ...route, + slug: 'proj', + mailbox: 'alias', + alias: 'alias', + addressing: 'shared', + domain: 'mail.example.com', + }; + const message = { + from: 'sender@example.net', + to: 'alias+orders@mail.example.com', + setReject: vi.fn(), + } as unknown as ForwardableEmailMessage; + await gateway.email( + message, + env(async () => Response.json(aliasRoute)), + context, + ); + expect(message.setReject).not.toHaveBeenCalled(); + expect(router.routeInbound).toHaveBeenCalledWith( + message, + expect.anything(), + context, + aliasRoute, + ); + }); + + it.each([ + ['mailbox', 42], + ['mailbox', null], + ['mailbox', { name: 'alias' }], + ['alias', 42], + ['alias', null], + ])( + 'treats a route with a non-string %s (%j) as a malformed authority response', + async (field, value) => { + const environment = env(async () => + Response.json({ ...route, addressing: 'shared', [field]: value }), + ); + expect((await gateway.fetch(request(new Uint8Array()), environment, context)).status).toBe( + 503, + ); + const message = { + from: 'sender@example.net', + to: 'support@example.com', + setReject: vi.fn(), + } as unknown as ForwardableEmailMessage; + await expect(gateway.email(message, environment, context)).rejects.toThrow( + 'temporarily unavailable', + ); + expect(message.setReject).not.toHaveBeenCalled(); + expect(router.routeInbound).not.toHaveBeenCalled(); + expect(router.routeRelayed).not.toHaveBeenCalled(); + }, + ); + it('probes exact staged ingress identity through the authoritative API', async () => { const body = JSON.stringify({ accountId: 'account-1', diff --git a/platform/packages/proxy/src/email-router.ts b/platform/packages/proxy/src/email-router.ts index 51a1c7b79..1e18f3984 100644 --- a/platform/packages/proxy/src/email-router.ts +++ b/platform/packages/proxy/src/email-router.ts @@ -55,11 +55,23 @@ export interface AuthoritativeEmailRoute { workerName: string; deploymentId: string; slug: string; + /** The shared-domain name the recipient used: the slug or the project's email alias. */ + mailbox?: string; + /** The project's email alias on the shared domain, when it has one. */ + alias?: string; addressing: 'shared' | 'custom'; domain: string; limits?: Record; } +/** Shared-domain names a reply or forward may be sent from: the slug and the email alias. */ +function mailboxNames(route: AuthoritativeEmailRoute): ReadonlyArray { + const names = [route.slug, route.alias, route.mailbox] + .filter((name): name is string => typeof name === 'string') + .map((name) => name.toLowerCase()); + return [...new Set(names)]; +} + function receiptRouteFence(route: AuthoritativeEmailRoute): ReceiptRouteFence { return { routeId: route.routeId, @@ -199,7 +211,8 @@ function forwardHeadersWithinBounds(headers: unknown): headers is Record; domain: string; customDomain: boolean; } @@ -221,7 +234,7 @@ function isPermittedReplySender(from: string, pin: ReplySenderPin): boolean { if (pin.customDomain) { return true; } - return lower.slice(0, at).split('+')[0] === pin.slug; + return pin.names.includes(lower.slice(0, at).split('+')[0]!); } interface OutboundGate { @@ -241,10 +254,12 @@ export async function routeInbound( const to = String(message.to ?? '').toLowerCase(); const local = to.split('@')[0] ?? ''; - // + — split on the first '+'. If there's no '+', the whole - // local-part is the slug and the user worker sees the tag-less address. + // +, where is the slug or the project's email alias. Split + // on the first '+'. If there's no '+', the whole local-part is the name and + // the user worker sees the tag-less address. const plus = local.indexOf('+'); const slug = plus === -1 ? local : local.slice(0, plus); + const mailbox = (authoritative.mailbox ?? authoritative.slug).toLowerCase(); // Domain CF pinned when it accepted this message. It is the expected // domain for any `reply` sender — see `ReplySenderPin`. const atIdx = to.indexOf('@'); @@ -266,7 +281,7 @@ export async function routeInbound( !authoritative.workerName || !authoritative.deploymentId || authoritative.domain?.toLowerCase() !== receivedDomain || - (authoritative.addressing === 'shared' && authoritative.slug.toLowerCase() !== slug) + (authoritative.addressing === 'shared' && mailbox !== slug) ) { message.setReject(`${label}: authoritative route mismatch`); return; @@ -364,7 +379,7 @@ export async function routeInbound( try { await enactActions(env, message, actions, gate, { customDomain: authoritative.addressing === 'custom', - slug: authoritative.slug.toLowerCase(), + names: mailboxNames(authoritative), domain: receivedDomain, }); } catch { @@ -451,7 +466,7 @@ export async function routeRelayed( try { await enactActions(env, relayed.message, actions, gate, { customDomain: authoritative.addressing === 'custom', - slug: slug.toLowerCase(), + names: mailboxNames(authoritative), domain, }); } catch { @@ -781,7 +796,7 @@ async function validateActions( // Check who the reply claims to be before asking the API to admit it. if (!isPermittedReplySender(action.from, replySender)) { console.error( - `[email-router] reply dropped: sender not owned by project "${sanitize(replySender.slug, 64)}" (from=${logAddress(action.from)})`, + `[email-router] reply dropped: sender not owned by project "${sanitize(replySender.names[0] ?? '', 64)}" (from=${logAddress(action.from)})`, ); return null; } diff --git a/platform/packages/proxy/test/unit/email-router.test.ts b/platform/packages/proxy/test/unit/email-router.test.ts index c99285272..4098398c6 100644 --- a/platform/packages/proxy/test/unit/email-router.test.ts +++ b/platform/packages/proxy/test/unit/email-router.test.ts @@ -425,6 +425,139 @@ describe('routeInbound — authoritative routing', () => { }); }); +// ----------------------------------------------------------------------- +// Project email alias: the shared-domain name may be the slug or the alias. +// ----------------------------------------------------------------------- + +describe('routeInbound — project email alias', () => { + /** The gateway route for project `proj` reached through `mailbox`. */ + function aliasRoute(mailbox?: string): Parameters[3] { + return { + ...ROUTE_FENCE, + projectId: 'p_test', + workerName: 'user-worker', + deploymentId: 'v1', + slug: 'proj', + ...(mailbox === undefined ? {} : { mailbox }), + addressing: 'shared', + domain: PROD_DOMAIN, + }; + } + + it('dispatches alias+tag@domain when the route mailbox is the alias', async () => { + const { env, emailFn } = makeEnv({ emailReturn: [] }); + const { message, rejectCalls } = createFakeMessage({ to: `alias+x@${PROD_DOMAIN}` }); + const { ctx } = createCtx(); + await routeInbound(message, env, ctx, aliasRoute('alias')); + expect(rejectCalls).toHaveLength(0); + expect(emailFn).toHaveBeenCalledTimes(1); + // The user worker sees the recipient as sent; the framework strips the name. + const envelope = emailFn.mock.calls[0]?.[0] as { to: string }; + expect(envelope.to).toBe(`alias+x@${PROD_DOMAIN}`); + }); + + it('compares the route mailbox case-insensitively', async () => { + const { env, emailFn } = makeEnv({ emailReturn: [] }); + const { message, rejectCalls } = createFakeMessage({ to: `Alias+x@${PROD_DOMAIN}` }); + const { ctx } = createCtx(); + await routeInbound(message, env, ctx, aliasRoute('ALIAS')); + expect(rejectCalls).toHaveLength(0); + expect(emailFn).toHaveBeenCalledTimes(1); + }); + + it('rejects when the route mailbox is not the recipient prefix', async () => { + const { env, dispatchGet } = makeEnv(); + const { message, rejectCalls } = createFakeMessage({ to: `alias+x@${PROD_DOMAIN}` }); + const { ctx } = createCtx(); + await routeInbound(message, env, ctx, aliasRoute('other')); + expect(rejectCalls[0]).toContain('authoritative route mismatch'); + expect(dispatchGet).not.toHaveBeenCalled(); + }); + + it('rejects the slug address when the route says the alias was used', async () => { + const { env, dispatchGet } = makeEnv(); + const { message, rejectCalls } = createFakeMessage({ to: `proj+x@${PROD_DOMAIN}` }); + const { ctx } = createCtx(); + await routeInbound(message, env, ctx, aliasRoute('alias')); + expect(rejectCalls[0]).toContain('authoritative route mismatch'); + expect(dispatchGet).not.toHaveBeenCalled(); + }); + + it('without a mailbox, compares the recipient prefix with the slug', async () => { + const { env, emailFn, dispatchGet } = makeEnv({ emailReturn: [] }); + const { ctx } = createCtx(); + + const viaSlug = createFakeMessage({ to: `proj+x@${PROD_DOMAIN}` }); + await routeInbound(viaSlug.message, env, ctx, aliasRoute()); + expect(viaSlug.rejectCalls).toHaveLength(0); + expect(emailFn).toHaveBeenCalledTimes(1); + + const viaAlias = createFakeMessage({ to: `alias+x@${PROD_DOMAIN}` }); + await routeInbound(viaAlias.message, env, ctx, aliasRoute()); + expect(viaAlias.rejectCalls[0]).toContain('authoritative route mismatch'); + expect(dispatchGet).toHaveBeenCalledTimes(1); + }); + + it('enacts replies sent from either the slug or the alias', async () => { + const fromSlug = `proj+support@${PROD_DOMAIN}`; + const fromAlias = `alias+support@${PROD_DOMAIN}`; + const { env } = makeEnv({ + emailReturn: [ + { type: 'reply', from: fromSlug, to: 'admin@example.com', raw: replyRaw(fromSlug) }, + { type: 'reply', from: fromAlias, to: 'admin@example.com', raw: replyRaw(fromAlias) }, + ], + }); + const { message, replyCalls } = createFakeMessage({ to: `alias+x@${PROD_DOMAIN}` }); + const { ctx } = createCtx(); + await routeInbound(message, env, ctx, aliasRoute('alias')); + expect(replyCalls.map((r) => r.from)).toEqual([fromSlug, fromAlias]); + }); + + it('enacts a reply from the alias when the message arrived at the slug address', async () => { + const fromAlias = `alias+support@${PROD_DOMAIN}`; + const { env } = makeEnv({ + emailReturn: [ + { type: 'reply', from: fromAlias, to: 'admin@example.com', raw: replyRaw(fromAlias) }, + ], + }); + const { message, replyCalls } = createFakeMessage({ to: `proj+x@${PROD_DOMAIN}` }); + const { ctx } = createCtx(); + await routeInbound(message, env, ctx, { ...aliasRoute('proj'), alias: 'alias' }); + expect(replyCalls.map((r) => r.from)).toEqual([fromAlias]); + }); + + it('drops a reply sent from another project on the shared domain', async () => { + const fromOther = `other+support@${PROD_DOMAIN}`; + const { env, apiFetch } = makeEnv({ + emailReturn: [ + { type: 'reply', from: fromOther, to: 'admin@example.com', raw: replyRaw(fromOther) }, + ], + }); + const { message, replyCalls } = createFakeMessage({ to: `alias+x@${PROD_DOMAIN}` }); + const { ctx } = createCtx(); + await routeInbound(message, env, ctx, aliasRoute('alias')); + expect(replyCalls).toHaveLength(0); + expect(apiBodies(apiFetch, '/admit')).toHaveLength(0); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('sender not owned by project')); + }); + + it('forwards from the alias recipient', async () => { + const recipient = `alias+x@${PROD_DOMAIN}`; + const { env, apiFetch } = makeEnv({ + emailReturn: [{ type: 'forward', to: 'admin@example.com' }], + }); + const { message, forwardCalls } = createFakeMessage({ to: recipient }); + const { ctx } = createCtx(); + await routeInbound(message, env, ctx, aliasRoute('alias')); + expect(apiBodies(apiFetch, '/admit')[0]).toMatchObject({ + kind: 'forward', + from: recipient, + transport: 'native_forward', + }); + expect(forwardCalls).toMatchObject([{ to: 'admin@example.com' }]); + }); +}); + describe('routeInbound — message size guards', () => { it('rejects messages exceeding rawSize > 10MB before streaming', async () => { const { env, emailFn } = makeEnv();