diff --git a/apps/api/src/auth/auth-server-origins.spec.ts b/apps/api/src/auth/auth-server-origins.spec.ts index 8c781ff1b6..6f9b0f7d32 100644 --- a/apps/api/src/auth/auth-server-origins.spec.ts +++ b/apps/api/src/auth/auth-server-origins.spec.ts @@ -1,5 +1,5 @@ /** - * Tests for the getTrustedOrigins logic. + * Tests for the getTrustedOrigins / isTrustedOrigin logic. * * Because auth.server.ts has side effects at module load time (better-auth * initialization, DB connections, validateSecurityConfig), we test the logic @@ -25,6 +25,32 @@ function getTrustedOriginsLogic(authTrustedOrigins: string | undefined): string[ ]; } +/** + * Mirror of isStaticTrustedOrigin from auth.server.ts for isolated testing. + * The full isTrustedOrigin is async (checks DB for custom domains) — + * that path is tested via integration tests. + */ +function isStaticTrustedOriginLogic( + origin: string, + trustedOrigins: string[], +): boolean { + if (trustedOrigins.includes(origin)) { + return true; + } + + try { + const url = new URL(origin); + return ( + url.hostname.endsWith('.trycomp.ai') || + url.hostname.endsWith('.staging.trycomp.ai') || + url.hostname.endsWith('.trust.inc') || + url.hostname === 'trust.inc' + ); + } catch { + return false; + } +} + describe('getTrustedOrigins', () => { it('should return env-configured origins when AUTH_TRUSTED_ORIGINS is set', () => { const origins = getTrustedOriginsLogic('https://a.com, https://b.com'); @@ -45,9 +71,39 @@ describe('getTrustedOrigins', () => { const origins = getTrustedOriginsLogic(' https://a.com , https://b.com '); expect(origins).toEqual(['https://a.com', 'https://b.com']); }); +}); + +describe('isStaticTrustedOrigin', () => { + const defaults = getTrustedOriginsLogic(undefined); + + it('should allow static trusted origins', () => { + expect(isStaticTrustedOriginLogic('https://app.trycomp.ai', defaults)).toBe(true); + }); + + it('should allow trust portal subdomains of trycomp.ai', () => { + expect(isStaticTrustedOriginLogic('https://security.trycomp.ai', defaults)).toBe(true); + expect(isStaticTrustedOriginLogic('https://acme.trycomp.ai', defaults)).toBe(true); + }); + + it('should allow trust portal subdomains of staging.trycomp.ai', () => { + expect(isStaticTrustedOriginLogic('https://security.staging.trycomp.ai', defaults)).toBe(true); + }); + + it('should allow trust.inc and its subdomains', () => { + expect(isStaticTrustedOriginLogic('https://trust.inc', defaults)).toBe(true); + expect(isStaticTrustedOriginLogic('https://acme.trust.inc', defaults)).toBe(true); + }); + + it('should reject unknown origins', () => { + expect(isStaticTrustedOriginLogic('https://evil.com', defaults)).toBe(false); + expect(isStaticTrustedOriginLogic('https://trycomp.ai.evil.com', defaults)).toBe(false); + }); + + it('should handle invalid origins gracefully', () => { + expect(isStaticTrustedOriginLogic('not-a-url', defaults)).toBe(false); + }); - it('main.ts should use getTrustedOrigins instead of origin: true', () => { - // Validate the CORS config change was made correctly by checking file content + it('main.ts should use isTrustedOrigin for CORS', () => { const fs = require('fs'); const path = require('path'); const mainTs = fs.readFileSync( @@ -55,7 +111,7 @@ describe('getTrustedOrigins', () => { 'utf-8', ) as string; expect(mainTs).not.toContain('origin: true'); - expect(mainTs).toContain('origin: getTrustedOrigins()'); - expect(mainTs).toContain("import { getTrustedOrigins } from './auth/auth.server'"); + expect(mainTs).toContain('isTrustedOrigin'); + expect(mainTs).toContain("import { isTrustedOrigin } from './auth/auth.server'"); }); }); diff --git a/apps/api/src/auth/auth.server.ts b/apps/api/src/auth/auth.server.ts index e0c5db7eca..66dd8cf567 100644 --- a/apps/api/src/auth/auth.server.ts +++ b/apps/api/src/auth/auth.server.ts @@ -15,6 +15,7 @@ import { } from 'better-auth/plugins'; import { ac, allRoles } from '@trycompai/auth'; import { createAuthMiddleware } from 'better-auth/api'; +import { Redis } from '@upstash/redis'; const MAGIC_LINK_EXPIRES_IN_SECONDS = 60 * 60; // 1 hour @@ -56,6 +57,93 @@ export function getTrustedOrigins(): string[] { ]; } +/** + * Check if an origin matches a known trusted pattern (static list + subdomains). + * This is a fast synchronous check that doesn't hit the DB. + */ +export function isStaticTrustedOrigin(origin: string): boolean { + const trustedOrigins = getTrustedOrigins(); + if (trustedOrigins.includes(origin)) { + return true; + } + + try { + const url = new URL(origin); + return ( + url.hostname.endsWith('.trycomp.ai') || + url.hostname.endsWith('.staging.trycomp.ai') || + url.hostname.endsWith('.trust.inc') || + url.hostname === 'trust.inc' + ); + } catch { + return false; + } +} + +// ── Custom domain lookup via Redis cache ───────────────────────────────────── + +const CORS_DOMAINS_CACHE_KEY = 'cors:custom-domains'; +const CORS_DOMAINS_CACHE_TTL_SECONDS = 5 * 60; // 5 minutes + +const corsRedisClient = new Redis({ + url: process.env.UPSTASH_REDIS_REST_URL!, + token: process.env.UPSTASH_REDIS_REST_TOKEN!, +}); + +async function getCustomDomains(): Promise> { + try { + // Try Redis cache first + const cached = await corsRedisClient.get(CORS_DOMAINS_CACHE_KEY); + if (cached) { + return new Set(cached); + } + + // Cache miss — query DB and store in Redis + const trusts = await db.trust.findMany({ + where: { + domain: { not: null }, + domainVerified: true, + status: 'published', + }, + select: { domain: true }, + }); + + const domains = trusts + .map((t) => t.domain) + .filter((d): d is string => d !== null); + + await corsRedisClient.set(CORS_DOMAINS_CACHE_KEY, domains, { + ex: CORS_DOMAINS_CACHE_TTL_SECONDS, + }); + + return new Set(domains); + } catch (error) { + console.error('[CORS] Failed to fetch custom domains:', error); + return new Set(); + } +} + +/** + * Check if an origin is trusted. Checks (in order): + * 1. Static trusted origins list + * 2. *.trycomp.ai / *.trust.inc subdomains + * 3. Verified custom domains from the DB (cached in Redis, TTL 5 min) + */ +export async function isTrustedOrigin(origin: string): Promise { + if (isStaticTrustedOrigin(origin)) { + return true; + } + + // Check verified custom domains from DB via Redis cache + try { + const url = new URL(origin); + const customDomains = await getCustomDomains(); + return customDomains.has(url.hostname); + } catch { + return false; + } +} + // Build social providers config const socialProviders: Record = {}; diff --git a/apps/api/src/auth/origin-check.middleware.spec.ts b/apps/api/src/auth/origin-check.middleware.spec.ts index 5659f84ef2..1690b74454 100644 --- a/apps/api/src/auth/origin-check.middleware.spec.ts +++ b/apps/api/src/auth/origin-check.middleware.spec.ts @@ -1,13 +1,27 @@ import { originCheckMiddleware } from './origin-check.middleware'; -// Mock getTrustedOrigins +// Mock isTrustedOrigin (async version) jest.mock('./auth.server', () => ({ - getTrustedOrigins: () => [ - 'http://localhost:3000', - 'http://localhost:3002', - 'https://app.trycomp.ai', - 'https://portal.trycomp.ai', - ], + isTrustedOrigin: async (origin: string) => { + const staticOrigins = [ + 'http://localhost:3000', + 'http://localhost:3002', + 'https://app.trycomp.ai', + 'https://portal.trycomp.ai', + ]; + if (staticOrigins.includes(origin)) return true; + try { + const url = new URL(origin); + return ( + url.hostname.endsWith('.trycomp.ai') || + url.hostname.endsWith('.staging.trycomp.ai') || + url.hostname.endsWith('.trust.inc') || + url.hostname === 'trust.inc' + ); + } catch { + return false; + } + }, })); function createMockReq( @@ -22,6 +36,9 @@ function createMockReq( }; } +/** Flush the microtask queue so async middleware completes. */ +const flushPromises = () => new Promise((resolve) => setImmediate(resolve)); + function createMockRes(): Record & { statusCode?: number; body?: unknown } { const res: Record & { statusCode?: number; body?: unknown } = {}; res.status = jest.fn().mockImplementation((code: number) => { @@ -66,44 +83,48 @@ describe('originCheckMiddleware', () => { expect(next).toHaveBeenCalled(); }); - it('should allow POST from trusted origin', () => { + it('should allow POST from trusted origin', async () => { const req = createMockReq('POST', '/v1/organization/api-keys', 'http://localhost:3000'); const res = createMockRes(); const next = jest.fn(); originCheckMiddleware(req as any, res as any, next); + await flushPromises(); expect(next).toHaveBeenCalled(); }); - it('should block POST from untrusted origin', () => { + it('should block POST from untrusted origin', async () => { const req = createMockReq('POST', '/v1/organization/transfer-ownership', 'http://evil.com'); const res = createMockRes(); const next = jest.fn(); originCheckMiddleware(req as any, res as any, next); + await flushPromises(); expect(next).not.toHaveBeenCalled(); expect(res.status).toHaveBeenCalledWith(403); }); - it('should block DELETE from untrusted origin', () => { + it('should block DELETE from untrusted origin', async () => { const req = createMockReq('DELETE', '/v1/organization', 'http://evil.com'); const res = createMockRes(); const next = jest.fn(); originCheckMiddleware(req as any, res as any, next); + await flushPromises(); expect(next).not.toHaveBeenCalled(); expect(res.status).toHaveBeenCalledWith(403); }); - it('should block PATCH from untrusted origin', () => { + it('should block PATCH from untrusted origin', async () => { const req = createMockReq('PATCH', '/v1/members/123/role', 'http://evil.com'); const res = createMockRes(); const next = jest.fn(); originCheckMiddleware(req as any, res as any, next); + await flushPromises(); expect(next).not.toHaveBeenCalled(); expect(res.status).toHaveBeenCalledWith(403); @@ -139,12 +160,13 @@ describe('originCheckMiddleware', () => { expect(next).toHaveBeenCalled(); }); - it('should allow production origins', () => { + it('should allow production origins', async () => { const req = createMockReq('POST', '/v1/organization/api-keys', 'https://app.trycomp.ai'); const res = createMockRes(); const next = jest.fn(); originCheckMiddleware(req as any, res as any, next); + await flushPromises(); expect(next).toHaveBeenCalled(); }); diff --git a/apps/api/src/auth/origin-check.middleware.ts b/apps/api/src/auth/origin-check.middleware.ts index ecfa92ab3a..d5d5aaf5ff 100644 --- a/apps/api/src/auth/origin-check.middleware.ts +++ b/apps/api/src/auth/origin-check.middleware.ts @@ -1,5 +1,5 @@ import type { Request, Response, NextFunction } from 'express'; -import { getTrustedOrigins } from './auth.server'; +import { isTrustedOrigin } from './auth.server'; const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); @@ -52,14 +52,21 @@ export function originCheckMiddleware( return next(); } - // Validate Origin against trusted origins - const trustedOrigins = getTrustedOrigins(); - if (trustedOrigins.includes(origin)) { - return next(); - } - - res.status(403).json({ - statusCode: 403, - message: 'Forbidden', - }); + // Validate Origin against trusted origins (includes dynamic subdomains + custom domains) + isTrustedOrigin(origin) + .then((trusted) => { + if (trusted) { + return next(); + } + res.status(403).json({ + statusCode: 403, + message: 'Forbidden', + }); + }) + .catch(() => { + res.status(403).json({ + statusCode: 403, + message: 'Forbidden', + }); + }); } diff --git a/apps/api/src/common/filters/cors-exception.filter.ts b/apps/api/src/common/filters/cors-exception.filter.ts index bb455da0b1..009d267d4c 100644 --- a/apps/api/src/common/filters/cors-exception.filter.ts +++ b/apps/api/src/common/filters/cors-exception.filter.ts @@ -5,6 +5,7 @@ import { HttpException, } from '@nestjs/common'; import type { Response, Request } from 'express'; +import { isStaticTrustedOrigin } from '../../auth/auth.server'; @Catch(HttpException) export class CorsExceptionFilter implements ExceptionFilter { @@ -15,39 +16,22 @@ export class CorsExceptionFilter implements ExceptionFilter { const status = exception.getStatus(); // Get the request origin - const origin = request.headers.origin; + const origin = request.headers.origin as string | undefined; - // Set CORS headers on error responses - if (origin) { - const isDevelopment = process.env.NODE_ENV !== 'production'; - const allowedOrigins = [ - 'http://localhost:3000', - 'http://localhost:3001', - 'http://127.0.0.1:3000', - 'https://app.trycomp.ai', - 'https://trycomp.ai', - process.env.APP_URL, - ].filter(Boolean) as string[]; - - const isAllowed = - allowedOrigins.includes(origin) || - (isDevelopment && - (origin.includes('localhost') || - origin.includes('127.0.0.1') || - origin.includes('ngrok'))); - - if (isAllowed) { - response.setHeader('Access-Control-Allow-Origin', origin); - response.setHeader('Access-Control-Allow-Credentials', 'true'); - response.setHeader( - 'Access-Control-Allow-Methods', - 'GET,POST,PUT,DELETE,PATCH,OPTIONS', - ); - response.setHeader( - 'Access-Control-Allow-Headers', - 'Content-Type,Authorization,X-API-Key,X-Organization-Id', - ); - } + // Set CORS headers on error responses for trusted origins. + // Uses the sync check only — the main CORS middleware already validated + // custom domains on the way in, so this is a best-effort fallback. + if (origin && isStaticTrustedOrigin(origin)) { + response.setHeader('Access-Control-Allow-Origin', origin); + response.setHeader('Access-Control-Allow-Credentials', 'true'); + response.setHeader( + 'Access-Control-Allow-Methods', + 'GET,POST,PUT,DELETE,PATCH,OPTIONS', + ); + response.setHeader( + 'Access-Control-Allow-Headers', + 'Content-Type,Authorization,X-API-Key,X-Organization-Id', + ); } response.status(status).json(exception.getResponse()); diff --git a/apps/api/src/integration-platform/controllers/sync-gws.controller.spec.ts b/apps/api/src/integration-platform/controllers/sync-gws.controller.spec.ts index e826e6d697..aaba4ac2ba 100644 --- a/apps/api/src/integration-platform/controllers/sync-gws.controller.spec.ts +++ b/apps/api/src/integration-platform/controllers/sync-gws.controller.spec.ts @@ -32,12 +32,18 @@ jest.mock('@trycompai/auth', () => ({ BUILT_IN_ROLE_PERMISSIONS: {}, })); -jest.mock('@trycompai/integration-platform', () => ({ - getManifest: jest.fn().mockReturnValue({ - auth: { type: 'oauth2', config: { tokenUrl: '', refreshUrl: '' } }, - }), - TASK_TEMPLATE_INFO: {}, -})); +jest.mock('@trycompai/integration-platform', () => { + const actual = jest.requireActual( + '@trycompai/integration-platform', + ); + return { + ...actual, + getManifest: jest.fn().mockReturnValue({ + auth: { type: 'oauth2', config: { tokenUrl: '', refreshUrl: '' } }, + }), + TASK_TEMPLATE_INFO: {}, + }; +}); const mockFetch = jest.fn(); global.fetch = mockFetch; diff --git a/apps/api/src/integration-platform/controllers/sync.controller.ts b/apps/api/src/integration-platform/controllers/sync.controller.ts index 005215a590..d3af5fd10d 100644 --- a/apps/api/src/integration-platform/controllers/sync.controller.ts +++ b/apps/api/src/integration-platform/controllers/sync.controller.ts @@ -24,6 +24,8 @@ import { CredentialVaultService } from '../services/credential-vault.service'; import { OAuthCredentialsService } from '../services/oauth-credentials.service'; import { getManifest, + matchesSyncFilterTerms, + parseSyncFilterTerms, type OAuthConfig, type RampUser, type RampUserStatus, @@ -58,49 +60,6 @@ type GoogleWorkspaceSyncFilterMode = 'all' | 'exclude' | 'include'; const GOOGLE_WORKSPACE_SYNC_FILTER_MODES = new Set(['all', 'exclude', 'include']); -const parseSyncFilterTerms = (value: unknown): string[] => { - const rawValues = Array.isArray(value) - ? value.map((item) => String(item)) - : typeof value === 'string' - ? [value] - : []; - - return Array.from( - new Set( - rawValues - .flatMap((item) => item.split(/[\n,;]+/)) - .map((item) => item.trim().toLowerCase()) - .filter((item) => item.length > 0), - ), - ); -}; - -const isFullEmailTerm = (term: string): boolean => - /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(term); - -const matchesSyncFilterTerm = (email: string, term: string): boolean => { - if (email === term) { - return true; - } - - if (term.startsWith('@')) { - return email.endsWith(term); - } - - if (isFullEmailTerm(term)) { - return false; - } - - if (term.includes('@')) { - return email.includes(term); - } - - return email.endsWith(`@${term}`) || email.includes(term); -}; - -const matchesSyncFilterTerms = (email: string, terms: string[]): boolean => - terms.some((term) => matchesSyncFilterTerm(email, term)); - @Controller({ path: 'integrations/sync', version: '1' }) @ApiTags('Integrations') @UseGuards(HybridAuthGuard, PermissionGuard) diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 89b3ea5e34..6736642596 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -8,7 +8,7 @@ import * as express from 'express'; import helmet from 'helmet'; import path from 'path'; import { AppModule } from './app.module'; -import { getTrustedOrigins } from './auth/auth.server'; +import { isTrustedOrigin } from './auth/auth.server'; import { adminAuthRateLimiter } from './auth/admin-rate-limit.middleware'; import { originCheckMiddleware } from './auth/origin-check.middleware'; import { mkdirSync, writeFileSync, existsSync } from 'fs'; @@ -22,9 +22,19 @@ async function bootstrap(): Promise { bodyParser: false, }); - // Enable CORS with explicit origin allowlist + // Enable CORS with origin validation. + // Uses a callback to support dynamic trust portal subdomains + // (e.g. security.trycomp.ai, acme.trust.inc) and verified custom domains. app.enableCors({ - origin: getTrustedOrigins(), + origin: (origin, callback) => { + // Allow requests with no origin (non-browser clients, same-origin, etc.) + if (!origin) { + return callback(null, true); + } + isTrustedOrigin(origin) + .then((trusted) => callback(null, trusted)) + .catch(() => callback(null, false)); + }, credentials: true, exposedHeaders: ['Content-Disposition'], }); diff --git a/packages/integration-platform/src/index.ts b/packages/integration-platform/src/index.ts index 6bcf8d1561..57435a1924 100644 --- a/packages/integration-platform/src/index.ts +++ b/packages/integration-platform/src/index.ts @@ -120,6 +120,9 @@ export type { // Individual manifests (for direct import if needed) export { manifest as githubManifest } from './manifests/github'; +// Directory sync email include/exclude terms (Google Workspace, JumpCloud, checks) +export { matchesSyncFilterTerms, parseSyncFilterTerms } from './sync-filter/email-exclusion-terms'; + // Ramp types (used by sync controller) export type { RampUser, diff --git a/packages/integration-platform/src/manifests/google-workspace/checks/two-factor-auth.ts b/packages/integration-platform/src/manifests/google-workspace/checks/two-factor-auth.ts index 87745f7ddd..e784ab03a9 100644 --- a/packages/integration-platform/src/manifests/google-workspace/checks/two-factor-auth.ts +++ b/packages/integration-platform/src/manifests/google-workspace/checks/two-factor-auth.ts @@ -1,5 +1,6 @@ import { TASK_TEMPLATES } from '../../../task-mappings'; import type { CheckContext, IntegrationCheck } from '../../../types'; +import { matchesSyncFilterTerms, parseSyncFilterTerms } from '../../../sync-filter/email-exclusion-terms'; import type { GoogleWorkspaceUser, GoogleWorkspaceUsersResponse } from '../types'; import { includeSuspendedVariable, targetOrgUnitsVariable } from '../variables'; @@ -18,6 +19,11 @@ export const twoFactorAuthCheck: IntegrationCheck = { ctx.log('Starting Google Workspace 2FA check'); const targetOrgUnits = ctx.variables.target_org_units as string[] | undefined; + const excludedTerms = parseSyncFilterTerms( + ctx.variables.sync_excluded_emails ?? ctx.variables.excluded_emails, + ); + const includedTerms = parseSyncFilterTerms(ctx.variables.sync_included_emails); + const userFilterMode = ctx.variables.sync_user_filter_mode as 'all' | 'exclude' | 'include' | undefined; const includeSuspended = ctx.variables.include_suspended === 'true'; // Fetch all users with pagination @@ -60,11 +66,28 @@ export const twoFactorAuthCheck: IntegrationCheck = { return false; } - // Filter by org unit if specified + // Org units first, then sync email filter — same order as employee sync (sync.controller.ts) if (targetOrgUnits && targetOrgUnits.length > 0) { - return targetOrgUnits.some( - (ou) => user.orgUnitPath === ou || user.orgUnitPath.startsWith(`${ou}/`), + const userOu = user.orgUnitPath ?? '/'; + const inOrgUnit = targetOrgUnits.some( + (ou) => ou === '/' || userOu === ou || userOu.startsWith(`${ou}/`), ); + if (!inOrgUnit) { + return false; + } + } + + const email = user.primaryEmail.toLowerCase(); + + if (userFilterMode === 'exclude' && excludedTerms.length > 0) { + return !matchesSyncFilterTerms(email, excludedTerms); + } + + if (userFilterMode === 'include') { + if (includedTerms.length === 0) { + return true; + } + return matchesSyncFilterTerms(email, includedTerms); } return true; diff --git a/packages/integration-platform/src/sync-filter/__tests__/email-exclusion-terms.test.ts b/packages/integration-platform/src/sync-filter/__tests__/email-exclusion-terms.test.ts new file mode 100644 index 0000000000..1a1a6929bd --- /dev/null +++ b/packages/integration-platform/src/sync-filter/__tests__/email-exclusion-terms.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'bun:test'; +import { matchesSyncFilterTerms, parseSyncFilterTerms } from '../email-exclusion-terms'; + +describe('parseSyncFilterTerms', () => { + it('normalizes and dedupes terms', () => { + expect(parseSyncFilterTerms(['A@B.COM', 'a@b.com', ' x '])).toEqual(['a@b.com', 'x']); + }); +}); + +describe('matchesSyncFilterTerms', () => { + it('matches full email exactly (case-insensitive via caller)', () => { + expect(matchesSyncFilterTerms('user@example.com', ['user@example.com'])).toBe(true); + expect(matchesSyncFilterTerms('user@example.com', ['other@example.com'])).toBe(false); + }); + + it('does not treat full-email terms as substrings', () => { + expect(matchesSyncFilterTerms('alice@company.com', ['ice@company.com'])).toBe(false); + }); + + it('matches @domain suffix', () => { + expect(matchesSyncFilterTerms('user@company.com', ['@company.com'])).toBe(true); + }); + + it('treats multi-segment domains as full-email terms (no substring false positives)', () => { + expect(matchesSyncFilterTerms('user@domain.co.uk', ['other@domain.co.uk'])).toBe(false); + }); +}); diff --git a/packages/integration-platform/src/sync-filter/email-exclusion-terms.ts b/packages/integration-platform/src/sync-filter/email-exclusion-terms.ts new file mode 100644 index 0000000000..21a468f5c8 --- /dev/null +++ b/packages/integration-platform/src/sync-filter/email-exclusion-terms.ts @@ -0,0 +1,72 @@ +/** + * Email exclusion / inclusion terms for directory sync and checks (Google Workspace, JumpCloud, etc.). + * Terms may be full emails, @domain suffixes, or other patterns per integration Admin UI copy. + */ + +export const parseSyncFilterTerms = (value: unknown): string[] => { + const rawValues = Array.isArray(value) + ? value.map((item) => String(item)) + : typeof value === 'string' + ? [value] + : []; + + return Array.from( + new Set( + rawValues + .flatMap((item) => item.split(/[\n,;]+/)) + .map((item) => item.trim().toLowerCase()) + .filter((item) => item.length > 0), + ), + ); +}; + +/** Linear-time full-email shape check (avoids ReDoS from regex on user-controlled terms). */ +const isFullEmailTerm = (term: string): boolean => { + const at = term.indexOf('@'); + if (at <= 0) return false; + if (term.indexOf('@', at + 1) !== -1) return false; + + const local = term.slice(0, at); + const domain = term.slice(at + 1); + if (local.length === 0 || domain.length === 0) return false; + + const segmentHasOnlyNonSpaceNonAt = (s: string): boolean => { + for (let i = 0; i < s.length; i++) { + const ch = s[i]; + if (ch === ' ' || ch === '@') return false; + } + return true; + }; + + if (!segmentHasOnlyNonSpaceNonAt(local) || !segmentHasOnlyNonSpaceNonAt(domain)) { + return false; + } + + const dotIdx = domain.lastIndexOf('.'); + if (dotIdx <= 0 || dotIdx >= domain.length - 1) return false; + + return true; +}; + +const matchesSyncFilterTerm = (email: string, term: string): boolean => { + if (email === term) { + return true; + } + + if (term.startsWith('@')) { + return email.endsWith(term); + } + + if (isFullEmailTerm(term)) { + return false; + } + + if (term.includes('@')) { + return email.includes(term); + } + + return email.endsWith(`@${term}`) || email.includes(term); +}; + +export const matchesSyncFilterTerms = (email: string, terms: string[]): boolean => + terms.some((term) => matchesSyncFilterTerm(email, term));