From 26264822690dc64ce81028b23d6a54bf60ab1493 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 12 Jun 2026 10:45:07 -0700 Subject: [PATCH 1/3] ci(migrations): fail dev schema push with an actionable error on rename/drop prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `drizzle-kit push --force` only suppresses the data-loss confirm, not the rename-vs-drop disambiguation prompt. That prompt fires whenever a diff both adds and drops tables/columns at once (e.g. migration 0231 created sim_trigger_state while dropping the workspace_notification_* tables), and in CI it crashes with a bare "Interactive prompts require a TTY" stack trace. Catch that specific failure in the dev push step and emit a GitHub error annotation explaining the cause and the fix (drop the stale objects on the dev DB to match schema.ts — the same DROPs the versioned migration already applied to staging/prod), instead of leaving an opaque trace. Exit status is preserved either way. Co-Authored-By: Claude Fable 5 --- .github/workflows/migrations.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/migrations.yml b/.github/workflows/migrations.yml index ea5ca453968..7be6e56b32d 100644 --- a/.github/workflows/migrations.yml +++ b/.github/workflows/migrations.yml @@ -69,7 +69,18 @@ jobs: if [ "${ENVIRONMENT}" = "dev" ]; then echo "Dev environment — pushing schema directly (db:push)" - bun run db:push --force + # `--force` only suppresses the data-loss confirm, not drizzle's + # rename-vs-drop prompt, which fires (and crashes, no TTY) when a + # diff both adds and drops tables/columns at once. Turn that opaque + # crash into an actionable failure instead of a bare stack trace. + push_output="$(bun run db:push --force 2>&1)" && push_status=0 || push_status=$? + echo "$push_output" + if [ "$push_status" -ne 0 ]; then + if printf '%s' "$push_output" | grep -q 'Interactive prompts require a TTY'; then + echo "::error title=Dev schema push needs manual reconciliation::drizzle-kit push hit an interactive rename/drop prompt that CI cannot answer. The dev DB has drifted from schema.ts: it still holds table(s)/column(s) the schema no longer declares while the schema also adds new ones, so drizzle cannot tell a rename from a drop+create. Fix: drop the stale objects on the dev DB to match schema.ts — the same DROPs the latest versioned migration already applied to staging/prod (grep packages/db/migrations for the most recent DROP TABLE / DROP COLUMN) — then re-run this workflow. --force cannot bypass this prompt." + fi + exit "$push_status" + fi else echo "Applying versioned migrations (db:migrate)" bun run ./scripts/migrate.ts From a489c4f44fc5acbb8cbca033b7b352c8b8412abf Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 12 Jun 2026 15:19:24 -0700 Subject: [PATCH 2/3] improvement(auth): layer disposable-email-domains into signup email validation Compose the disposable-email-domains list (exact + wildcard) into better-auth-harmony's validator alongside its bundled Mailchecker list, so signup rejects an email if either flags it. Server-only module to keep the dataset out of the client bundle. --- apps/sim/lib/auth/auth.ts | 11 ++++++- .../messaging/email/disposable-domains.d.ts | 10 ++++++ .../email/disposable-domains.server.test.ts | 31 +++++++++++++++++++ .../email/disposable-domains.server.ts | 19 ++++++++++++ apps/sim/package.json | 1 + bun.lock | 3 ++ 6 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 apps/sim/lib/messaging/email/disposable-domains.d.ts create mode 100644 apps/sim/lib/messaging/email/disposable-domains.server.test.ts create mode 100644 apps/sim/lib/messaging/email/disposable-domains.server.ts diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index c43a7f56ce5..2435380391f 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -21,6 +21,7 @@ import { organization, } from 'better-auth/plugins' import { emailHarmony } from 'better-auth-harmony' +import { validateEmail as validateEmailWithMailchecker } from 'better-auth-harmony/email' import { and, count, eq, inArray, sql } from 'drizzle-orm' import { headers } from 'next/headers' import Stripe from 'stripe' @@ -78,6 +79,7 @@ import { import { PlatformEvents } from '@/lib/core/telemetry' import { getBaseUrl, isLocalhostUrl, parseOriginList } from '@/lib/core/utils/urls' import { processCredentialDraft } from '@/lib/credentials/draft-processor' +import { isDisposableEmailDomain } from '@/lib/messaging/email/disposable-domains.server' import { sendEmail } from '@/lib/messaging/email/mailer' import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils' import { quickValidateEmail } from '@/lib/messaging/email/validation' @@ -930,7 +932,14 @@ export const auth = betterAuth({ }), }, plugins: [ - ...(isSignupEmailValidationEnabled ? [emailHarmony()] : []), + ...(isSignupEmailValidationEnabled + ? [ + emailHarmony({ + validator: (email) => + validateEmailWithMailchecker(email) && !isDisposableEmailDomain(email), + }), + ] + : []), ...(env.TURNSTILE_SECRET_KEY ? [ captcha({ diff --git a/apps/sim/lib/messaging/email/disposable-domains.d.ts b/apps/sim/lib/messaging/email/disposable-domains.d.ts new file mode 100644 index 00000000000..e182fb4ded6 --- /dev/null +++ b/apps/sim/lib/messaging/email/disposable-domains.d.ts @@ -0,0 +1,10 @@ +/** Ambient types for `disposable-email-domains` — ships JSON arrays with no bundled types. */ +declare module 'disposable-email-domains' { + const domains: string[] + export default domains +} + +declare module 'disposable-email-domains/wildcard.json' { + const baseDomains: string[] + export default baseDomains +} diff --git a/apps/sim/lib/messaging/email/disposable-domains.server.test.ts b/apps/sim/lib/messaging/email/disposable-domains.server.test.ts new file mode 100644 index 00000000000..d733967c9b0 --- /dev/null +++ b/apps/sim/lib/messaging/email/disposable-domains.server.test.ts @@ -0,0 +1,31 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { isDisposableEmailDomain } from '@/lib/messaging/email/disposable-domains.server' + +describe('isDisposableEmailDomain', () => { + it('flags a known disposable domain', () => { + expect(isDisposableEmailDomain('someone@mailinator.com')).toBe(true) + }) + + it('flags a subdomain of a wildcard base domain', () => { + expect(isDisposableEmailDomain('someone@inbox.10mail.org')).toBe(true) + }) + + it('is case-insensitive on the domain', () => { + expect(isDisposableEmailDomain('Someone@MailInator.com')).toBe(true) + }) + + it('allows a normal provider domain', () => { + expect(isDisposableEmailDomain('someone@gmail.com')).toBe(false) + }) + + it('allows a custom catch-all domain that is not on the list', () => { + expect(isDisposableEmailDomain('sim6dc088f506@lordfortescue.org.uk')).toBe(false) + }) + + it('returns false for malformed input with no domain', () => { + expect(isDisposableEmailDomain('not-an-email')).toBe(false) + }) +}) diff --git a/apps/sim/lib/messaging/email/disposable-domains.server.ts b/apps/sim/lib/messaging/email/disposable-domains.server.ts new file mode 100644 index 00000000000..b4ab61992c4 --- /dev/null +++ b/apps/sim/lib/messaging/email/disposable-domains.server.ts @@ -0,0 +1,19 @@ +import disposableDomains from 'disposable-email-domains' +import wildcardBaseDomains from 'disposable-email-domains/wildcard.json' + +const exactDomains = new Set(disposableDomains) + +/** + * Server-only disposable-email-domain check backed by the `disposable-email-domains` + * package (~120K exact domains plus wildcard base domains). Layered alongside + * better-auth-harmony's bundled Mailchecker list at the signup gate. + * + * Never import from client code — the dataset would bloat the browser bundle. + * Matches exact domains and any subdomain of a wildcard base domain. + */ +export function isDisposableEmailDomain(email: string): boolean { + const domain = email.split('@')[1]?.toLowerCase() + if (!domain) return false + if (exactDomains.has(domain)) return true + return wildcardBaseDomains.some((base) => domain === base || domain.endsWith(`.${base}`)) +} diff --git a/apps/sim/package.json b/apps/sim/package.json index 1c5915a267c..2b22800826b 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -126,6 +126,7 @@ "csv-parse": "6.1.0", "date-fns": "4.1.0", "decimal.js": "10.6.0", + "disposable-email-domains": "1.0.62", "docx": "^9.6.1", "docx-preview": "^0.3.7", "drizzle-orm": "^0.45.2", diff --git a/bun.lock b/bun.lock index 447ad2121cc..3a084c8d47c 100644 --- a/bun.lock +++ b/bun.lock @@ -185,6 +185,7 @@ "csv-parse": "6.1.0", "date-fns": "4.1.0", "decimal.js": "10.6.0", + "disposable-email-domains": "1.0.62", "docx": "^9.6.1", "docx-preview": "^0.3.7", "drizzle-orm": "^0.45.2", @@ -2212,6 +2213,8 @@ "dingbat-to-unicode": ["dingbat-to-unicode@1.0.1", "", {}, "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w=="], + "disposable-email-domains": ["disposable-email-domains@1.0.62", "", {}, "sha512-LBQvhRw7mznQTPoyZbsmYeNOZt1pN5aCsx4BAU/3siVFuiM9f2oyKzUaB8v1jbxFjE3aYqYiMo63kAL4pHgfWQ=="], + "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], "dockerfile-ast": ["dockerfile-ast@0.7.1", "", { "dependencies": { "vscode-languageserver-textdocument": "^1.0.8", "vscode-languageserver-types": "^3.17.3" } }, "sha512-oX/A4I0EhSkGqrFv0YuvPkBUSYp1XiY8O8zAKc8Djglx8ocz+JfOr8gP0ryRMC2myqvDLagmnZaU9ot1vG2ijw=="], From d70f56d5a9c77a2a58265756f17d4987715a3e8a Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 12 Jun 2026 15:28:30 -0700 Subject: [PATCH 3/3] improvement(auth): defer disposable-domains dataset behind lazy dynamic import Address review: load the ~120K-entry dataset on first use instead of at module import, so deployments with SIGNUP_EMAIL_VALIDATION_ENABLED off never pay the cost. Add a bare wildcard-base test case. --- apps/sim/lib/auth/auth.ts | 4 +-- .../email/disposable-domains.server.test.ts | 28 +++++++++------- .../email/disposable-domains.server.ts | 33 +++++++++++++------ 3 files changed, 41 insertions(+), 24 deletions(-) diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 2435380391f..099e14aa7a3 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -935,8 +935,8 @@ export const auth = betterAuth({ ...(isSignupEmailValidationEnabled ? [ emailHarmony({ - validator: (email) => - validateEmailWithMailchecker(email) && !isDisposableEmailDomain(email), + validator: async (email) => + validateEmailWithMailchecker(email) && !(await isDisposableEmailDomain(email)), }), ] : []), diff --git a/apps/sim/lib/messaging/email/disposable-domains.server.test.ts b/apps/sim/lib/messaging/email/disposable-domains.server.test.ts index d733967c9b0..05eaa593391 100644 --- a/apps/sim/lib/messaging/email/disposable-domains.server.test.ts +++ b/apps/sim/lib/messaging/email/disposable-domains.server.test.ts @@ -5,27 +5,31 @@ import { describe, expect, it } from 'vitest' import { isDisposableEmailDomain } from '@/lib/messaging/email/disposable-domains.server' describe('isDisposableEmailDomain', () => { - it('flags a known disposable domain', () => { - expect(isDisposableEmailDomain('someone@mailinator.com')).toBe(true) + it('flags a known disposable domain', async () => { + expect(await isDisposableEmailDomain('someone@mailinator.com')).toBe(true) }) - it('flags a subdomain of a wildcard base domain', () => { - expect(isDisposableEmailDomain('someone@inbox.10mail.org')).toBe(true) + it('flags a subdomain of a wildcard base domain', async () => { + expect(await isDisposableEmailDomain('someone@inbox.10mail.org')).toBe(true) }) - it('is case-insensitive on the domain', () => { - expect(isDisposableEmailDomain('Someone@MailInator.com')).toBe(true) + it('flags the bare wildcard base domain itself', async () => { + expect(await isDisposableEmailDomain('someone@10mail.org')).toBe(true) }) - it('allows a normal provider domain', () => { - expect(isDisposableEmailDomain('someone@gmail.com')).toBe(false) + it('is case-insensitive on the domain', async () => { + expect(await isDisposableEmailDomain('Someone@MailInator.com')).toBe(true) }) - it('allows a custom catch-all domain that is not on the list', () => { - expect(isDisposableEmailDomain('sim6dc088f506@lordfortescue.org.uk')).toBe(false) + it('allows a normal provider domain', async () => { + expect(await isDisposableEmailDomain('someone@gmail.com')).toBe(false) }) - it('returns false for malformed input with no domain', () => { - expect(isDisposableEmailDomain('not-an-email')).toBe(false) + it('allows a custom catch-all domain that is not on the list', async () => { + expect(await isDisposableEmailDomain('sim6dc088f506@lordfortescue.org.uk')).toBe(false) + }) + + it('returns false for malformed input with no domain', async () => { + expect(await isDisposableEmailDomain('not-an-email')).toBe(false) }) }) diff --git a/apps/sim/lib/messaging/email/disposable-domains.server.ts b/apps/sim/lib/messaging/email/disposable-domains.server.ts index b4ab61992c4..03bed585f95 100644 --- a/apps/sim/lib/messaging/email/disposable-domains.server.ts +++ b/apps/sim/lib/messaging/email/disposable-domains.server.ts @@ -1,19 +1,32 @@ -import disposableDomains from 'disposable-email-domains' -import wildcardBaseDomains from 'disposable-email-domains/wildcard.json' +let cache: { exact: Set; wildcards: string[] } | undefined -const exactDomains = new Set(disposableDomains) +/** + * Lazily loads the `disposable-email-domains` dataset (~120K exact domains plus + * wildcard base domains) on first use and memoizes it. Deferred behind a dynamic + * import so deployments with signup email validation disabled never load it. + */ +async function loadDisposableData(): Promise<{ exact: Set; wildcards: string[] }> { + if (!cache) { + const [{ default: exactList }, { default: wildcards }] = await Promise.all([ + import('disposable-email-domains'), + import('disposable-email-domains/wildcard.json'), + ]) + cache = { exact: new Set(exactList), wildcards } + } + return cache +} /** - * Server-only disposable-email-domain check backed by the `disposable-email-domains` - * package (~120K exact domains plus wildcard base domains). Layered alongside - * better-auth-harmony's bundled Mailchecker list at the signup gate. + * Server-only disposable-email-domain check. Layered alongside better-auth-harmony's + * bundled Mailchecker list at the signup gate. Matches exact domains and any subdomain + * of (or the bare) wildcard base domain. * * Never import from client code — the dataset would bloat the browser bundle. - * Matches exact domains and any subdomain of a wildcard base domain. */ -export function isDisposableEmailDomain(email: string): boolean { +export async function isDisposableEmailDomain(email: string): Promise { const domain = email.split('@')[1]?.toLowerCase() if (!domain) return false - if (exactDomains.has(domain)) return true - return wildcardBaseDomains.some((base) => domain === base || domain.endsWith(`.${base}`)) + const { exact, wildcards } = await loadDisposableData() + if (exact.has(domain)) return true + return wildcards.some((base) => domain === base || domain.endsWith(`.${base}`)) }