diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0aa7a40ed9e..88be4166929 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -19,7 +19,7 @@ on:
push:
branches: [main, staging, dev]
pull_request:
- branches: [main, staging, dev]
+ branches: [main, staging, dev, e2e/settings-playwright]
# Docs content and markdown don't affect the app build or images; push
# runs stay unfiltered because they feed the deploy pipeline.
paths-ignore:
diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml
index ed348a41446..16168dc5990 100644
--- a/.github/workflows/test-build.yml
+++ b/.github/workflows/test-build.yml
@@ -258,4 +258,104 @@ jobs:
AWS_REGION: 'us-west-2'
ENCRYPTION_KEY: '7cf672e460e430c1fba707575c2b0e2ad5a99dddf9b7b7e3b5646e630861db1c' # dummy key for CI only
TURBO_CACHE_DIR: .turbo
- run: bunx turbo run build --filter=sim
\ No newline at end of file
+ run: bunx turbo run build --filter=sim
+
+ settings-e2e:
+ name: Settings E2E (informational)
+ runs-on: blacksmith-8vcpu-ubuntu-2404
+ timeout-minutes: 45
+ continue-on-error: true
+
+ services:
+ postgres:
+ image: pgvector/pgvector:pg17
+ env:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: postgres
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd "pg_isready -U postgres"
+ --health-interval 5s
+ --health-timeout 5s
+ --health-retries 10
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
+ with:
+ bun-version: 1.3.13
+
+ - name: Setup Node
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
+ with:
+ node-version: 22
+
+ - name: Mount Bun cache (Sticky Disk)
+ uses: useblacksmith/stickydisk@4c034ba57b706cf0e3b4b0ce098c2a3b1071580c # v1
+ with:
+ key: ${{ github.repository }}-bun-cache-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && '-fork' || '' }}
+ path: ~/.bun/install/cache
+
+ - name: Mount node_modules (Sticky Disk)
+ uses: useblacksmith/stickydisk@4c034ba57b706cf0e3b4b0ce098c2a3b1071580c # v1
+ with:
+ key: ${{ github.repository }}-node-modules-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && '-fork' || '' }}
+ path: ./node_modules
+
+ - name: Mount Playwright browsers (Sticky Disk)
+ uses: useblacksmith/stickydisk@4c034ba57b706cf0e3b4b0ce098c2a3b1071580c # v1
+ with:
+ key: ${{ github.repository }}-playwright-browsers-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && '-fork' || '' }}
+ path: ~/.cache/ms-playwright
+
+ - name: Install dependencies
+ run: bun install --frozen-lockfile
+
+ - name: Configure E2E hostnames
+ run: echo "127.0.0.1 e2e.sim.ai mcp.e2e.sim.ai" | sudo tee -a /etc/hosts
+
+ - name: Install Chromium
+ working-directory: apps/sim
+ run: bun run test:e2e:install-browsers -- --with-deps
+
+ - name: Run settings E2E foundation
+ timeout-minutes: 40
+ working-directory: apps/sim
+ env:
+ CI: 'true'
+ E2E_PG_ADMIN_URL: 'postgresql://postgres:postgres@127.0.0.1:5432/postgres'
+ run: bun run test:e2e
+
+ - name: Check E2E diagnostics eligibility
+ id: e2e_diagnostics
+ if: failure()
+ shell: bash
+ run: |
+ shopt -s nullglob
+ markers=(apps/sim/e2e/.runs/*/markers/leak-scan-complete.json)
+ if (( ${#markers[@]} > 0 )); then
+ echo "safe=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "safe=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Upload E2E diagnostics
+ if: failure() && steps.e2e_diagnostics.outputs.safe == 'true'
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: settings-e2e-${{ github.run_id }}
+ path: |
+ apps/sim/playwright-report/
+ apps/sim/test-results/
+ apps/sim/e2e/.runs/
+ !apps/sim/e2e/.runs/**/auth/**
+ !apps/sim/e2e/.runs/**/private/**
+ !apps/sim/e2e/.runs/**/homes/**
+ if-no-files-found: ignore
+ include-hidden-files: true
+ retention-days: 7
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index a8a0d8e2fde..109495320d0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,6 +18,11 @@ package-lock.json
# testing
/coverage
/apps/**/coverage
+/apps/**/playwright-report/
+/apps/**/test-results/
+/apps/**/e2e/.runs/
+/apps/**/e2e/.auth/
+/apps/**/e2e/.cache/
# next.js
/.next/
@@ -31,6 +36,7 @@ package-lock.json
**/dist/
**/standalone/
sim-standalone.tar.gz
+/.artifacts/
# redis
dump.rdb
diff --git a/apps/docs/content/docs/en/platform/enterprise/sso.mdx b/apps/docs/content/docs/en/platform/enterprise/sso.mdx
index ee3d53be3ec..112ec7f28ef 100644
--- a/apps/docs/content/docs/en/platform/enterprise/sso.mdx
+++ b/apps/docs/content/docs/en/platform/enterprise/sso.mdx
@@ -252,7 +252,7 @@ SSO provisioning creates internal organization members. External workspace membe
},
{
question: "A user already has an account with the same email — what happens when they sign in with SSO?",
- answer: "Sim links the SSO identity to the existing account automatically, as long as your identity provider reports the email as verified (email_verified) or the provider is trusted. Most OIDC providers (Okta, Google Workspace, Auth0) assert email_verified, so linking just works. If sign-in fails with 'account not linked' — common with SAML providers that omit the claim — add the provider's ID to SSO_TRUSTED_PROVIDER_IDS on self-hosted and restart."
+ answer: "Before verified-domain enforcement is activated, Sim links the SSO identity when your identity provider reports a verified email or the provider is operator-trusted. After enforcement is activated, the provider's verified organization domain is the account-linking trust boundary."
},
{
question: "Who can configure SSO on Sim Cloud?",
@@ -281,11 +281,16 @@ Self-hosted deployments use environment variables instead of the billing/plan ch
SSO_ENABLED=true
NEXT_PUBLIC_SSO_ENABLED=true
+# Enable only after the SSO schema migration, provider audit, and approved
+# domain-verification backfill described below.
+SSO_DOMAIN_VERIFICATION_ENABLED=false
+
# Required if you want users auto-added to your organization on first SSO sign-in
ORGANIZATIONS_ENABLED=true
NEXT_PUBLIC_ORGANIZATIONS_ENABLED=true
-# Optional: comma-separated SSO provider IDs to trust for automatic account linking
+# Optional while domain verification is disabled: comma-separated SSO provider IDs
+# to trust for automatic account linking
# (links an SSO sign-in to an existing account with the same email). Needed when your
# IdP does not assert email_verified — typically SAML providers, or OIDC providers that
# omit the claim. Set it to the Provider ID you registered, then restart.
@@ -295,12 +300,27 @@ SSO_TRUSTED_PROVIDER_IDS=custom-oidc,partner-saml
```
- When someone signs in with SSO and an account with the same email already exists
- (for example, they previously signed up with email/password), Sim links the SSO
- identity to that account automatically as long as your IdP reports the email as
- verified, or the provider is trusted. If you hit an `account not linked` error,
- either confirm your IdP sends `email_verified`, or add the provider's ID to
- `SSO_TRUSTED_PROVIDER_IDS` and restart.
+ While `SSO_DOMAIN_VERIFICATION_ENABLED` is false, existing linking behavior uses
+ the IdP's `email_verified` claim or `SSO_TRUSTED_PROVIDER_IDS`. Once the flag is
+ true, Sim requires the SSO provider's organization domain to be verified and no
+ longer trusts an IdP-controlled email-verification claim by itself.
+
+
+
+ For upgrades, leave domain verification disabled until the database migration
+ has landed and
+ `SSO_AUDIT_APPROVED_PROVIDER_IDS=reviewed-provider-ids bun run --cwd packages/db db:audit-sso-providers`
+ passes. The approval list records the providers whose existing account links
+ and active sessions operators chose to retain; omit a provider until its
+ links are migrated or removed and its sessions are revoked. The audit reports
+ linked-user and active-session counts and requires one owner organization and
+ one registrable domain per provider.
+ Remediate legacy user-scoped, public-suffix, duplicate, or overlapping rows;
+ explicitly backfill `domain_verified` only for domains your operators have
+ verified; quiesce SSO writes and rerun the audit immediately before enabling
+ the flag. Internal pseudo-domains are not eligible for verified-domain
+ enforcement. Existing internal-domain providers can remain unchanged while
+ the flag is off, but must move to a registrable domain before activation.
You can register providers through the **Settings UI** (same as cloud) or by running the registration script directly against your database.
@@ -318,6 +338,7 @@ SSO_PROVIDER_ID=okta \
SSO_ISSUER=https://dev-1234567.okta.com/oauth2/default \
SSO_DOMAIN=company.com \
SSO_USER_EMAIL=admin@company.com \
+SSO_ORGANIZATION_ID=your-organization-id \
SSO_OIDC_CLIENT_ID=your-client-id \
SSO_OIDC_CLIENT_SECRET=your-client-secret \
bun run packages/db/scripts/register-sso-provider.ts
@@ -332,6 +353,7 @@ SSO_PROVIDER_ID=adfs \
SSO_ISSUER=https://your-instance.com \
SSO_DOMAIN=company.com \
SSO_USER_EMAIL=admin@company.com \
+SSO_ORGANIZATION_ID=your-organization-id \
SSO_SAML_ENTRY_POINT=https://adfs.company.com/adfs/ls \
SSO_SAML_CERT="-----BEGIN CERTIFICATE-----
...
@@ -345,5 +367,7 @@ To remove a provider:
```bash
SSO_USER_EMAIL=admin@company.com \
+SSO_ORGANIZATION_ID=your-organization-id \
+SSO_PROVIDER_ID=adfs \
bun run packages/db/scripts/deregister-sso-provider.ts
```
diff --git a/apps/realtime/src/env.ts b/apps/realtime/src/env.ts
index 5afdf3a20f3..2f75c4a595f 100644
--- a/apps/realtime/src/env.ts
+++ b/apps/realtime/src/env.ts
@@ -14,6 +14,7 @@ const EnvSchema = z.object({
INTERNAL_API_SECRET: z.string().min(32),
NEXT_PUBLIC_APP_URL: z.string().url(),
ALLOWED_ORIGINS: z.string().optional(),
+ REALTIME_HOST: z.string().min(1).default('0.0.0.0'),
PORT: z.coerce.number().int().positive().default(3002),
SIM_DB_ROLE: z.enum(['web', 'trigger', 'realtime']).optional(),
DISABLE_AUTH: z
diff --git a/apps/realtime/src/index.ts b/apps/realtime/src/index.ts
index e43184c1f02..0c84867156b 100644
--- a/apps/realtime/src/index.ts
+++ b/apps/realtime/src/index.ts
@@ -31,6 +31,7 @@ async function createRoomManager(io: SocketIOServer): Promise {
async function main() {
const httpServer = createServer()
const PORT = env.PORT
+ const HOST = env.REALTIME_HOST
logger.info('Starting Socket.IO server...', {
port: PORT,
@@ -96,9 +97,9 @@ async function main() {
await assertSchemaCompatibility()
- httpServer.listen(PORT, '0.0.0.0', () => {
- logger.info(`Socket.IO server running on port ${PORT}`)
- logger.info(`Health check available at: http://localhost:${PORT}/health`)
+ httpServer.listen(PORT, HOST, () => {
+ logger.info(`Socket.IO server running on ${HOST}:${PORT}`)
+ logger.info(`Health check available at: http://${HOST}:${PORT}/health`)
})
const shutdown = async () => {
diff --git a/apps/realtime/src/routes/http.ts b/apps/realtime/src/routes/http.ts
index 0f8ed73cc52..0bfef282fcf 100644
--- a/apps/realtime/src/routes/http.ts
+++ b/apps/realtime/src/routes/http.ts
@@ -69,6 +69,7 @@ export function createHttpHandler(roomManager: IRoomManager, logger: Logger) {
status: 'ok',
timestamp: new Date().toISOString(),
connections,
+ ...(process.env.E2E_RUN_ID ? { runId: process.env.E2E_RUN_ID } : {}),
})
)
} catch (error) {
diff --git a/apps/sim/app/(landing)/layout.tsx b/apps/sim/app/(landing)/layout.tsx
index 9fef6524507..f9c31e12780 100644
--- a/apps/sim/app/(landing)/layout.tsx
+++ b/apps/sim/app/(landing)/layout.tsx
@@ -11,6 +11,7 @@ import { XPageViewTracker } from '@/app/(landing)/x-page-view-tracker'
const HUBSPOT_SCRIPT_SRC = 'https://js-na2.hs-scripts.com/246720681.js' as const
const X_PIXEL_ID = 'q5xbl' as const
+const isMarketingAnalyticsEnabled = isHosted && !process.env.E2E_PROFILE
/** X (Twitter) conversion tracking base code — loads uwt.js and fires the initial PageView. */
const X_PIXEL_BASE_CODE = `!function(e,t,n,s,u,a){e.twq||(s=e.twq=function(){s.exe?s.exe.apply(s,arguments):s.queue.push(arguments);
@@ -43,7 +44,7 @@ export default function LandingLayout({ children }: { children: ReactNode }) {
{children}
{/* HubSpot + X pixel tracking — hosted only */}
- {isHosted && (
+ {isMarketingAnalyticsEnabled && (
<>