diff --git a/apps/docs/content/docs/en/platform/enterprise/index.mdx b/apps/docs/content/docs/en/platform/enterprise/index.mdx
index 6aee826a8af..f6ab6ad7b11 100644
--- a/apps/docs/content/docs/en/platform/enterprise/index.mdx
+++ b/apps/docs/content/docs/en/platform/enterprise/index.mdx
@@ -81,19 +81,17 @@ Clone a workspace into a linked child, then push or pull **deployed** workflow c
## Self-hosted setup
-Self-hosted deployments enable enterprise features via environment variables instead of billing.
-
-| Variable | Description |
-|----------|-------------|
-| `ORGANIZATIONS_ENABLED`, `NEXT_PUBLIC_ORGANIZATIONS_ENABLED` | Team and organization management |
-| `ACCESS_CONTROL_ENABLED`, `NEXT_PUBLIC_ACCESS_CONTROL_ENABLED` | Permission groups |
-| `SSO_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED` | SAML and OIDC sign-in |
-| `WHITELABELING_ENABLED`, `NEXT_PUBLIC_WHITELABELING_ENABLED` | Custom branding |
-| `AUDIT_LOGS_ENABLED`, `NEXT_PUBLIC_AUDIT_LOGS_ENABLED` | Audit logging |
-| `NEXT_PUBLIC_DATA_RETENTION_ENABLED` | Data retention configuration |
-| `DATA_DRAINS_ENABLED`, `NEXT_PUBLIC_DATA_DRAINS_ENABLED` | Data drains |
-| `FORKING_ENABLED`, `NEXT_PUBLIC_FORKING_ENABLED` | Workspace forking |
-| `INBOX_ENABLED`, `NEXT_PUBLIC_INBOX_ENABLED` | Sim Mailer inbox |
-| `DISABLE_INVITATIONS`, `NEXT_PUBLIC_DISABLE_INVITATIONS` | Disable invitations; manage membership via Admin API |
-
-Once enabled, each feature is configured through the same Settings UI as Sim Cloud. When invitations are disabled, use the Admin API (`x-admin-key` header) to manage organization membership and workspace access. Internal members join the organization; external workspace members only receive access to a specific workspace.
+Self-hosted deployments unlock enterprise features through environment configuration instead of billing. One switch turns on the whole set:
+
+```bash
+ENTERPRISE_ENABLED=true
+NEXT_PUBLIC_ENTERPRISE_ENABLED=true
+```
+
+Each feature also keeps its own flag, so you can enable them one at a time or switch a single feature back off.
+
+Most of these features read their settings from the organization that owns a workspace, so a deployment also needs an organization model — either one instance-wide organization that every user joins automatically, or organizations you provision yourself through the Admin API.
+
+See the [self-hosted enterprise guide](/platform/enterprise/self-hosted) for the full variable list, both organization patterns, the Admin API reference, and troubleshooting.
+
+Once enabled, each feature is configured through the same Settings UI as Sim Cloud. When invitations are disabled (`DISABLE_INVITATIONS`, `NEXT_PUBLIC_DISABLE_INVITATIONS`), use the Admin API (`x-admin-key` header) to manage organization membership and workspace access. Internal members join the organization; external workspace members only receive access to a specific workspace.
diff --git a/apps/docs/content/docs/en/platform/enterprise/meta.json b/apps/docs/content/docs/en/platform/enterprise/meta.json
index 0b5066c495d..cdf420f1ff3 100644
--- a/apps/docs/content/docs/en/platform/enterprise/meta.json
+++ b/apps/docs/content/docs/en/platform/enterprise/meta.json
@@ -2,6 +2,7 @@
"title": "Enterprise",
"pages": [
"index",
+ "self-hosted",
"sso",
"verified-domains",
"session-policies",
diff --git a/apps/docs/content/docs/en/platform/enterprise/self-hosted.mdx b/apps/docs/content/docs/en/platform/enterprise/self-hosted.mdx
new file mode 100644
index 00000000000..db0889249d0
--- /dev/null
+++ b/apps/docs/content/docs/en/platform/enterprise/self-hosted.mdx
@@ -0,0 +1,211 @@
+---
+title: Self-hosted Enterprise
+description: Run the full enterprise feature set on a self-hosted deployment without billing
+---
+
+import { Callout } from 'fumadocs-ui/components/callout'
+import { Step, Steps } from 'fumadocs-ui/components/steps'
+import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
+
+On Sim Cloud, enterprise features are unlocked by an Enterprise subscription. Self-hosted deployments have no subscription, so they are unlocked by environment configuration instead.
+
+There are two parts to getting this right, and skipping the second is the most common reason features appear to do nothing:
+
+1. **Enable the features** with `ENTERPRISE_ENABLED`.
+2. **Give them an organization to apply to.** Whitelabeling, PII redaction, permission groups, data drains, and audit scoping all read their settings from the organization that owns a workspace. A deployment where everyone works in personal workspaces has no organization for those settings to come from.
+
+## Enable the feature set
+
+Set the master switch and its client twin. Both are required — the server value decides access, and the `NEXT_PUBLIC_` value decides what the settings UI shows.
+
+```bash
+ENTERPRISE_ENABLED=true
+NEXT_PUBLIC_ENTERPRISE_ENABLED=true
+```
+
+That turns on organizations, permission groups, SSO, whitelabeling, audit logs, session policies, data retention, data drains, workspace forks, and the inbox.
+
+### Turning one feature off
+
+Every feature keeps its own flag, and an explicitly set flag always wins over the master switch. To run the suite without data drains:
+
+```bash
+ENTERPRISE_ENABLED=true
+NEXT_PUBLIC_ENTERPRISE_ENABLED=true
+DATA_DRAINS_ENABLED=false
+NEXT_PUBLIC_DATA_DRAINS_ENABLED=false
+```
+
+The individual flags also work on their own if you would rather opt in one at a time and leave the master switch unset.
+
+| Feature | Server variable | Client variable |
+|---------|-----------------|-----------------|
+| Everything below | `ENTERPRISE_ENABLED` | `NEXT_PUBLIC_ENTERPRISE_ENABLED` |
+| Organizations | `ORGANIZATIONS_ENABLED` | `NEXT_PUBLIC_ORGANIZATIONS_ENABLED` |
+| Permission groups | `ACCESS_CONTROL_ENABLED` | `NEXT_PUBLIC_ACCESS_CONTROL_ENABLED` |
+| SAML and OIDC sign-in | `SSO_ENABLED` | `NEXT_PUBLIC_SSO_ENABLED` |
+| Custom branding | `WHITELABELING_ENABLED` | `NEXT_PUBLIC_WHITELABELING_ENABLED` |
+| Audit logs | `AUDIT_LOGS_ENABLED` | `NEXT_PUBLIC_AUDIT_LOGS_ENABLED` |
+| Session policies | `SESSION_POLICIES_ENABLED` | `NEXT_PUBLIC_SESSION_POLICIES_ENABLED` |
+| Data retention deletion | `DATA_RETENTION_ENABLED` | `NEXT_PUBLIC_DATA_RETENTION_ENABLED` |
+| Data drains | `DATA_DRAINS_ENABLED` | `NEXT_PUBLIC_DATA_DRAINS_ENABLED` |
+| Workspace forks | `FORKING_ENABLED` | — |
+| Sim Mailer inbox | `INBOX_ENABLED` | `NEXT_PUBLIC_INBOX_ENABLED` |
+
+
+ Data retention is the one feature that deletes data. Its flag controls the cleanup pass, not the settings screen — retention windows are always configurable. Nothing is ever deleted until you enable it, and even then only against windows you configured explicitly. Sim never applies the hosted plan defaults to a self-hosted deployment.
+
+
+## Choose an organization model
+
+### Pattern 1: one organization for the whole instance
+
+Best when everyone on the deployment belongs to the same company. Set a name and every user joins that organization automatically at signup, with their workspaces created org-owned.
+
+```bash
+INSTANCE_ORG_NAME="Acme Inc"
+```
+
+Optionally pin the slug and the owner:
+
+```bash
+INSTANCE_ORG_SLUG=acme-inc
+INSTANCE_ORG_OWNER_EMAIL=admin@acme.com
+```
+
+The organization is created the first time a user signs up. If `INSTANCE_ORG_OWNER_EMAIL` is not set, or names a user who does not exist yet, the first user to sign up becomes the owner; move ownership later with the Admin API. Provisioning is idempotent and safe across multiple replicas.
+
+
+ Instance-organization mode only applies when billing is disabled. With billing enabled, organizations are created through the normal subscription flow and these variables are ignored.
+
+
+#### Existing deployments
+
+Users and workspaces created before you set `INSTANCE_ORG_NAME` stay where they are. Move them across once with the backfill script, which adds every user to the organization and attaches their workspaces:
+
+```bash
+# Preview
+DATABASE_URL=... INSTANCE_ORG_NAME="Acme Inc" \
+ bun run apps/sim/scripts/consolidate-users-into-organization.ts
+
+# Apply
+DATABASE_URL=... INSTANCE_ORG_NAME="Acme Inc" \
+ bun run apps/sim/scripts/consolidate-users-into-organization.ts --apply
+```
+
+It is a dry run unless you pass `--apply`, and it is safe to re-run. Users who already belong to a different organization are reported and skipped, since a user can only belong to one.
+
+### Pattern 2: many organizations you manage yourself
+
+Best when one deployment serves several teams that should not see each other's data. Leave `INSTANCE_ORG_NAME` unset and provision organizations through the Admin API.
+
+Set an admin key first:
+
+```bash
+ADMIN_API_KEY=$(openssl rand -hex 32)
+```
+
+
+
+### Create an organization
+
+The owner must not already belong to another organization.
+
+```bash
+curl -X POST https://sim.example.com/api/v1/admin/organizations \
+ -H "x-admin-key: $ADMIN_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"name": "Acme Inc", "ownerId": "user_123", "slug": "acme-inc"}'
+```
+
+
+
+### Add members
+
+```bash
+curl -X POST https://sim.example.com/api/v1/admin/organizations/$ORG_ID/members \
+ -H "x-admin-key: $ADMIN_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"userId": "user_456", "role": "member"}'
+```
+
+
+
+### Move a workspace into the organization
+
+Organization-scoped features only apply to workspaces the organization owns.
+
+```bash
+curl -X POST https://sim.example.com/api/v1/admin/dashboard/workspaces/$WORKSPACE_ID/move \
+ -H "x-admin-key: $ADMIN_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d "{\"destinationOrganizationId\": \"$ORG_ID\"}"
+```
+
+
+
+### Configure organization settings
+
+Branding, retention, and session policies can be set from the API instead of the UI.
+
+
+
+```bash
+curl -X PATCH https://sim.example.com/api/v1/admin/organizations/$ORG_ID/whitelabel \
+ -H "x-admin-key: $ADMIN_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"brandName": "Acme AI", "hidePoweredBySim": true}'
+```
+
+
+```bash
+curl -X PATCH https://sim.example.com/api/v1/admin/organizations/$ORG_ID/data-retention \
+ -H "x-admin-key: $ADMIN_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"logRetentionHours": 2160}'
+```
+
+
+```bash
+curl -X PATCH https://sim.example.com/api/v1/admin/organizations/$ORG_ID/session-policy \
+ -H "x-admin-key: $ADMIN_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"maxSessionHours": 168, "idleTimeoutHours": 48}'
+```
+
+
+
+
+
+Deleting an organization requires echoing its slug, because the delete cascades to members, invitations, and permission groups, and detaches its workspaces:
+
+```bash
+curl -X DELETE "https://sim.example.com/api/v1/admin/organizations/$ORG_ID?confirmSlug=acme-inc" \
+ -H "x-admin-key: $ADMIN_API_KEY"
+```
+
+## Verifying it worked
+
+If a feature is enabled but nothing appears, check these in order.
+
+**The settings section is missing.** The `NEXT_PUBLIC_` twin is not set, or the app was not restarted after adding it. Client variables are read at build and boot.
+
+**The section appears but the API returns 403.** The server-side variable is missing while its client twin is set. Set both.
+
+**The feature is on but has no effect inside a workspace.** The workspace is not owned by an organization. Check `workspace_mode` and `organization_id`:
+
+```sql
+SELECT id, name, workspace_mode, organization_id FROM workspace;
+```
+
+A workspace showing `personal` or a null `organization_id` will not pick up branding, PII redaction, permission groups, or drains. Use the backfill script or the workspace move endpoint.
+
+**Retention is configured but nothing is deleted.** `DATA_RETENTION_ENABLED` is unset. Configuring windows and running the cleanup pass are separate switches by design.
+
+## Related
+
+- [Environment variables](/platform/self-hosting/environment-variables)
+- [Single Sign-On](/platform/enterprise/sso)
+- [Access control](/platform/enterprise/access-control)
+- [Roles and permissions](/platform/permissions)
+- [Workspaces](/platform/workspaces)
diff --git a/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx b/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx
index 0e084dfc858..7e8f86e958c 100644
--- a/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx
+++ b/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx
@@ -70,6 +70,19 @@ import { Callout } from 'fumadocs-ui/components/callout'
| `ALLOWED_LOGIN_EMAILS` | Restrict signups to specific emails (comma-separated) |
| `DISABLE_REGISTRATION` | Set to `true` to disable new user signups |
+## Enterprise Features
+
+Enterprise features are unlocked by configuration rather than billing on self-hosted deployments. One switch turns on the full set; per-feature flags below it override the switch either way.
+
+| Variable | Description |
+|----------|-------------|
+| `ENTERPRISE_ENABLED`, `NEXT_PUBLIC_ENTERPRISE_ENABLED` | Enable the whole enterprise feature set |
+| `INSTANCE_ORG_NAME` | Name of the organization every user joins automatically at signup |
+| `INSTANCE_ORG_SLUG` | Slug for that organization (derived from the name when omitted) |
+| `INSTANCE_ORG_OWNER_EMAIL` | Owner of that organization (defaults to the first user to sign up) |
+
+Most enterprise features read their settings from the organization that owns a workspace, so enabling the flags alone is not enough — the deployment also needs an organization model. See the [self-hosted enterprise guide](/platform/enterprise/self-hosted) for the per-feature flags, both organization patterns, and the Admin API.
+
## File Storage
By default Sim writes uploads to local disk. For production, point it at AWS S3, Azure Blob, or Google Cloud Storage. See [Object Storage](/platform/self-hosting/object-storage) for the full setup, bucket layout, and IAM policy.
diff --git a/apps/docs/content/docs/en/platform/self-hosting/index.mdx b/apps/docs/content/docs/en/platform/self-hosting/index.mdx
index c31c30423b7..1cecb325ac0 100644
--- a/apps/docs/content/docs/en/platform/self-hosting/index.mdx
+++ b/apps/docs/content/docs/en/platform/self-hosting/index.mdx
@@ -55,6 +55,19 @@ Open [http://localhost:3000](http://localhost:3000)
+## Enterprise Features
+
+Organizations, SSO, permission groups, audit logs, whitelabeling, session policies, data retention, and data drains all run on a self-hosted deployment — no billing or subscription required. One switch turns on the set:
+
+```bash
+ENTERPRISE_ENABLED=true
+NEXT_PUBLIC_ENTERPRISE_ENABLED=true
+```
+
+Most of these features read their settings from the organization that owns a workspace, so enabling the flags is only half of it — your deployment also needs an organization model. Set `INSTANCE_ORG_NAME` to put every user in one shared organization automatically, or provision organizations yourself through the Admin API.
+
+See the [self-hosted enterprise guide](/platform/enterprise/self-hosted) for both patterns, the per-feature flags, and troubleshooting.
+
## Architecture
| Component | Port | Description |
diff --git a/apps/sim/.env.example b/apps/sim/.env.example
index 795a002a6ad..ef123188499 100644
--- a/apps/sim/.env.example
+++ b/apps/sim/.env.example
@@ -141,6 +141,34 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener
# ADMIN_API_KEY= # Use `openssl rand -hex 32` to generate. Enables admin API for workflow export/import.
# Usage: curl -H "x-admin-key: your_key" https://your-instance/api/v1/admin/workspaces
+# Enterprise Features (Optional - self-hosted). One switch enables organizations, SSO,
+# permission groups, audit logs, whitelabeling, session policies, data retention, data
+# drains, forks, and the inbox. Set both — the server value grants access, the
+# NEXT_PUBLIC_ value decides what the settings UI shows.
+# Docs: https://docs.sim.ai/platform/enterprise/self-hosted
+# ENTERPRISE_ENABLED=true
+# NEXT_PUBLIC_ENTERPRISE_ENABLED=true
+
+# Per-feature overrides. An explicitly set flag wins over ENTERPRISE_ENABLED, so use these
+# to enable one feature on its own or to switch a single feature back off.
+# ACCESS_CONTROL_ENABLED= / NEXT_PUBLIC_ACCESS_CONTROL_ENABLED= # Permission groups
+# SSO_ENABLED= / NEXT_PUBLIC_SSO_ENABLED= # SAML and OIDC sign-in
+# WHITELABELING_ENABLED= / NEXT_PUBLIC_WHITELABELING_ENABLED= # Custom branding
+# AUDIT_LOGS_ENABLED= / NEXT_PUBLIC_AUDIT_LOGS_ENABLED= # Audit logging
+# SESSION_POLICIES_ENABLED= / NEXT_PUBLIC_SESSION_POLICIES_ENABLED=
+# DATA_RETENTION_ENABLED= / NEXT_PUBLIC_DATA_RETENTION_ENABLED= # Runs retention deletion — off by default
+# DATA_DRAINS_ENABLED= / NEXT_PUBLIC_DATA_DRAINS_ENABLED= # Export streams
+# FORKING_ENABLED= # Workspace forks
+# ORGANIZATIONS_ENABLED= / NEXT_PUBLIC_ORGANIZATIONS_ENABLED= # Organizations only
+
+# Instance organization (Optional). Most enterprise features read their settings from the
+# organization that owns a workspace, so a deployment needs an organization for them to
+# apply. Setting a name puts every user in one shared org at signup and makes their
+# workspaces org-owned. Leave unset to manage organizations yourself via the Admin API.
+# INSTANCE_ORG_NAME=Acme Inc
+# INSTANCE_ORG_SLUG=acme-inc # Optional — derived from the name when unset
+# INSTANCE_ORG_OWNER_EMAIL=admin@acme.com # Optional — defaults to the first user to sign up
+
# Limits (Optional - self-hosted). With billing disabled (BILLING_ENABLED unset), no plan
# limits are enforced. Explicitly setting a free-tier variable below opts that specific
# limit back in at the configured value.
diff --git a/apps/sim/app/(auth)/components/sso-login-button.tsx b/apps/sim/app/(auth)/components/sso-login-button.tsx
index ddca42526ca..b675cd5e01c 100644
--- a/apps/sim/app/(auth)/components/sso-login-button.tsx
+++ b/apps/sim/app/(auth)/components/sso-login-button.tsx
@@ -1,7 +1,7 @@
'use client'
import { Chip, cn } from '@sim/emcn'
import { useRouter } from 'next/navigation'
-import { getEnv, isTruthy } from '@/lib/core/config/env'
+import { isSsoEnabled } from '@/lib/core/config/env-flags'
import { AUTH_BUTTON_CLASS } from '@/app/(auth)/components/constants'
interface SSOLoginButtonProps {
@@ -17,7 +17,7 @@ export function SSOLoginButton({
}: SSOLoginButtonProps) {
const router = useRouter()
- if (!isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))) {
+ if (!isSsoEnabled) {
return null
}
diff --git a/apps/sim/app/(auth)/login/login-form.tsx b/apps/sim/app/(auth)/login/login-form.tsx
index 359dd2f2671..a2349c428eb 100644
--- a/apps/sim/app/(auth)/login/login-form.tsx
+++ b/apps/sim/app/(auth)/login/login-form.tsx
@@ -16,7 +16,8 @@ import { useRouter, useSearchParams } from 'next/navigation'
import { requestJson } from '@/lib/api/client/request'
import { forgetPasswordContract } from '@/lib/api/contracts'
import { client } from '@/lib/auth/auth-client'
-import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env'
+import { getEnv, isFalsy } from '@/lib/core/config/env'
+import { isSsoEnabled } from '@/lib/core/config/env-flags'
import { validateCallbackUrl } from '@/lib/core/security/input-validation'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
@@ -343,7 +344,7 @@ export default function LoginPage({
}
}
- const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))
+ const ssoEnabled = isSsoEnabled
const emailEnabled = !isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED'))
const hasSocial = githubAvailable || googleAvailable || microsoftAvailable
const hasOnlySSO = ssoEnabled && !emailEnabled && !hasSocial
diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx
index f55bdfac5c9..dfd0428f30b 100644
--- a/apps/sim/app/(auth)/signup/signup-form.tsx
+++ b/apps/sim/app/(auth)/signup/signup-form.tsx
@@ -6,7 +6,8 @@ import { createLogger } from '@sim/logger'
import { useRouter, useSearchParams } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
import { client, useSession } from '@/lib/auth/auth-client'
-import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env'
+import { getEnv, isFalsy } from '@/lib/core/config/env'
+import { isSsoEnabled } from '@/lib/core/config/env-flags'
import { validateCallbackUrl } from '@/lib/core/security/input-validation'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { captureClientEvent, captureEvent } from '@/lib/posthog/client'
@@ -360,7 +361,7 @@ function SignupFormContent({
}
}
- const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))
+ const ssoEnabled = isSsoEnabled
const emailEnabled =
!isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) && emailSignupEnabled
const hasSocial = githubAvailable || googleAvailable || microsoftAvailable
diff --git a/apps/sim/app/(auth)/sso/page.tsx b/apps/sim/app/(auth)/sso/page.tsx
index db865bec590..b1e27f22608 100644
--- a/apps/sim/app/(auth)/sso/page.tsx
+++ b/apps/sim/app/(auth)/sso/page.tsx
@@ -1,7 +1,7 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
-import { getEnv, isTruthy } from '@/lib/core/config/env'
+import { isSsoEnabled } from '@/lib/core/config/env-flags'
import SSOForm from '@/ee/sso/components/sso-form'
export const metadata: Metadata = {
@@ -11,7 +11,7 @@ export const metadata: Metadata = {
export const dynamic = 'force-dynamic'
export default async function SSOPage() {
- if (!isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))) {
+ if (!isSsoEnabled) {
redirect('/login')
}
diff --git a/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx b/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx
index 5252032a921..b0b7aa127d3 100644
--- a/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx
+++ b/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx
@@ -18,7 +18,8 @@ import { GithubIcon, GoogleIcon, MicrosoftIcon } from '@/components/icons'
import { requestJson } from '@/lib/api/client/request'
import { type AuthProviderStatusResponse, getAuthProvidersContract } from '@/lib/api/contracts/auth'
import { client } from '@/lib/auth/auth-client'
-import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env'
+import { getEnv, isFalsy } from '@/lib/core/config/env'
+import { isSsoEnabled } from '@/lib/core/config/env-flags'
import { captureClientEvent } from '@/lib/posthog/client'
import type { PostHogEventMap } from '@/lib/posthog/events'
import { getBrandConfig } from '@/ee/whitelabeling'
@@ -75,7 +76,7 @@ export function AuthModal({ children, defaultView = 'login', source }: AuthModal
fetchProviderStatus().then(setProviderStatus)
}, [])
- const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))
+ const ssoEnabled = isSsoEnabled
const emailEnabled = !isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED'))
/**
diff --git a/apps/sim/app/api/auth/sso/register/route.test.ts b/apps/sim/app/api/auth/sso/register/route.test.ts
index 02d10f217d1..3a4e5c752b2 100644
--- a/apps/sim/app/api/auth/sso/register/route.test.ts
+++ b/apps/sim/app/api/auth/sso/register/route.test.ts
@@ -7,9 +7,11 @@ import {
dbChainMockFns,
queueTableRows,
resetDbChainMock,
+ resetEnvFlagsMock,
resetEnvMock,
schemaMock,
setEnv,
+ setEnvFlags,
} from '@sim/testing'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -96,6 +98,11 @@ describe('POST /api/auth/sso/register', () => {
vi.clearAllMocks()
resetDbChainMock()
setEnv({ SSO_ENABLED: 'true' })
+ /**
+ * The route gates on the resolved `isSsoEnabled` rather than the raw env
+ * var, so the suite switch (`ENTERPRISE_ENABLED`) can register SSO too.
+ */
+ setEnvFlags({ isSsoEnabled: true })
mockGetSession.mockResolvedValue({ user: { id: 'u1' } })
mockHasSSOAccess.mockResolvedValue(true)
mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '1.2.3.4' })
@@ -116,6 +123,7 @@ describe('POST /api/auth/sso/register', () => {
afterAll(() => {
resetDbChainMock()
resetEnvMock()
+ resetEnvFlagsMock()
})
it('rejects callers without an Enterprise plan', async () => {
diff --git a/apps/sim/app/api/auth/sso/register/route.ts b/apps/sim/app/api/auth/sso/register/route.ts
index 0c78d76f216..8defefba9b8 100644
--- a/apps/sim/app/api/auth/sso/register/route.ts
+++ b/apps/sim/app/api/auth/sso/register/route.ts
@@ -8,7 +8,7 @@ import { ssoRegistrationContract } from '@/lib/api/contracts/auth'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { auth, getSession } from '@/lib/auth'
import { hasSSOAccess } from '@/lib/billing'
-import { env } from '@/lib/core/config/env'
+import { isSsoEnabled } from '@/lib/core/config/env-flags'
import {
secureFetchWithPinnedIP,
validateUrlWithDNS,
@@ -68,7 +68,7 @@ async function fetchOIDCDiscoveryDocument(discoveryUrl: string): Promise {
try {
- if (!env.SSO_ENABLED) {
+ if (!isSsoEnabled) {
return NextResponse.json({ error: 'SSO is not enabled' }, { status: 400 })
}
diff --git a/apps/sim/app/api/organizations/[id]/data-retention/route.ts b/apps/sim/app/api/organizations/[id]/data-retention/route.ts
index d8e71ebf435..2b85f41795a 100644
--- a/apps/sim/app/api/organizations/[id]/data-retention/route.ts
+++ b/apps/sim/app/api/organizations/[id]/data-retention/route.ts
@@ -1,9 +1,9 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import type { DataRetentionSettings } from '@sim/db/schema'
-import { member, organization, workspace } from '@sim/db/schema'
+import { member, organization } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
-import { and, eq, inArray } from 'drizzle-orm'
+import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import {
type OrganizationRetentionValues,
@@ -13,6 +13,10 @@ import { parseRequest, validationErrorResponse } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { CLEANUP_CONFIG } from '@/lib/billing/cleanup-dispatcher'
import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription'
+import {
+ getForeignWorkspaceTargetsReason,
+ getPiiRedactionDenialReason,
+} from '@/lib/billing/retention'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -65,27 +69,6 @@ function normalizeConfigured(
}
}
-/**
- * Which granular stages (`input`/`blockOutputs`) are already enabled per rule
- * target (`workspaceId ?? ''` = the org default). Used to gate the
- * `pii-granular-redaction` flag on *new* enablement only: when the flag is off,
- * an org that already configured granular stages must still be able to re-save
- * unrelated settings (the UI re-sends the full PII snapshot every save), so we
- * reject only a stage transitioning off→on, never a preserved one.
- */
-function granularStageEnablement(
- settings: OrganizationRetentionValues['piiRedaction']
-): Map {
- const map = new Map()
- for (const rule of settings?.rules ?? []) {
- map.set(rule.workspaceId ?? '', {
- input: rule.stages?.input?.enabled === true,
- blockOutputs: rule.stages?.blockOutputs?.enabled === true,
- })
- }
- return map
-}
-
/**
* GET /api/organizations/[id]/data-retention
* Returns the organization's data retention settings.
@@ -225,34 +208,14 @@ export const PUT = withRouteHandler(
merged.taskCleanupHours = body.taskCleanupHours
}
if (body.piiRedaction !== undefined) {
- if (!piiRedactionEnabled) {
- return NextResponse.json(
- { error: 'PII redaction is not enabled for this organization' },
- { status: 403 }
- )
- }
- if (!piiGranularRedactionEnabled) {
- // Reject only a granular stage transitioning off→on; a body that merely
- // preserves already-enabled granular stages must still save (the UI
- // re-sends the full snapshot on every save), so existing orgs aren't
- // locked out of unrelated retention changes when the flag is off.
- const currentGranular = granularStageEnablement(current.piiRedaction)
- const newlyEnablesGranular = (body.piiRedaction?.rules ?? []).some((rule) => {
- const cur = currentGranular.get(rule.workspaceId ?? '')
- return (
- (rule.stages?.input?.enabled === true && !cur?.input) ||
- (rule.stages?.blockOutputs?.enabled === true && !cur?.blockOutputs)
- )
- })
- if (newlyEnablesGranular) {
- return NextResponse.json(
- {
- error:
- 'Granular PII redaction (workflow input and block outputs) is not enabled for this organization',
- },
- { status: 403 }
- )
- }
+ const denialReason = getPiiRedactionDenialReason({
+ current: current.piiRedaction,
+ incoming: body.piiRedaction,
+ piiRedactionEnabled,
+ piiGranularRedactionEnabled,
+ })
+ if (denialReason) {
+ return NextResponse.json({ error: denialReason }, { status: 403 })
}
merged.piiRedaction = body.piiRedaction
}
@@ -260,27 +223,13 @@ export const PUT = withRouteHandler(
merged.retentionOverrides = body.retentionOverrides
}
- const targetedWorkspaceIds = new Set()
- for (const override of body.retentionOverrides ?? []) {
- targetedWorkspaceIds.add(override.workspaceId)
- }
- for (const rule of body.piiRedaction?.rules ?? []) {
- if (rule.workspaceId) targetedWorkspaceIds.add(rule.workspaceId)
- }
- if (targetedWorkspaceIds.size > 0) {
- const ids = [...targetedWorkspaceIds]
- const orgWorkspaces = await db
- .select({ id: workspace.id })
- .from(workspace)
- .where(and(eq(workspace.organizationId, organizationId), inArray(workspace.id, ids)))
- const known = new Set(orgWorkspaces.map((row) => row.id))
- const unknown = ids.filter((id) => !known.has(id))
- if (unknown.length > 0) {
- return NextResponse.json(
- { error: `Override targets workspaces outside this organization: ${unknown.join(', ')}` },
- { status: 400 }
- )
- }
+ const foreignTargetsReason = await getForeignWorkspaceTargetsReason({
+ organizationId,
+ retentionOverrides: body.retentionOverrides,
+ piiRedaction: body.piiRedaction,
+ })
+ if (foreignTargetsReason) {
+ return NextResponse.json({ error: foreignTargetsReason }, { status: 400 })
}
const [updated] = await db
diff --git a/apps/sim/app/api/organizations/[id]/session-policy/route.test.ts b/apps/sim/app/api/organizations/[id]/session-policy/route.test.ts
index c8722a51154..65183a0e83d 100644
--- a/apps/sim/app/api/organizations/[id]/session-policy/route.test.ts
+++ b/apps/sim/app/api/organizations/[id]/session-policy/route.test.ts
@@ -28,8 +28,14 @@ vi.mock('@/lib/auth/security-policy', () => ({
invalidateSecurityPolicyVersionCache: vi.fn(),
}))
+/**
+ * These tests run with billing enabled, where `isOrganizationFeatureEntitled`
+ * delegates straight to the plan check — so both names resolve to the same
+ * mock and `mockIsEnterprise` keeps steering the gate.
+ */
vi.mock('@/lib/billing/core/subscription', () => ({
isOrganizationOnEnterprisePlan: mockIsEnterprise,
+ isOrganizationFeatureEntitled: mockIsEnterprise,
}))
vi.mock('@sim/audit', () => ({
diff --git a/apps/sim/app/api/organizations/[id]/session-policy/route.ts b/apps/sim/app/api/organizations/[id]/session-policy/route.ts
index d3ce9d53bc3..fca76741506 100644
--- a/apps/sim/app/api/organizations/[id]/session-policy/route.ts
+++ b/apps/sim/app/api/organizations/[id]/session-policy/route.ts
@@ -11,8 +11,8 @@ import { parseRequest, validationErrorResponse } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { invalidateSecurityPolicyVersionCache } from '@/lib/auth/security-policy'
import { eagerClampOrgSessions, invalidateSessionPolicyCache } from '@/lib/auth/session-policy'
-import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription'
-import { isBillingEnabled } from '@/lib/core/config/env-flags'
+import { isOrganizationFeatureEntitled } from '@/lib/billing/core/subscription'
+import { isBillingEnabled, isSessionPoliciesEnabled } from '@/lib/core/config/env-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('SessionPolicyAPI')
@@ -60,7 +60,10 @@ export const GET = withRouteHandler(
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
}
- const isEnterprise = !isBillingEnabled || (await isOrganizationOnEnterprisePlan(organizationId))
+ const isEnterprise = await isOrganizationFeatureEntitled(
+ organizationId,
+ isSessionPoliciesEnabled
+ )
return NextResponse.json({
success: true,
@@ -113,14 +116,16 @@ export const PUT = withRouteHandler(
)
}
- if (isBillingEnabled) {
- const hasEnterprise = await isOrganizationOnEnterprisePlan(organizationId)
- if (!hasEnterprise) {
- return NextResponse.json(
- { error: 'Session policies are available on Enterprise plans only' },
- { status: 403 }
- )
- }
+ const entitled = await isOrganizationFeatureEntitled(organizationId, isSessionPoliciesEnabled)
+ if (!entitled) {
+ return NextResponse.json(
+ {
+ error: isBillingEnabled
+ ? 'Session policies are available on Enterprise plans only'
+ : 'Session policies are disabled. Set ENTERPRISE_ENABLED or SESSION_POLICIES_ENABLED to enable them.',
+ },
+ { status: 403 }
+ )
}
const [currentOrg] = await db
diff --git a/apps/sim/app/api/organizations/[id]/whitelabel/route.ts b/apps/sim/app/api/organizations/[id]/whitelabel/route.ts
index f74309815a5..d2812453bfc 100644
--- a/apps/sim/app/api/organizations/[id]/whitelabel/route.ts
+++ b/apps/sim/app/api/organizations/[id]/whitelabel/route.ts
@@ -7,8 +7,9 @@ import { type NextRequest, NextResponse } from 'next/server'
import { updateOrganizationWhitelabelContract } from '@/lib/api/contracts/organization'
import { parseRequest, validationErrorResponse } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
-import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription'
+import { isOrganizationFeatureEntitled } from '@/lib/billing/core/subscription'
import type { OrganizationWhitelabelSettings } from '@/lib/branding/types'
+import { isBillingEnabled, isWhitelabelingEnabled } from '@/lib/core/config/env-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('WhitelabelAPI')
@@ -107,11 +108,15 @@ export const PUT = withRouteHandler(
)
}
- const hasEnterprisePlan = await isOrganizationOnEnterprisePlan(organizationId)
+ const entitled = await isOrganizationFeatureEntitled(organizationId, isWhitelabelingEnabled)
- if (!hasEnterprisePlan) {
+ if (!entitled) {
return NextResponse.json(
- { error: 'Whitelabeling is available on Enterprise plans only' },
+ {
+ error: isBillingEnabled
+ ? 'Whitelabeling is available on Enterprise plans only'
+ : 'Whitelabeling is disabled. Set ENTERPRISE_ENABLED or WHITELABELING_ENABLED to enable it.',
+ },
{ status: 403 }
)
}
diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/data-retention/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/data-retention/route.ts
new file mode 100644
index 00000000000..355e49f0d55
--- /dev/null
+++ b/apps/sim/app/api/v1/admin/organizations/[id]/data-retention/route.ts
@@ -0,0 +1,153 @@
+/**
+ * PATCH /api/v1/admin/organizations/[id]/data-retention
+ *
+ * Set an organization's data-retention settings without going through the
+ * settings UI, so a self-hosted deployment can provision retention windows and
+ * PII redaction rules from its own configuration management.
+ *
+ * Retention windows only take effect once the cleanup pass is enabled — set
+ * `DATA_RETENTION_ENABLED` (or `ENTERPRISE_ENABLED`) when billing is off.
+ *
+ * Body: any subset of `logRetentionHours`, `softDeleteRetentionHours`,
+ * `taskCleanupHours`, `piiRedaction`, `retentionOverrides`. Omitted keys keep
+ * their current value; `null` means "forever" for an hours field.
+ *
+ * Response: AdminSingleResponse<{ success, organizationId }>
+ */
+
+import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
+import { db } from '@sim/db'
+import type { DataRetentionSettings } from '@sim/db/schema'
+import { organization } from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
+import { eq } from 'drizzle-orm'
+import { adminV1UpdateOrganizationDataRetentionContract } from '@/lib/api/contracts/v1/admin'
+import { parseRequest } from '@/lib/api/server'
+import {
+ getForeignWorkspaceTargetsReason,
+ getPiiRedactionDenialReason,
+} from '@/lib/billing/retention'
+import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
+import {
+ adminInvalidJsonResponse,
+ adminValidationErrorResponse,
+ badRequestResponse,
+ forbiddenResponse,
+ internalErrorResponse,
+ notFoundResponse,
+ singleResponse,
+} from '@/app/api/v1/admin/responses'
+
+const logger = createLogger('AdminOrganizationDataRetentionAPI')
+
+interface RouteParams {
+ id: string
+}
+
+export const PATCH = withRouteHandler(
+ withAdminAuthParams(async (request, context) => {
+ const parsed = await parseRequest(
+ adminV1UpdateOrganizationDataRetentionContract,
+ request,
+ context,
+ {
+ validationErrorResponse: adminValidationErrorResponse,
+ invalidJsonResponse: adminInvalidJsonResponse,
+ }
+ )
+ if (!parsed.success) return parsed.response
+
+ const { id: organizationId } = parsed.data.params
+ const body = parsed.data.body
+
+ try {
+ const [existing] = await db
+ .select({
+ name: organization.name,
+ dataRetentionSettings: organization.dataRetentionSettings,
+ })
+ .from(organization)
+ .where(eq(organization.id, organizationId))
+ .limit(1)
+
+ if (!existing) {
+ return notFoundResponse('Organization')
+ }
+
+ const merged: DataRetentionSettings = { ...(existing.dataRetentionSettings ?? {}) }
+ if (body.logRetentionHours !== undefined) merged.logRetentionHours = body.logRetentionHours
+ if (body.softDeleteRetentionHours !== undefined) {
+ merged.softDeleteRetentionHours = body.softDeleteRetentionHours
+ }
+ if (body.taskCleanupHours !== undefined) merged.taskCleanupHours = body.taskCleanupHours
+
+ if (body.piiRedaction !== undefined) {
+ /**
+ * The same gate the settings UI applies. An admin key authenticates an
+ * operator, not an entitlement — without this check it would be a way
+ * to switch on PII redaction that the product does not offer this
+ * organization.
+ */
+ const [piiRedactionEnabled, piiGranularRedactionEnabled] = await Promise.all([
+ isFeatureEnabled('pii-redaction'),
+ isFeatureEnabled('pii-granular-redaction'),
+ ])
+ const denialReason = getPiiRedactionDenialReason({
+ current: existing.dataRetentionSettings?.piiRedaction,
+ incoming: body.piiRedaction,
+ piiRedactionEnabled,
+ piiGranularRedactionEnabled,
+ })
+ if (denialReason) return forbiddenResponse(denialReason)
+
+ merged.piiRedaction = body.piiRedaction
+ }
+
+ if (body.retentionOverrides !== undefined) {
+ merged.retentionOverrides = body.retentionOverrides
+ }
+
+ /**
+ * Same ownership check the settings UI applies. Neither `workspaceId`
+ * field is a foreign key, so without it the Admin API could persist an
+ * override naming another organization's workspace.
+ */
+ const foreignTargetsReason = await getForeignWorkspaceTargetsReason({
+ organizationId,
+ retentionOverrides: body.retentionOverrides,
+ piiRedaction: body.piiRedaction,
+ })
+ if (foreignTargetsReason) {
+ return badRequestResponse(foreignTargetsReason)
+ }
+
+ await db
+ .update(organization)
+ .set({ dataRetentionSettings: merged, updatedAt: new Date() })
+ .where(eq(organization.id, organizationId))
+
+ logger.info(`Admin API: Updated data retention for organization ${organizationId}`, {
+ fields: Object.keys(body),
+ })
+
+ recordAudit({
+ workspaceId: null,
+ actorId: 'admin-api',
+ action: AuditAction.ORGANIZATION_UPDATED,
+ resourceType: AuditResourceType.ORGANIZATION,
+ resourceId: organizationId,
+ resourceName: existing.name,
+ description: `Admin API updated data retention for "${existing.name}"`,
+ metadata: { fields: Object.keys(body) },
+ request,
+ })
+
+ return singleResponse({ success: true as const, organizationId })
+ } catch (error) {
+ logger.error('Admin API: Failed to update data retention', { error, organizationId })
+ return internalErrorResponse('Failed to update data retention')
+ }
+ })
+)
diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/route.test.ts b/apps/sim/app/api/v1/admin/organizations/[id]/route.test.ts
new file mode 100644
index 00000000000..216d88bbd71
--- /dev/null
+++ b/apps/sim/app/api/v1/admin/organizations/[id]/route.test.ts
@@ -0,0 +1,143 @@
+/**
+ * @vitest-environment node
+ */
+import { createMockRequest, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing'
+import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockDetachOrganizationWorkspacesTx, mockDelete, mockAuthenticateAdminRequest } = vi.hoisted(
+ () => ({
+ mockDetachOrganizationWorkspacesTx: vi.fn(),
+ mockDelete: vi.fn(),
+ mockAuthenticateAdminRequest: vi.fn(),
+ })
+)
+
+vi.mock('@/lib/workspaces/organization-workspaces', () => ({
+ detachOrganizationWorkspacesTx: mockDetachOrganizationWorkspacesTx,
+}))
+
+vi.mock('@/app/api/v1/admin/auth', () => ({
+ authenticateAdminRequest: mockAuthenticateAdminRequest,
+}))
+
+vi.mock('@sim/audit', () => ({
+ recordAudit: vi.fn(),
+ recordAuditBatch: vi.fn(),
+ AuditAction: {
+ ORGANIZATION_UPDATED: 'organization.updated',
+ ORGANIZATION_DELETED: 'organization.deleted',
+ },
+ AuditResourceType: { ORGANIZATION: 'organization' },
+}))
+
+import { DELETE } from '@/app/api/v1/admin/organizations/[id]/route'
+
+const ORG_ID = 'org-1'
+const routeContext = { params: Promise.resolve({ id: ORG_ID }) }
+
+function deleteRequest(confirmSlug?: string) {
+ const query = confirmSlug === undefined ? '' : `?confirmSlug=${encodeURIComponent(confirmSlug)}`
+ return createMockRequest(
+ 'DELETE',
+ undefined,
+ {},
+ `http://localhost:3000/api/v1/admin/organizations/${ORG_ID}${query}`
+ )
+}
+
+/** Queues the organization lookup the handler runs before any guard. */
+function queueOrganization(slug = 'acme-inc') {
+ queueTableRows(schemaMock.organization, [{ id: ORG_ID, name: 'Acme', slug }])
+}
+
+describe('admin organization DELETE', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ resetDbChainMock()
+ mockAuthenticateAdminRequest.mockReturnValue({ authenticated: true })
+ mockDetachOrganizationWorkspacesTx.mockResolvedValue({
+ detachedWorkspaceIds: ['ws-1', 'ws-2'],
+ billedAccountUserId: 'user-1',
+ /** Returned rather than written, so the caller can emit them post-commit. */
+ auditEntries: [],
+ })
+ mockDelete.mockClear()
+ })
+
+ afterAll(resetDbChainMock)
+
+ it('returns 404 when the organization does not exist', async () => {
+ queueTableRows(schemaMock.organization, [])
+
+ const response = await DELETE(deleteRequest('acme-inc'), routeContext)
+
+ expect(response.status).toBe(404)
+ expect(mockDetachOrganizationWorkspacesTx).not.toHaveBeenCalled()
+ })
+
+ it('refuses when confirmSlug does not match the organization slug', async () => {
+ queueOrganization('acme-inc')
+
+ const response = await DELETE(deleteRequest('wrong-slug'), routeContext)
+
+ expect(response.status).toBe(400)
+ expect(mockDetachOrganizationWorkspacesTx).not.toHaveBeenCalled()
+ })
+
+ it('refuses with 409 while a subscription still references the organization', async () => {
+ queueOrganization()
+ queueTableRows(schemaMock.subscription, [{ id: 'sub-1', plan: 'enterprise' }])
+
+ const response = await DELETE(deleteRequest('acme-inc'), routeContext)
+
+ /**
+ * `subscription.reference_id` has no foreign key, so deleting here would
+ * strand the row and its Stripe billing against a dangling id.
+ */
+ expect(response.status).toBe(409)
+ expect(mockDetachOrganizationWorkspacesTx).not.toHaveBeenCalled()
+ })
+
+ it('is not blocked by a canceled subscription', async () => {
+ queueOrganization()
+ /**
+ * The status filter runs in SQL, so a canceled row never comes back — an
+ * empty result here is what an organization whose subscription already
+ * ended looks like. Without that filter the row would block deletion
+ * forever even though it bills nobody.
+ */
+ queueTableRows(schemaMock.subscription, [])
+ queueTableRows(schemaMock.member, [{ value: 1 }])
+
+ const response = await DELETE(deleteRequest('acme-inc'), routeContext)
+
+ expect(response.status).toBe(200)
+ expect(mockDetachOrganizationWorkspacesTx).toHaveBeenCalledWith(expect.anything(), ORG_ID)
+ })
+
+ it('detaches workspaces before deleting the organization', async () => {
+ queueOrganization()
+ queueTableRows(schemaMock.subscription, [])
+ queueTableRows(schemaMock.member, [{ value: 3 }])
+
+ const response = await DELETE(deleteRequest('acme-inc'), routeContext)
+
+ expect(response.status).toBe(200)
+ /**
+ * The `ON DELETE SET NULL` foreign key alone would leave workspaces in
+ * `organization` mode with a null org id and drop the organization's
+ * storage ledger without crediting the new payer. The transaction is passed
+ * through so the detach and the delete commit together.
+ */
+ expect(mockDetachOrganizationWorkspacesTx).toHaveBeenCalledWith(expect.anything(), ORG_ID)
+
+ const body = await response.json()
+ expect(body.data).toMatchObject({
+ success: true,
+ organizationId: ORG_ID,
+ slug: 'acme-inc',
+ membersRemoved: 3,
+ workspacesDetached: 2,
+ })
+ })
+})
diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/route.ts
index a1ef18a9cd0..d8e1c9c450c 100644
--- a/apps/sim/app/api/v1/admin/organizations/[id]/route.ts
+++ b/apps/sim/app/api/v1/admin/organizations/[id]/route.ts
@@ -14,14 +14,29 @@
* - slug?: string - Organization slug
*
* Response: AdminSingleResponse
+ *
+ * DELETE /api/v1/admin/organizations/[id]
+ *
+ * Permanently delete an organization. Members, invitations, permission groups,
+ * and org-level settings go with it. Its workspaces are detached first — moved
+ * back to their own billing account with their storage ledger transferred — and
+ * survive the delete.
+ *
+ * Refuses with 409 when a subscription still references the organization.
+ *
+ * Query:
+ * - confirmSlug: string - Must equal the organization's slug
+ *
+ * Response: AdminSingleResponse<{ success, organizationId, slug, membersRemoved, workspacesDetached }>
*/
-import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
+import { AuditAction, AuditResourceType, recordAudit, recordAuditBatch } from '@sim/audit'
import { db } from '@sim/db'
import { member, organization, subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
-import { and, count, eq, inArray } from 'drizzle-orm'
+import { and, count, eq, inArray, isNull, not, or } from 'drizzle-orm'
import {
+ adminV1DeleteOrganizationContract,
adminV1GetOrganizationContract,
adminV1UpdateOrganizationContract,
} from '@/lib/api/contracts/v1/admin'
@@ -32,13 +47,18 @@ import {
OrganizationSlugTakenError,
validateOrganizationSlugOrThrow,
} from '@/lib/billing/organizations/create-organization'
-import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
+import {
+ ENTITLED_SUBSCRIPTION_STATUSES,
+ TERMINAL_SUBSCRIPTION_STATUSES,
+} from '@/lib/billing/subscriptions/utils'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { detachOrganizationWorkspacesTx } from '@/lib/workspaces/organization-workspaces'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
adminInvalidJsonResponse,
adminValidationErrorResponse,
badRequestResponse,
+ conflictResponse,
internalErrorResponse,
notFoundResponse,
singleResponse,
@@ -191,3 +211,127 @@ export const PATCH = withRouteHandler(
}
})
)
+
+export const DELETE = withRouteHandler(
+ withAdminAuthParams(async (request, context) => {
+ const parsed = await parseRequest(adminV1DeleteOrganizationContract, request, context, {
+ validationErrorResponse: adminValidationErrorResponse,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { id: organizationId } = parsed.data.params
+ const { confirmSlug } = parsed.data.query
+
+ try {
+ const [existing] = await db
+ .select({ id: organization.id, name: organization.name, slug: organization.slug })
+ .from(organization)
+ .where(eq(organization.id, organizationId))
+ .limit(1)
+
+ if (!existing) {
+ return notFoundResponse('Organization')
+ }
+
+ if (confirmSlug !== existing.slug) {
+ return badRequestResponse(
+ `confirmSlug does not match this organization's slug. Pass confirmSlug=${existing.slug} to confirm deletion.`
+ )
+ }
+
+ /**
+ * `subscription.reference_id` is polymorphic — it holds either a user id
+ * or an organization id — so it carries no foreign key and nothing
+ * cascades. Deleting an organization out from under a live subscription
+ * would strand it, and its Stripe billing, against an id that no longer
+ * resolves. Refuse instead of guessing whether to cancel.
+ *
+ * Scoped to non-terminal rows. A canceled row bills nobody, so treating
+ * it as a blocker would make an organization that once had a
+ * subscription permanently undeletable — but entitlement is the wrong
+ * test in the other direction too, since a `trialing` subscription grants
+ * nothing today and is still live in Stripe. A null status is unknown, so
+ * it blocks.
+ */
+ const [existingSubscription] = await db
+ .select({ id: subscription.id, plan: subscription.plan })
+ .from(subscription)
+ .where(
+ and(
+ eq(subscription.referenceId, organizationId),
+ or(
+ isNull(subscription.status),
+ not(inArray(subscription.status, TERMINAL_SUBSCRIPTION_STATUSES))
+ )
+ )
+ )
+ .limit(1)
+
+ if (existingSubscription) {
+ return conflictResponse(
+ `Organization still has a "${existingSubscription.plan}" subscription. Cancel it before deleting the organization.`
+ )
+ }
+
+ const [memberCountRow] = await db
+ .select({ value: count() })
+ .from(member)
+ .where(eq(member.organizationId, organizationId))
+
+ /**
+ * Detach before deleting rather than leaning on the `ON DELETE SET NULL`
+ * foreign key. The key only clears `organization_id`; it leaves
+ * `workspace_mode` at `organization` — a state the cleanup dispatcher
+ * treats as malformed and skips — keeps `billed_account_user_id` pointed
+ * at the departing organization, and drops the organization's storage
+ * ledger without returning those bytes to the workspace's new payer.
+ * This helper does the payer transfer, resets the mode, and re-grants
+ * admin permissions.
+ *
+ * Both run in one transaction: a detach that committed on its own would
+ * leave workspaces re-billed to their owners while the organization,
+ * its members, and its settings survived a failed delete.
+ */
+ const { detachedWorkspaceIds, auditEntries } = await db.transaction(async (tx) => {
+ const detached = await detachOrganizationWorkspacesTx(tx, organizationId)
+ await tx.delete(organization).where(eq(organization.id, organizationId))
+ return detached
+ })
+
+ recordAuditBatch(auditEntries)
+
+ logger.info(`Admin API: Deleted organization ${organizationId}`, {
+ slug: existing.slug,
+ membersRemoved: memberCountRow?.value ?? 0,
+ workspacesDetached: detachedWorkspaceIds.length,
+ })
+
+ recordAudit({
+ workspaceId: null,
+ actorId: 'admin-api',
+ action: AuditAction.ORGANIZATION_DELETED,
+ resourceType: AuditResourceType.ORGANIZATION,
+ resourceId: organizationId,
+ resourceName: existing.name,
+ description: `Admin API deleted organization "${existing.name}"`,
+ metadata: {
+ slug: existing.slug,
+ membersRemoved: memberCountRow?.value ?? 0,
+ workspacesDetached: detachedWorkspaceIds.length,
+ },
+ request,
+ })
+
+ return singleResponse({
+ success: true as const,
+ organizationId,
+ slug: existing.slug,
+ membersRemoved: memberCountRow?.value ?? 0,
+ workspacesDetached: detachedWorkspaceIds.length,
+ })
+ } catch (error) {
+ logger.error('Admin API: Failed to delete organization', { error, organizationId })
+ return internalErrorResponse('Failed to delete organization')
+ }
+ })
+)
diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/session-policy/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/session-policy/route.ts
new file mode 100644
index 00000000000..c38a26c08f4
--- /dev/null
+++ b/apps/sim/app/api/v1/admin/organizations/[id]/session-policy/route.ts
@@ -0,0 +1,137 @@
+/**
+ * PATCH /api/v1/admin/organizations/[id]/session-policy
+ *
+ * Set an organization's session policy without going through the settings UI,
+ * so a self-hosted deployment can provision it from its own configuration
+ * management.
+ *
+ * Body:
+ * - maxSessionHours: number | null - Absolute session lifetime cap
+ * - idleTimeoutHours: number | null - Idle timeout
+ *
+ * Response: AdminSingleResponse<{ success, organizationId }>
+ */
+
+import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
+import { db } from '@sim/db'
+import { organization } from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
+import { eq, sql } from 'drizzle-orm'
+import { adminV1UpdateOrganizationSessionPolicyContract } from '@/lib/api/contracts/v1/admin'
+import { parseRequest } from '@/lib/api/server'
+import { invalidateSecurityPolicyVersionCache } from '@/lib/auth/security-policy'
+import { eagerClampOrgSessions, invalidateSessionPolicyCache } from '@/lib/auth/session-policy'
+import { isOrganizationFeatureEntitled } from '@/lib/billing/core/subscription'
+import { isBillingEnabled, isSessionPoliciesEnabled } from '@/lib/core/config/env-flags'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
+import {
+ adminInvalidJsonResponse,
+ adminValidationErrorResponse,
+ forbiddenResponse,
+ internalErrorResponse,
+ notFoundResponse,
+ singleResponse,
+} from '@/app/api/v1/admin/responses'
+
+const logger = createLogger('AdminOrganizationSessionPolicyAPI')
+
+interface RouteParams {
+ id: string
+}
+
+export const PATCH = withRouteHandler(
+ withAdminAuthParams(async (request, context) => {
+ const parsed = await parseRequest(
+ adminV1UpdateOrganizationSessionPolicyContract,
+ request,
+ context,
+ {
+ validationErrorResponse: adminValidationErrorResponse,
+ invalidJsonResponse: adminInvalidJsonResponse,
+ }
+ )
+ if (!parsed.success) return parsed.response
+
+ const { id: organizationId } = parsed.data.params
+ const body = parsed.data.body
+
+ try {
+ const [existing] = await db
+ .select({ name: organization.name })
+ .from(organization)
+ .where(eq(organization.id, organizationId))
+ .limit(1)
+
+ if (!existing) {
+ return notFoundResponse('Organization')
+ }
+
+ /**
+ * Same entitlement gate the settings UI applies. Without it the stored
+ * policy is inert theater: `getSessionPolicy` resolves to no-op when the
+ * feature is off, so limits would sit in the database and the one eager
+ * clamp here would be undone by the next session refresh.
+ */
+ const entitled = await isOrganizationFeatureEntitled(organizationId, isSessionPoliciesEnabled)
+ if (!entitled) {
+ return forbiddenResponse(
+ isBillingEnabled
+ ? 'Session policies are available on Enterprise plans only'
+ : 'Session policies are disabled. Set ENTERPRISE_ENABLED or SESSION_POLICIES_ENABLED to enable them.'
+ )
+ }
+
+ const merged = {
+ maxSessionHours: body.maxSessionHours,
+ idleTimeoutHours: body.idleTimeoutHours,
+ }
+
+ /**
+ * Mirrors the settings-UI write: the policy, the security-policy version
+ * bump, and the clamp of already-issued sessions commit together, so a
+ * stored policy is never left unenforced by a partial failure.
+ */
+ const updated = await db.transaction(async (tx) => {
+ const [row] = await tx
+ .update(organization)
+ .set({
+ sessionPolicySettings: merged,
+ securityPolicyVersion: sql`${organization.securityPolicyVersion} + 1`,
+ updatedAt: new Date(),
+ })
+ .where(eq(organization.id, organizationId))
+ .returning({ id: organization.id })
+ if (!row) return null
+ await eagerClampOrgSessions(organizationId, merged, tx)
+ return row
+ })
+
+ if (!updated) {
+ return notFoundResponse('Organization')
+ }
+
+ invalidateSessionPolicyCache(organizationId)
+ invalidateSecurityPolicyVersionCache(organizationId)
+
+ logger.info(`Admin API: Updated session policy for organization ${organizationId}`)
+
+ recordAudit({
+ workspaceId: null,
+ actorId: 'admin-api',
+ action: AuditAction.ORGANIZATION_SESSION_POLICY_UPDATED,
+ resourceType: AuditResourceType.ORGANIZATION,
+ resourceId: organizationId,
+ resourceName: existing.name,
+ description: `Admin API updated the session policy for "${existing.name}"`,
+ metadata: { changes: body },
+ request,
+ })
+
+ return singleResponse({ success: true as const, organizationId })
+ } catch (error) {
+ logger.error('Admin API: Failed to update session policy', { error, organizationId })
+ return internalErrorResponse('Failed to update session policy')
+ }
+ })
+)
diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/whitelabel/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/whitelabel/route.ts
new file mode 100644
index 00000000000..91f048db597
--- /dev/null
+++ b/apps/sim/app/api/v1/admin/organizations/[id]/whitelabel/route.ts
@@ -0,0 +1,119 @@
+/**
+ * PATCH /api/v1/admin/organizations/[id]/whitelabel
+ *
+ * Set an organization's whitelabel settings without going through the settings
+ * UI, so a self-hosted deployment can provision branding from its own
+ * configuration management.
+ *
+ * Body: partial whitelabel settings; provided keys are merged over the current
+ * value and an explicit `null` clears a key.
+ *
+ * Response: AdminSingleResponse<{ success, organizationId }>
+ */
+
+import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
+import { db } from '@sim/db'
+import { organization } from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
+import { eq } from 'drizzle-orm'
+import { adminV1UpdateOrganizationWhitelabelContract } from '@/lib/api/contracts/v1/admin'
+import { parseRequest } from '@/lib/api/server'
+import { isOrganizationFeatureEntitled } from '@/lib/billing/core/subscription'
+import type { OrganizationWhitelabelSettings } from '@/lib/branding/types'
+import { isBillingEnabled, isWhitelabelingEnabled } from '@/lib/core/config/env-flags'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
+import {
+ adminInvalidJsonResponse,
+ adminValidationErrorResponse,
+ forbiddenResponse,
+ internalErrorResponse,
+ notFoundResponse,
+ singleResponse,
+} from '@/app/api/v1/admin/responses'
+
+const logger = createLogger('AdminOrganizationWhitelabelAPI')
+
+interface RouteParams {
+ id: string
+}
+
+export const PATCH = withRouteHandler(
+ withAdminAuthParams(async (request, context) => {
+ const parsed = await parseRequest(
+ adminV1UpdateOrganizationWhitelabelContract,
+ request,
+ context,
+ {
+ validationErrorResponse: adminValidationErrorResponse,
+ invalidJsonResponse: adminInvalidJsonResponse,
+ }
+ )
+ if (!parsed.success) return parsed.response
+
+ const { id: organizationId } = parsed.data.params
+ const incoming = parsed.data.body
+
+ try {
+ const [existing] = await db
+ .select({ name: organization.name, whitelabelSettings: organization.whitelabelSettings })
+ .from(organization)
+ .where(eq(organization.id, organizationId))
+ .limit(1)
+
+ if (!existing) {
+ return notFoundResponse('Organization')
+ }
+
+ /**
+ * Same entitlement gate the settings UI applies. An admin key
+ * authenticates an operator, not an entitlement, so without this it would
+ * be a way to set branding the product has not granted this organization.
+ */
+ const entitled = await isOrganizationFeatureEntitled(organizationId, isWhitelabelingEnabled)
+ if (!entitled) {
+ return forbiddenResponse(
+ isBillingEnabled
+ ? 'Whitelabeling is available on Enterprise plans only'
+ : 'Whitelabeling is disabled. Set ENTERPRISE_ENABLED or WHITELABELING_ENABLED to enable it.'
+ )
+ }
+
+ const merged: OrganizationWhitelabelSettings = { ...(existing.whitelabelSettings ?? {}) }
+ for (const key of Object.keys(incoming) as Array) {
+ const value = incoming[key]
+ if (value === null || value === undefined) {
+ delete merged[key]
+ } else {
+ Object.assign(merged, { [key]: value })
+ }
+ }
+
+ await db
+ .update(organization)
+ .set({ whitelabelSettings: merged, updatedAt: new Date() })
+ .where(eq(organization.id, organizationId))
+
+ logger.info(`Admin API: Updated whitelabel settings for organization ${organizationId}`, {
+ fields: Object.keys(incoming),
+ })
+
+ recordAudit({
+ workspaceId: null,
+ actorId: 'admin-api',
+ action: AuditAction.ORGANIZATION_UPDATED,
+ resourceType: AuditResourceType.ORGANIZATION,
+ resourceId: organizationId,
+ resourceName: existing.name,
+ description: `Admin API updated whitelabel settings for "${existing.name}"`,
+ metadata: { fields: Object.keys(incoming) },
+ request,
+ })
+
+ return singleResponse({ success: true as const, organizationId })
+ } catch (error) {
+ logger.error('Admin API: Failed to update whitelabel settings', { error, organizationId })
+ return internalErrorResponse('Failed to update whitelabel settings')
+ }
+ })
+)
diff --git a/apps/sim/app/api/v1/admin/responses.ts b/apps/sim/app/api/v1/admin/responses.ts
index 592a5b0a0c0..9308df895dc 100644
--- a/apps/sim/app/api/v1/admin/responses.ts
+++ b/apps/sim/app/api/v1/admin/responses.ts
@@ -71,6 +71,11 @@ export function badRequestResponse(message: string, details?: unknown): NextResp
return errorResponse('BAD_REQUEST', message, 400, details)
}
+/** The request is well-formed but conflicts with the resource's current state. */
+export function conflictResponse(message: string, details?: unknown): NextResponse {
+ return errorResponse('CONFLICT', message, 409, details)
+}
+
export function adminValidationErrorResponse(error: z.ZodError): NextResponse {
return badRequestResponse(
getValidationErrorMessage(error, 'Invalid request body'),
diff --git a/apps/sim/app/api/v1/audit-logs/auth.test.ts b/apps/sim/app/api/v1/audit-logs/auth.test.ts
index a16dee2b0c4..e8122de36dd 100644
--- a/apps/sim/app/api/v1/audit-logs/auth.test.ts
+++ b/apps/sim/app/api/v1/audit-logs/auth.test.ts
@@ -1,7 +1,14 @@
/**
* @vitest-environment node
*/
-import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing'
+import {
+ dbChainMockFns,
+ queueTableRows,
+ resetDbChainMock,
+ resetEnvFlagsMock,
+ schemaMock,
+ setEnvFlags,
+} from '@sim/testing'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
const { mockIsOrganizationBillingBlocked } = vi.hoisted(() => ({
@@ -19,30 +26,86 @@ describe('enterprise audit access', () => {
vi.clearAllMocks()
resetDbChainMock()
mockIsOrganizationBillingBlocked.mockResolvedValue(false)
- queueTableRows(schemaMock.member, [{ organizationId: 'organization-route', role: 'admin' }])
- queueTableRows(schemaMock.subscription, [{ id: 'subscription-1' }])
- queueTableRows(schemaMock.member, [{ userId: 'viewer' }, { userId: 'member-2' }])
})
afterAll(() => {
resetDbChainMock()
+ resetEnvFlagsMock()
})
- it('authorizes and bills against the organization named by the route', async () => {
- await expect(validateEnterpriseAuditAccess('viewer', 'organization-route')).resolves.toEqual({
- success: true,
- context: {
- organizationId: 'organization-route',
- orgMemberIds: ['viewer', 'member-2'],
- },
+ describe('with billing enabled', () => {
+ beforeEach(() => {
+ setEnvFlags({ isBillingEnabled: true })
+ queueTableRows(schemaMock.member, [{ organizationId: 'organization-route', role: 'admin' }])
+ queueTableRows(schemaMock.subscription, [{ id: 'subscription-1' }])
+ queueTableRows(schemaMock.member, [{ userId: 'viewer' }, { userId: 'member-2' }])
})
- expect(dbChainMockFns.where).toHaveBeenNthCalledWith(1, {
- type: 'and',
- conditions: [
- { type: 'eq', left: schemaMock.member.userId, right: 'viewer' },
- { type: 'eq', left: schemaMock.member.organizationId, right: 'organization-route' },
- ],
+
+ it('authorizes and bills against the organization named by the route', async () => {
+ await expect(validateEnterpriseAuditAccess('viewer', 'organization-route')).resolves.toEqual({
+ success: true,
+ context: {
+ organizationId: 'organization-route',
+ orgMemberIds: ['viewer', 'member-2'],
+ },
+ })
+ expect(dbChainMockFns.where).toHaveBeenNthCalledWith(1, {
+ type: 'and',
+ conditions: [
+ { type: 'eq', left: schemaMock.member.userId, right: 'viewer' },
+ { type: 'eq', left: schemaMock.member.organizationId, right: 'organization-route' },
+ ],
+ })
+ expect(mockIsOrganizationBillingBlocked).toHaveBeenCalledWith('organization-route')
+ })
+ })
+
+ describe('with billing disabled', () => {
+ beforeEach(() => {
+ setEnvFlags({ isBillingEnabled: false })
+ })
+
+ it('authorizes on the audit-logs entitlement without any subscription row', async () => {
+ setEnvFlags({ isAuditLogsEnabled: true })
+ queueTableRows(schemaMock.member, [{ organizationId: 'org-1', role: 'owner' }])
+ queueTableRows(schemaMock.member, [{ userId: 'viewer' }, { userId: 'member-2' }])
+
+ await expect(validateEnterpriseAuditAccess('viewer')).resolves.toEqual({
+ success: true,
+ context: { organizationId: 'org-1', orgMemberIds: ['viewer', 'member-2'] },
+ })
+ /**
+ * The subscription lookup is what made audit logs unreachable
+ * self-hosted; a billing-free deployment never has one to find.
+ */
+ expect(mockIsOrganizationBillingBlocked).not.toHaveBeenCalled()
+ })
+
+ it('refuses when the audit-logs entitlement is off', async () => {
+ setEnvFlags({ isAuditLogsEnabled: false })
+ queueTableRows(schemaMock.member, [{ organizationId: 'org-1', role: 'owner' }])
+
+ const result = await validateEnterpriseAuditAccess('viewer')
+
+ expect(result.success).toBe(false)
+ })
+
+ it('still requires an admin or owner role', async () => {
+ setEnvFlags({ isAuditLogsEnabled: true })
+ queueTableRows(schemaMock.member, [{ organizationId: 'org-1', role: 'member' }])
+
+ const result = await validateEnterpriseAuditAccess('viewer')
+
+ expect(result.success).toBe(false)
+ })
+
+ it('still requires organization membership', async () => {
+ setEnvFlags({ isAuditLogsEnabled: true })
+ queueTableRows(schemaMock.member, [])
+
+ const result = await validateEnterpriseAuditAccess('viewer')
+
+ expect(result.success).toBe(false)
})
- expect(mockIsOrganizationBillingBlocked).toHaveBeenCalledWith('organization-route')
})
})
diff --git a/apps/sim/app/api/v1/audit-logs/auth.ts b/apps/sim/app/api/v1/audit-logs/auth.ts
index 323d1b82bdd..566a17a5e27 100644
--- a/apps/sim/app/api/v1/audit-logs/auth.ts
+++ b/apps/sim/app/api/v1/audit-logs/auth.ts
@@ -12,6 +12,7 @@ import { and, eq, inArray } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { isOrganizationBillingBlocked } from '@/lib/billing/core/access'
import { USABLE_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
+import { isAuditLogsEnabled, isBillingEnabled } from '@/lib/core/config/env-flags'
const logger = createLogger('V1AuditLogsAuth')
@@ -30,7 +31,13 @@ type AuthResult =
* Checks:
* 1. User belongs to an organization
* 2. User has admin or owner role
- * 3. Organization has an active enterprise subscription
+ * 3. The organization is entitled to audit logs — an active enterprise
+ * subscription when billing runs, otherwise the deployment's audit-logs
+ * entitlement
+ *
+ * The subscription query is skipped entirely with billing off. Requiring it
+ * there made audit logs unreachable on every self-hosted deployment, since no
+ * subscription row is ever written without billing.
*
* Returns the organization ID and all member user IDs on success,
* or an error response on failure.
@@ -66,36 +73,51 @@ export async function validateEnterpriseAuditAccess(
}
}
- const billingBlocked = await isOrganizationBillingBlocked(membership.organizationId)
- if (billingBlocked) {
+ if (isBillingEnabled) {
+ const billingBlocked = await isOrganizationBillingBlocked(membership.organizationId)
+ if (billingBlocked) {
+ return {
+ success: false,
+ response: NextResponse.json(
+ { error: 'Active enterprise subscription required' },
+ { status: 403 }
+ ),
+ }
+ }
+ } else if (!isAuditLogsEnabled) {
return {
success: false,
response: NextResponse.json(
- { error: 'Active enterprise subscription required' },
+ {
+ error:
+ 'Audit logs are disabled. Set ENTERPRISE_ENABLED or AUDIT_LOGS_ENABLED to enable them.',
+ },
{ status: 403 }
),
}
}
const [orgSub, orgMembers] = await Promise.all([
- db
- .select({ id: subscription.id })
- .from(subscription)
- .where(
- and(
- eq(subscription.referenceId, membership.organizationId),
- eq(subscription.plan, 'enterprise'),
- inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES)
- )
- )
- .limit(1),
+ isBillingEnabled
+ ? db
+ .select({ id: subscription.id })
+ .from(subscription)
+ .where(
+ and(
+ eq(subscription.referenceId, membership.organizationId),
+ eq(subscription.plan, 'enterprise'),
+ inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES)
+ )
+ )
+ .limit(1)
+ : Promise.resolve([]),
db
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, membership.organizationId)),
])
- if (orgSub.length === 0) {
+ if (isBillingEnabled && orgSub.length === 0) {
return {
success: false,
response: NextResponse.json(
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx
index d4e1105e087..b8d3f9de074 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx
@@ -16,7 +16,7 @@ import { Send } from '@sim/emcn/icons'
import { generateShortId } from '@sim/utils/id'
import { GeneratedPasswordInput } from '@/components/ui'
import type { ShareAuthType, ShareRecord } from '@/lib/api/contracts/public-shares'
-import { getEnv, isTruthy } from '@/lib/core/config/env'
+import { isSsoEnabled } from '@/lib/core/config/env-flags'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { useFileShare, useUpsertFileShare } from '@/hooks/queries/public-shares'
@@ -91,7 +91,7 @@ export function ShareModal({
const isAuthTypeAllowed = (mode: ShareAuthType) =>
allowedAuthTypes === null || allowedAuthTypes.includes(mode)
- const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED')) || savedAccessMode === 'sso'
+ const ssoEnabled = isSsoEnabled || savedAccessMode === 'sso'
const candidateAuthTypes: ShareAuthType[] = [
'public',
'password',
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx
index 12b820efe5f..f08467fe3e6 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx
@@ -22,7 +22,7 @@ import { getErrorMessage } from '@sim/utils/errors'
import { normalizeEmail } from '@sim/utils/string'
import { AlertTriangle, Check } from 'lucide-react'
import { GeneratedPasswordInput } from '@/components/ui'
-import { getEnv, isTruthy } from '@/lib/core/config/env'
+import { isSsoEnabled } from '@/lib/core/config/env-flags'
import { getBaseUrl, getEmailDomain } from '@/lib/core/utils/urls'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select'
@@ -713,9 +713,7 @@ function AuthSelector({
const allowedAuthTypes = permissionConfig.allowedChatDeployAuthTypes
const ssoAvailable =
- isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED')) ||
- savedAuthType === 'sso' ||
- (allowedAuthTypes?.includes('sso') ?? false)
+ isSsoEnabled || savedAuthType === 'sso' || (allowedAuthTypes?.includes('sso') ?? false)
const baseAuthOptions: AuthType[] = ssoAvailable
? ['public', 'password', 'email', 'sso']
: ['public', 'password', 'email']
diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts
index a567a79e317..b5073b23ab0 100644
--- a/apps/sim/components/settings/navigation.ts
+++ b/apps/sim/components/settings/navigation.ts
@@ -24,7 +24,17 @@ import {
import { type PermissionType, permissionSatisfies } from '@sim/platform-authz/workspace'
import { McpIcon } from '@/components/icons'
import { getEnv, isTruthy } from '@/lib/core/config/env'
-import { isHosted } from '@/lib/core/config/env-flags'
+import {
+ isAccessControlEnabled,
+ isAuditLogsEnabled,
+ isDataDrainsEnabled,
+ isDataRetentionEnabled,
+ isHosted,
+ isInboxEnabled,
+ isSessionPoliciesEnabled,
+ isSsoEnabled,
+ isWhitelabelingEnabled,
+} from '@/lib/core/config/env-flags'
export type SettingsPlane = 'account' | 'organization' | 'selfhost' | 'workspace'
@@ -166,16 +176,27 @@ export interface SettingsSectionRegistryEntry {
planes?: SettingsPlaneProjections
}
+/**
+ * Which enterprise sections a self-hosted deployment may show.
+ *
+ * These read the same resolved flags the server gates use, so a section is
+ * visible exactly when its API would accept the request. Reading the raw
+ * `NEXT_PUBLIC_*` vars here instead is what previously let nav and server
+ * disagree — a feature could be reachable but hidden, or listed but rejected.
+ *
+ * `customBlocks` stays on its own var because its server gate runs through the
+ * AppConfig-backed feature-flag service rather than the entitlement resolver.
+ */
const SETTINGS_SELF_HOSTED_OVERRIDES = {
- accessControl: isTruthy(getEnv('NEXT_PUBLIC_ACCESS_CONTROL_ENABLED')),
- auditLogs: isTruthy(getEnv('NEXT_PUBLIC_AUDIT_LOGS_ENABLED')),
+ accessControl: isAccessControlEnabled,
+ auditLogs: isAuditLogsEnabled,
customBlocks: isTruthy(getEnv('NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED')),
- dataDrains: isTruthy(getEnv('NEXT_PUBLIC_DATA_DRAINS_ENABLED')),
- dataRetention: isTruthy(getEnv('NEXT_PUBLIC_DATA_RETENTION_ENABLED')),
- inbox: isTruthy(getEnv('NEXT_PUBLIC_INBOX_ENABLED')),
- sessionPolicies: isTruthy(getEnv('NEXT_PUBLIC_SESSION_POLICIES_ENABLED')),
- sso: isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED')),
- whitelabeling: isTruthy(getEnv('NEXT_PUBLIC_WHITELABELING_ENABLED')),
+ dataDrains: isDataDrainsEnabled,
+ dataRetention: isDataRetentionEnabled,
+ inbox: isInboxEnabled,
+ sessionPolicies: isSessionPoliciesEnabled,
+ sso: isSsoEnabled,
+ whitelabeling: isWhitelabelingEnabled,
} as const
export const SETTINGS_NAVIGATION_BILLING_ENABLED = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
diff --git a/apps/sim/ee/access-control/components/access-control.tsx b/apps/sim/ee/access-control/components/access-control.tsx
index 6d2cb78d2e3..ce4330347b1 100644
--- a/apps/sim/ee/access-control/components/access-control.tsx
+++ b/apps/sim/ee/access-control/components/access-control.tsx
@@ -18,7 +18,7 @@ import { ArrowRight, Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
import { useQueryState } from 'nuqs'
import { isEnterprise } from '@/lib/billing/plan-helpers'
-import { getEnv, isTruthy } from '@/lib/core/config/env'
+import { isAccessControlEnabled } from '@/lib/core/config/env-flags'
import {
groupIdParam,
groupIdUrlKeys,
@@ -73,9 +73,14 @@ export function AccessControl({ isOrganizationAdmin, organizationId }: AccessCon
const { data: organizationWorkspaces = [], isPending: workspacesLoading } =
useOrganizationWorkspaces(organizationId, !!organizationId && currentUserIsOrgAdmin)
- const accessControlEnabledLocally = isTruthy(getEnv('NEXT_PUBLIC_ACCESS_CONTROL_ENABLED'))
+ /**
+ * Must be the resolved flag, not the raw `NEXT_PUBLIC_ACCESS_CONTROL_ENABLED`
+ * read. The settings nav decides visibility from the same resolver, so
+ * reading the bare var here let a deployment with only `ENTERPRISE_ENABLED`
+ * set show the section and then refuse to manage it.
+ */
const isEntitled =
- accessControlEnabledLocally ||
+ isAccessControlEnabled ||
!!userPermissionConfig?.entitled ||
isEnterprise(organizationBillingData?.data?.subscriptionPlan)
const canManage = isEntitled && currentUserIsOrgAdmin && !!organizationId
diff --git a/apps/sim/lib/api/contracts/v1/admin/organizations.ts b/apps/sim/lib/api/contracts/v1/admin/organizations.ts
index 1281b5e649d..192415b06da 100644
--- a/apps/sim/lib/api/contracts/v1/admin/organizations.ts
+++ b/apps/sim/lib/api/contracts/v1/admin/organizations.ts
@@ -1,4 +1,9 @@
import { z } from 'zod'
+import {
+ updateOrganizationDataRetentionBodySchema,
+ updateOrganizationSessionPolicyBodySchema,
+ updateOrganizationWhitelabelBodySchema,
+} from '@/lib/api/contracts/organization'
import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contracts/types'
import {
adminV1BooleanQuerySchema,
@@ -154,6 +159,32 @@ const adminV1OrganizationBillingUpdateResultSchema = z.object({
orgUsageLimit: z.string().nullable(),
})
+/**
+ * Deleting an organization cascades to its members, invitations, permission
+ * groups, and settings, and detaches its workspaces (`organization_id` is
+ * `ON DELETE SET NULL`). Requiring the caller to echo the slug makes that
+ * irreversible scope an explicit act rather than a mistyped id.
+ */
+export const adminV1DeleteOrganizationQuerySchema = z.object({
+ confirmSlug: z
+ .string({ error: 'confirmSlug is required and must match the organization slug' })
+ .min(1, { error: 'confirmSlug is required and must match the organization slug' }),
+})
+
+const adminV1DeleteOrganizationResultSchema = z.object({
+ success: z.literal(true),
+ organizationId: z.string(),
+ slug: z.string(),
+ membersRemoved: z.number(),
+ workspacesDetached: z.number(),
+})
+
+/** Shared result for the org-settings PATCH endpoints. */
+const adminV1OrganizationSettingsResultSchema = z.object({
+ success: z.literal(true),
+ organizationId: z.string(),
+})
+
const adminV1TransferOwnershipResultSchema = z.object({
organizationId: z.string(),
currentOwnerUserId: z.string(),
@@ -337,6 +368,62 @@ export type AdminV1UpdateOrganizationBillingResponse = ContractJsonResponse<
export type AdminV1GetOrganizationSeatsResponse = ContractJsonResponse<
typeof adminV1GetOrganizationSeatsContract
>
+export const adminV1DeleteOrganizationContract = defineRouteContract({
+ method: 'DELETE',
+ path: '/api/v1/admin/organizations/[id]',
+ params: adminV1IdParamsSchema,
+ query: adminV1DeleteOrganizationQuerySchema,
+ response: {
+ mode: 'json',
+ schema: adminV1SingleResponseSchema(adminV1DeleteOrganizationResultSchema),
+ },
+})
+
+export const adminV1UpdateOrganizationWhitelabelContract = defineRouteContract({
+ method: 'PATCH',
+ path: '/api/v1/admin/organizations/[id]/whitelabel',
+ params: adminV1IdParamsSchema,
+ body: updateOrganizationWhitelabelBodySchema,
+ response: {
+ mode: 'json',
+ schema: adminV1SingleResponseSchema(adminV1OrganizationSettingsResultSchema),
+ },
+})
+
+export const adminV1UpdateOrganizationDataRetentionContract = defineRouteContract({
+ method: 'PATCH',
+ path: '/api/v1/admin/organizations/[id]/data-retention',
+ params: adminV1IdParamsSchema,
+ body: updateOrganizationDataRetentionBodySchema,
+ response: {
+ mode: 'json',
+ schema: adminV1SingleResponseSchema(adminV1OrganizationSettingsResultSchema),
+ },
+})
+
+export const adminV1UpdateOrganizationSessionPolicyContract = defineRouteContract({
+ method: 'PATCH',
+ path: '/api/v1/admin/organizations/[id]/session-policy',
+ params: adminV1IdParamsSchema,
+ body: updateOrganizationSessionPolicyBodySchema,
+ response: {
+ mode: 'json',
+ schema: adminV1SingleResponseSchema(adminV1OrganizationSettingsResultSchema),
+ },
+})
+
export type AdminV1TransferOwnershipResponse = ContractJsonResponse<
typeof adminV1TransferOwnershipContract
>
+export type AdminV1DeleteOrganizationResponse = ContractJsonResponse<
+ typeof adminV1DeleteOrganizationContract
+>
+export type AdminV1UpdateOrganizationWhitelabelResponse = ContractJsonResponse<
+ typeof adminV1UpdateOrganizationWhitelabelContract
+>
+export type AdminV1UpdateOrganizationDataRetentionResponse = ContractJsonResponse<
+ typeof adminV1UpdateOrganizationDataRetentionContract
+>
+export type AdminV1UpdateOrganizationSessionPolicyResponse = ContractJsonResponse<
+ typeof adminV1UpdateOrganizationSessionPolicyContract
+>
diff --git a/apps/sim/lib/auth/auth-client.ts b/apps/sim/lib/auth/auth-client.ts
index b1ca36b84d2..0f91980cb52 100644
--- a/apps/sim/lib/auth/auth-client.ts
+++ b/apps/sim/lib/auth/auth-client.ts
@@ -10,8 +10,7 @@ import {
} from 'better-auth/client/plugins'
import { createAuthClient } from 'better-auth/react'
import type { auth } from '@/lib/auth'
-import { env } from '@/lib/core/config/env'
-import { isBillingEnabled, isOrganizationsEnabled } from '@/lib/core/config/env-flags'
+import { isBillingEnabled, isOrganizationsEnabled, isSsoEnabled } from '@/lib/core/config/env-flags'
import { getBaseUrl, getBrowserOrigin } from '@/lib/core/utils/urls'
import { SessionContext, type SessionHookResult } from '@/app/_shell/providers/session-provider'
@@ -34,7 +33,7 @@ export const client = createAuthClient({
]
: []),
...(isOrganizationsEnabled ? [organizationClient()] : []),
- ...(env.NEXT_PUBLIC_SSO_ENABLED ? [ssoClient()] : []),
+ ...(isSsoEnabled ? [ssoClient()] : []),
],
})
diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts
index 167e02112dd..39a0cf7da55 100644
--- a/apps/sim/lib/auth/auth.ts
+++ b/apps/sim/lib/auth/auth.ts
@@ -105,6 +105,7 @@ import {
import { extractSlackTeamId, fanOutSlackTokenChain } from '@/lib/oauth/slack'
import { clearDeadFlag } from '@/lib/oauth/terminal-errors'
import { getCanonicalScopesForProvider } from '@/lib/oauth/utils'
+import { joinInstanceOrganization } from '@/lib/organizations/instance-org'
import { captureServerEvent, getPostHogClient } from '@/lib/posthog/server'
import { disableUserResources } from '@/lib/workflows/lifecycle'
import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants'
@@ -328,6 +329,15 @@ export const auth = betterAuth({
})
}
+ /**
+ * Places the user in the instance organization before they reach the
+ * workspace list, so their first workspace is created org-owned and
+ * org-scoped enterprise settings apply to it from the start. No-ops
+ * unless `INSTANCE_ORG_NAME` is set, and swallows its own failures so
+ * organization setup can never block a signup.
+ */
+ await joinInstanceOrganization(user.id)
+
if (isHosted && user.email && user.emailVerified) {
try {
const html = await renderWelcomeEmail(user.name || undefined)
@@ -3228,8 +3238,14 @@ export const auth = betterAuth({
},
],
}),
- // Include SSO plugin when enabled
- ...(env.SSO_ENABLED
+ /**
+ * Include SSO plugin when enabled. Resolved through `isSsoEnabled` rather
+ * than the raw env var so the `ENTERPRISE_ENABLED` suite switch registers
+ * the plugin too — reading `env.SSO_ENABLED` here would leave the settings
+ * section visible and `hasSSOAccess` passing while sign-in silently had no
+ * SSO provider behind it.
+ */
+ ...(isSsoEnabled
? [
sso({
/**
diff --git a/apps/sim/lib/auth/session-policy.ts b/apps/sim/lib/auth/session-policy.ts
index 484423dfde3..705129af9e7 100644
--- a/apps/sim/lib/auth/session-policy.ts
+++ b/apps/sim/lib/auth/session-policy.ts
@@ -5,8 +5,8 @@ import { createLogger } from '@sim/logger'
import { eq, sql } from 'drizzle-orm'
import { MIN_IDLE_TIMEOUT_HOURS } from '@/lib/api/contracts/organization'
import { getMemberOrganizationId, invalidateMembershipCache } from '@/lib/auth/security-policy'
-import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription'
-import { isBillingEnabled } from '@/lib/core/config/env-flags'
+import { isOrganizationFeatureEntitled } from '@/lib/billing/core/subscription'
+import { isSessionPoliciesEnabled } from '@/lib/core/config/env-flags'
const logger = createLogger('SessionPolicy')
@@ -60,7 +60,7 @@ export async function getSessionPolicy(
const settings: SessionPolicySettings = row?.settings ?? {}
const hasBounds = Boolean(settings.maxSessionHours || settings.idleTimeoutHours)
const isEntitled =
- !hasBounds || !isBillingEnabled || (await isOrganizationOnEnterprisePlan(organizationId))
+ !hasBounds || (await isOrganizationFeatureEntitled(organizationId, isSessionPoliciesEnabled))
const policy: ResolvedSessionPolicy = isEntitled
? {
maxSessionHours: settings.maxSessionHours ?? null,
diff --git a/apps/sim/lib/billing/cleanup-dispatcher.test.ts b/apps/sim/lib/billing/cleanup-dispatcher.test.ts
index e923da4135a..e1d3f8977ac 100644
--- a/apps/sim/lib/billing/cleanup-dispatcher.test.ts
+++ b/apps/sim/lib/billing/cleanup-dispatcher.test.ts
@@ -1,19 +1,34 @@
/**
* @vitest-environment node
*/
-import { dbChainMockFns, resetDbChainMock, resetEnvFlagsMock, setEnvFlags } from '@sim/testing'
+import {
+ dbChainMockFns,
+ queueTableRows,
+ resetDbChainMock,
+ resetEnvFlagsMock,
+ schemaMock,
+ setEnvFlags,
+} from '@sim/testing'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
-const { mockIsTriggerAvailable } = vi.hoisted(() => ({
+const { mockIsTriggerAvailable, mockGetOrganizationSubscription, mockEnqueue } = vi.hoisted(() => ({
mockIsTriggerAvailable: vi.fn(),
+ mockGetOrganizationSubscription: vi.fn(),
+ mockEnqueue: vi.fn(),
}))
-vi.mock('@/lib/billing/core/billing', () => ({ getOrganizationSubscription: vi.fn() }))
+vi.mock('@/lib/billing/core/billing', () => ({
+ getOrganizationSubscription: mockGetOrganizationSubscription,
+}))
vi.mock('@/lib/billing/core/subscription', () => ({
getHighestPriorityPersonalSubscription: vi.fn(),
}))
-vi.mock('@/lib/cleanup/batch-delete', () => ({ chunkArray: vi.fn() }))
-vi.mock('@/lib/core/async-jobs', () => ({ getJobQueue: vi.fn() }))
+vi.mock('@/lib/cleanup/batch-delete', () => ({
+ chunkArray: vi.fn((items: unknown[]) => (items.length > 0 ? [items] : [])),
+}))
+vi.mock('@/lib/core/async-jobs', () => ({
+ getJobQueue: vi.fn(() => ({ enqueue: mockEnqueue })),
+}))
vi.mock('@/lib/core/async-jobs/config', () => ({ shouldExecuteInline: vi.fn(() => false) }))
vi.mock('@/lib/core/async-jobs/region', () => ({ resolveTriggerRegion: vi.fn() }))
vi.mock('@/lib/knowledge/documents/service', () => ({
@@ -28,22 +43,65 @@ import { dispatchCleanupJobs } from '@/lib/billing/cleanup-dispatcher'
afterAll(resetEnvFlagsMock)
-describe('dispatchCleanupJobs billing gate', () => {
+describe('dispatchCleanupJobs retention gate', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
- setEnvFlags({ isBillingEnabled: false })
+ setEnvFlags({ isBillingEnabled: false, isDataRetentionEnabled: false })
+ mockIsTriggerAvailable.mockReturnValue(false)
+ mockGetOrganizationSubscription.mockResolvedValue(null)
})
afterAll(() => {
resetDbChainMock()
})
- it('never dispatches plan-based retention deletion when billing is disabled', async () => {
+ it('never dispatches retention deletion when billing is disabled and retention is off', async () => {
const result = await dispatchCleanupJobs('cleanup-logs')
expect(result).toEqual({ jobIds: [], jobCount: 0, chunkCount: 0, workspaceCount: 0 })
expect(mockIsTriggerAvailable).not.toHaveBeenCalled()
expect(dbChainMockFns.select).not.toHaveBeenCalled()
})
+
+ it('scans workspaces once retention is explicitly enabled', async () => {
+ setEnvFlags({ isDataRetentionEnabled: true })
+ queueTableRows(schemaMock.workspace, [])
+
+ await dispatchCleanupJobs('cleanup-logs')
+
+ expect(dbChainMockFns.select).toHaveBeenCalled()
+ })
+
+ it('deletes nothing for a workspace with no configured retention', async () => {
+ setEnvFlags({ isDataRetentionEnabled: true })
+ /**
+ * The safety property for self-hosted retention: with billing off every
+ * workspace resolves as enterprise, which carries no plan default, so a
+ * workspace whose organization configured nothing keeps its data forever.
+ * Falling through to the free-tier default would silently expire logs on a
+ * 30-day window the operator never chose.
+ */
+ queueTableRows(schemaMock.workspace, [
+ {
+ id: 'ws-1',
+ billedAccountUserId: 'user-1',
+ organizationId: null,
+ workspaceMode: 'personal',
+ organizationSettings: null,
+ },
+ ])
+ queueTableRows(schemaMock.workspace, [])
+
+ const result = await dispatchCleanupJobs('cleanup-logs')
+
+ expect(result.workspaceCount).toBe(0)
+ expect(mockGetOrganizationSubscription).not.toHaveBeenCalled()
+ /**
+ * No chunks at all, including the plan-wide housekeeping one. That chunk is
+ * keyed to the hosted free-tier 30-day window, so emitting it off-hosted
+ * would act on the very default the per-workspace pass refuses to apply.
+ */
+ expect(result.chunkCount).toBe(0)
+ })
})
diff --git a/apps/sim/lib/billing/cleanup-dispatcher.ts b/apps/sim/lib/billing/cleanup-dispatcher.ts
index 78a97e4dd72..8b8608d63a4 100644
--- a/apps/sim/lib/billing/cleanup-dispatcher.ts
+++ b/apps/sim/lib/billing/cleanup-dispatcher.ts
@@ -13,7 +13,7 @@ import { getJobQueue } from '@/lib/core/async-jobs'
import { shouldExecuteInline } from '@/lib/core/async-jobs/config'
import { resolveTriggerRegion } from '@/lib/core/async-jobs/region'
import type { EnqueueOptions } from '@/lib/core/async-jobs/types'
-import { isBillingEnabled } from '@/lib/core/config/env-flags'
+import { isBillingEnabled, isDataRetentionEnabled } from '@/lib/core/config/env-flags'
import { isTriggerAvailable } from '@/lib/knowledge/documents/service'
import { isOrganizationWorkspace, WORKSPACE_MODE } from '@/lib/workspaces/policy'
@@ -137,6 +137,22 @@ async function resolvePersonalPlanTypesByBilledUserId(
async function resolvePlanTypesByWorkspaceId(
rows: WorkspaceCleanupScopeRow[]
): Promise