diff --git a/apps/sim/lib/core/config/env-flags.dom.test.ts b/apps/sim/lib/core/config/env-flags.dom.test.ts new file mode 100644 index 00000000000..3511aaccf2b --- /dev/null +++ b/apps/sim/lib/core/config/env-flags.dom.test.ts @@ -0,0 +1,54 @@ +/** + * @vitest-environment jsdom + * @vitest-environment-options {"url":"https://www.sim.ai"} + */ +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.hoisted(() => { + vi.stubEnv('NEXT_PUBLIC_APP_URL', '') + vi.stubEnv('NEXT_PUBLIC_FORCE_HOSTED', 'false') + vi.stubEnv('NODE_ENV', 'production') + document.documentElement.id = '__next_error__' +}) + +vi.unmock('@/lib/core/config/env') +vi.unmock('@/lib/core/config/env-flags') +vi.mock('@/lib/oauth/utils', () => ({ getScopesForService: () => [] })) +vi.mock('@/providers/utils', () => ({ getProviderFromModel: () => 'openai' })) + +import { getEnv, PUBLIC_ENV_ATTRIBUTE } from '@/lib/core/config/env' +import { isHosted } from '@/lib/core/config/env-flags' +import { evaluateSubBlockCondition } from '@/lib/workflows/subblocks/visibility' +import { getApiKeyCondition } from '@/blocks/utils' +import { getHostedModels } from '@/providers/models' + +describe('hosted detection during client recovery', () => { + afterEach(() => { + document.documentElement.removeAttribute(PUBLIC_ENV_ATTRIBUTE) + Reflect.deleteProperty(window, '__ENV') + }) + + it('hides hosted model keys before the recovered layout installs runtime configuration', () => { + expect(window.__ENV).toBeUndefined() + expect(document.documentElement.getAttribute(PUBLIC_ENV_ATTRIBUTE)).toBeNull() + expect(isHosted).toBe(true) + + for (const model of ['gpt-5.6-sol', 'claude-sonnet-5', 'gemini-2.5-pro']) { + expect(getHostedModels()).toContain(model) + expect(evaluateSubBlockCondition(getApiKeyCondition(), { model })).toBe(false) + } + + document.documentElement.setAttribute( + PUBLIC_ENV_ATTRIBUTE, + JSON.stringify({ NEXT_PUBLIC_APP_URL: 'https://www.sim.ai' }) + ) + + expect(getEnv('NEXT_PUBLIC_APP_URL')).toBe('https://www.sim.ai') + expect(isHosted).toBe(true) + expect(evaluateSubBlockCondition(getApiKeyCondition(), { model: 'gpt-5.6-sol' })).toBe(false) + }) + + it('still requires keys for models outside the hosted catalog', () => { + expect(evaluateSubBlockCondition(getApiKeyCondition(), { model: 'custom/model' })).toBe(true) + }) +}) diff --git a/apps/sim/lib/core/config/env-flags.test.ts b/apps/sim/lib/core/config/env-flags.test.ts new file mode 100644 index 00000000000..fa30e64738e --- /dev/null +++ b/apps/sim/lib/core/config/env-flags.test.ts @@ -0,0 +1,23 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' + +vi.hoisted(() => { + vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://self-hosted.example') + vi.stubEnv('NEXT_PUBLIC_FORCE_HOSTED', 'true') + vi.stubEnv('NODE_ENV', 'production') + vi.stubGlobal('window', { location: { hostname: 'www.sim.ai' } }) +}) + +vi.unmock('@/lib/core/config/env') +vi.unmock('@/lib/core/config/env-flags') + +import { isHosted, isProd } from '@/lib/core/config/env-flags' + +describe('configured hosted detection', () => { + it('preserves a configured self-hosted URL and ignores the development override in production', () => { + expect(isProd).toBe(true) + expect(isHosted).toBe(false) + }) +}) diff --git a/apps/sim/lib/core/config/env-flags.ts b/apps/sim/lib/core/config/env-flags.ts index 1a954327eea..d90b2aa1e84 100644 --- a/apps/sim/lib/core/config/env-flags.ts +++ b/apps/sim/lib/core/config/env-flags.ts @@ -35,13 +35,16 @@ export const isTest = env.NODE_ENV === 'test' /** * Is this the hosted version of the application. * True for sim.ai and any subdomain of sim.ai (e.g. staging.sim.ai, dev.sim.ai). + * The browser hostname remains available when an error document boots without + * the root layout's runtime environment, before client rendering recovers it. + * A valid configured URL takes precedence; server detection stays env-only. */ const appUrl = getEnv('NEXT_PUBLIC_APP_URL') -let appHostname = '' +let appHostname = typeof window === 'undefined' ? '' : window.location.hostname try { - appHostname = appUrl ? new URL(appUrl).hostname : '' + if (appUrl) appHostname = new URL(appUrl).hostname } catch { - // invalid URL — isHosted stays false + /** Keep the document hostname when the configured URL cannot be parsed. */ } /** * Local-development escape hatch for exercising hosted-only paths (the sim-auto diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 947688417a7..31f967a3b38 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -24,11 +24,11 @@ import { z } from 'zod' * hydration. So on a warm cache both module bodies and the first commit can run * before the parser has reached the assignment. * - * An attribute has no such ordering problem. `` is the first tag in the - * document — ~490 bytes ahead of the first bootstrap script — so - * `document.documentElement` already carries this value by the time *any* - * script, framework or application, is able to execute. This is the race-free - * transport; `window.__ENV` stays the public global and the preferred read. + * On a normally rendered document, the attribute is parsed before bootstrap + * scripts can execute. Next's server-rendering error document omits the root + * layout, so client recovery only installs the attribute when that layout + * mounts. Hosted detection also uses the browser hostname during this gap. + * `window.__ENV` stays the public global and the preferred read. */ export const PUBLIC_ENV_ATTRIBUTE = 'data-public-env'