Skip to content
Open
30 changes: 30 additions & 0 deletions apps/sim/app/api/favicon/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getDeploymentEnv } from '@/lib/core/config/env-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getBrandConfig, ICON_SETS } from '@/ee/whitelabeling'

export const dynamic = 'force-dynamic'

/**
* Redirect target for the legacy `/favicon.ico` path (rewritten here in
* `next.config.ts`), resolved per-request rather than baked into
* `next.config.ts`'s `rewrites()` directly. This app's Docker image is built
* once with dummy env values and promoted through dev/staging/production —
* `bootstrap.ts` hydrates `process.env` from AWS Secrets Manager at container
* boot, after the build already ran (`docker/app.Dockerfile`) — so a
* same-process-lifetime decision like `getDeploymentEnv()` must be evaluated
* here, at request time, not in `next.config.ts`, which only runs once during
* that shared build and would freeze whichever tier happened to be active
* then (never the tier the container actually ends up running as).
*
* Reuses {@link ICON_SETS} from `ee/whitelabeling/metadata.ts` rather than
* maintaining a second env-to-path map here, so the two can't drift apart. A
* whitelabeled deployment's own favicon always wins here too, matching
* `generateBrandedMetadata()` — a tenant on a dev/staging environment should
* never see Sim's internal tinted mark.
*/
export const GET = withRouteHandler(async (request: NextRequest) => {
const brand = getBrandConfig()
const destination = brand.faviconUrl || ICON_SETS[getDeploymentEnv()].svg
return NextResponse.redirect(new URL(destination, request.url))
})
Comment thread
waleedlatif1 marked this conversation as resolved.
27 changes: 26 additions & 1 deletion apps/sim/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,32 @@ export const viewport: Viewport = {
],
}

export const metadata: Metadata = generateBrandedMetadata()
/**
* Not a static `export const metadata` object: {@link generateBrandedMetadata}
* reads `getDeploymentEnv()` for the favicon set, which depends on
* `process.env` being populated by `bootstrap.ts`'s runtime secrets load. A
* static object is resolved once at module-evaluation time and Next.js treats
* it as truly constant (eligible for build-time resolution) —
* `generateMetadata()` at least makes resolution part of the page's own
* render rather than a frozen module-level value.
*
* Deliberately NOT paired with `export const dynamic = 'force-dynamic'` on
* this (root) layout: every `(landing)` marketing page sets its own
* `revalidate` and relies on being statically generated /
* incrementally revalidated for its Lighthouse/LCP budget (see
* `app/(landing)/CLAUDE.md`) — forcing the root layout dynamic would force
* the entire marketing site dynamic too, which is a much larger regression
* than a favicon staying on its previous tier for up to one revalidate
* window after a deploy. The actual workspace app (this feature's real
* audience) is already fully dynamic today (session/auth), so
* `generateMetadata()` already resolves per-request there without needing
* the marker. Marketing pages accept bounded staleness instead: the favicon
* self-corrects at the next ISR regeneration, which runs in the live server
* process (after `bootstrap.ts` has loaded secrets), not at build time.
*/
export function generateMetadata(): Metadata {
return generateBrandedMetadata()
}
Comment thread
waleedlatif1 marked this conversation as resolved.

const GTM_ID = 'GTM-T7PHSRX5' as const
const GA_ID = 'G-DR7YBE70VS' as const
Expand Down
33 changes: 15 additions & 18 deletions apps/sim/app/manifest.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import type { MetadataRoute } from 'next'
import { getBrandConfig } from '@/ee/whitelabeling'
import { getDeploymentEnv } from '@/lib/core/config/env-flags'
import { getBrandConfig, ICON_SETS } from '@/ee/whitelabeling'

export const dynamic = 'force-dynamic'

export default function manifest(): MetadataRoute.Manifest {
const brand = getBrandConfig()
const icons = ICON_SETS[getDeploymentEnv()]

return {
name:
Expand All @@ -20,23 +22,18 @@ export default function manifest(): MetadataRoute.Manifest {
background_color: '#ffffff',
theme_color: brand.theme?.primaryColor || '#33C482',
orientation: 'portrait-primary',
icons: [
{
src: '/favicon/android-chrome-192x192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: '/favicon/android-chrome-512x512.png',
sizes: '512x512',
type: 'image/png',
},
{
src: '/favicon/apple-touch-icon.png',
sizes: '180x180',
type: 'image/png',
},
],
// A whitelabeled deployment's install/home-screen icon should be the
// tenant's own brand, not Sim's; otherwise it's the same per-environment
// set as the HTML favicon slots in generateBrandedMetadata()
// (ee/whitelabeling/metadata.ts), so a staging/dev PWA install doesn't
// look like prod either.
icons: brand.faviconUrl
? [{ src: brand.faviconUrl, sizes: 'any', type: 'image/png' }]
: [
{ src: icons.android192, sizes: '192x192', type: 'image/png' },
{ src: icons.android512, sizes: '512x512', type: 'image/png' },
{ src: icons.apple, sizes: '180x180', type: 'image/png' },
],
Comment thread
waleedlatif1 marked this conversation as resolved.
categories: ['productivity', 'developer', 'business'],
shortcuts: [
{
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/ee/whitelabeling/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ export type { OrganizationWhitelabelSettings } from '@/lib/branding/types'
export type { BrandConfig, ThemeColors } from './branding'
export { getBrandConfig, useBrandConfig } from './branding'
export { generateThemeCSS } from './inject-theme'
export { generateBrandedMetadata, generateStructuredData } from './metadata'
export { generateBrandedMetadata, generateStructuredData, ICON_SETS } from './metadata'
export { generateOrgThemeCSS, mergeOrgBrandConfig } from './org-branding-utils'
85 changes: 67 additions & 18 deletions apps/sim/ee/whitelabeling/metadata.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,62 @@
import type { Metadata } from 'next'
import { getDeploymentEnv } from '@/lib/core/config/env-flags'
import { getBaseUrl, SITE_URL } from '@/lib/core/utils/urls'
import { getBrandConfig } from '@/ee/whitelabeling/branding'

interface FaviconSet {
svg: string
favicon16: string
favicon32: string
android192: string
android512: string
apple: string
}

/**
* Same "sim" wordmark per {@link getDeploymentEnv}, background color only —
* so a dev/staging tab is never mistaken for prod at a glance. Ignored
* entirely when `brand.faviconUrl` is set (a whitelabeled deployment's own
* favicon always wins, regardless of which tier it's running on). Exported
* for `app/api/favicon/route.ts` (the legacy `/favicon.ico` redirect target)
* to reuse `.svg` from, so the two never drift out of sync the way two
* separately-maintained copies could.
*/
export const ICON_SETS: Record<ReturnType<typeof getDeploymentEnv>, FaviconSet> = {
development: {
svg: '/icon-dev.svg',
favicon16: '/favicon-dev/favicon-16x16.png',
favicon32: '/favicon-dev/favicon-32x32.png',
android192: '/favicon-dev/android-chrome-192x192.png',
android512: '/favicon-dev/android-chrome-512x512.png',
apple: '/favicon-dev/apple-touch-icon.png',
},
staging: {
svg: '/icon-staging.svg',
favicon16: '/favicon-staging/favicon-16x16.png',
favicon32: '/favicon-staging/favicon-32x32.png',
android192: '/favicon-staging/android-chrome-192x192.png',
android512: '/favicon-staging/android-chrome-512x512.png',
apple: '/favicon-staging/apple-touch-icon.png',
},
production: {
svg: '/icon.svg',
favicon16: '/favicon/favicon-16x16.png',
favicon32: '/favicon/favicon-32x32.png',
android192: '/favicon/android-chrome-192x192.png',
android512: '/favicon/android-chrome-512x512.png',
apple: '/favicon/apple-touch-icon.png',
},
}

/**
* Generate dynamic metadata based on brand configuration
*/
export function generateBrandedMetadata(override: Partial<Metadata> = {}): Metadata {
const brand = getBrandConfig()
// Only read when brand.faviconUrl is unset — every icon/apple/shortcut slot
// below short-circuits on brand.faviconUrl first when it's set, so this
// never needs to reflect the whitelabel case.
const icons = ICON_SETS[getDeploymentEnv()]

const defaultTitle = brand.name
const summaryFull = `Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. Connect 1,000+ integrations and every major LLM to create agents that automate real work — visually, conversationally, or with code. Trusted by over 100,000 builders — from startups to Fortune 500 companies. SOC2 compliant.`
Expand Down Expand Up @@ -89,24 +139,23 @@ export function generateBrandedMetadata(override: Partial<Metadata> = {}): Metad
},
manifest: '/manifest.webmanifest',
Comment thread
waleedlatif1 marked this conversation as resolved.
icons: {
icon: [
...(brand.faviconUrl ? [] : [{ url: '/icon.svg', type: 'image/svg+xml', sizes: 'any' }]),
{ url: '/favicon/favicon-16x16.png', sizes: '16x16', type: 'image/png' },
{ url: '/favicon/favicon-32x32.png', sizes: '32x32', type: 'image/png' },
{
url: '/favicon/android-chrome-192x192.png',
sizes: '192x192',
type: 'image/png',
},
{
url: '/favicon/android-chrome-512x512.png',
sizes: '512x512',
type: 'image/png',
},
...(brand.faviconUrl ? [{ url: brand.faviconUrl, sizes: 'any', type: 'image/png' }] : []),
],
apple: '/favicon/apple-touch-icon.png',
shortcut: brand.faviconUrl || '/icon.svg',
// A whitelabeled deployment only ever supplies one favicon URL (no
// multi-size set of its own) - emitting Sim's exact-size PNG/apple
// entries alongside it would let browsers prefer those more specific
// sizes over the tenant's generic `sizes: 'any'` entry, showing Sim
// branding instead. So the tenant favicon fully replaces every slot
// rather than being appended to Sim's.
icon: brand.faviconUrl
? [{ url: brand.faviconUrl, sizes: 'any', type: 'image/png' }]
Comment thread
waleedlatif1 marked this conversation as resolved.
: [
{ url: icons.svg, type: 'image/svg+xml', sizes: 'any' },
{ url: icons.favicon16, sizes: '16x16', type: 'image/png' },
{ url: icons.favicon32, sizes: '32x32', type: 'image/png' },
{ url: icons.android192, sizes: '192x192', type: 'image/png' },
{ url: icons.android512, sizes: '512x512', type: 'image/png' },
],
apple: brand.faviconUrl || icons.apple,
shortcut: brand.faviconUrl || icons.svg,
},
appleWebApp: {
capable: true,
Expand Down
78 changes: 78 additions & 0 deletions apps/sim/lib/core/config/env-flags.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const { envRef } = vi.hoisted(() => ({
envRef: { NODE_ENV: 'development' as string | undefined },
}))

vi.mock('@/lib/core/config/env', () => ({
get env() {
return envRef
},
getEnv: (key: string) => process.env[key],
isFalsy: (v: unknown) => v === false || v === 'false' || v === '0',
isTruthy: (v: unknown) => v === true || v === 'true' || v === '1',
}))

import { getDeploymentEnv } from '@/lib/core/config/env-flags'

describe('getDeploymentEnv', () => {
const ENV_KEYS = [
'OTEL_DEPLOYMENT_ENVIRONMENT',
'DEPLOYMENT_ENVIRONMENT',
'APPCONFIG_ENVIRONMENT',
]

beforeEach(() => {
for (const key of ENV_KEYS) delete process.env[key]
envRef.NODE_ENV = 'development'
})

afterEach(() => {
for (const key of ENV_KEYS) delete process.env[key]
})

it('resolves the dev tier from OTEL_DEPLOYMENT_ENVIRONMENT=dev', () => {
process.env.OTEL_DEPLOYMENT_ENVIRONMENT = 'dev'
expect(getDeploymentEnv()).toBe('development')
})

it('resolves the staging tier from OTEL_DEPLOYMENT_ENVIRONMENT=staging', () => {
process.env.OTEL_DEPLOYMENT_ENVIRONMENT = 'staging'
expect(getDeploymentEnv()).toBe('staging')
})

it('resolves the production tier from OTEL_DEPLOYMENT_ENVIRONMENT=prod', () => {
process.env.OTEL_DEPLOYMENT_ENVIRONMENT = 'prod'
expect(getDeploymentEnv()).toBe('production')
})

it('maps APPCONFIG_ENVIRONMENT=production to the production tier when OTEL var is unset', () => {
process.env.APPCONFIG_ENVIRONMENT = 'production'
expect(getDeploymentEnv()).toBe('production')
})

it('falls back to NODE_ENV when no deployment-tier env var is set', () => {
envRef.NODE_ENV = 'production'
expect(getDeploymentEnv()).toBe('production')
})

it('defaults to development when nothing is set at all', () => {
envRef.NODE_ENV = undefined
expect(getDeploymentEnv()).toBe('development')
})

it('prefers OTEL_DEPLOYMENT_ENVIRONMENT over DEPLOYMENT_ENVIRONMENT and APPCONFIG_ENVIRONMENT', () => {
process.env.OTEL_DEPLOYMENT_ENVIRONMENT = 'staging'
process.env.DEPLOYMENT_ENVIRONMENT = 'prod'
process.env.APPCONFIG_ENVIRONMENT = 'production'
expect(getDeploymentEnv()).toBe('staging')
})

it('buckets an unrecognized tier value to development rather than throwing', () => {
process.env.OTEL_DEPLOYMENT_ENVIRONMENT = 'some-future-tier'
expect(getDeploymentEnv()).toBe('development')
})
})
38 changes: 38 additions & 0 deletions apps/sim/lib/core/config/env-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,44 @@ export const isDev = env.NODE_ENV === 'development'
*/
export const isTest = env.NODE_ENV === 'test'

/**
* Raw deployment-tier signal ('dev' | 'staging' | 'prod' | ...), read the same
* way `instrumentation-node.ts` and `lib/monitoring/metrics.ts` resolve it for
* OTel's `deployment.environment` attribute: every deployed Sim tier sets
* `OTEL_DEPLOYMENT_ENVIRONMENT` directly (dev/staging/prod, matching Go's
* value space); failing that, `APPCONFIG_ENVIRONMENT` (dev/staging/production
* — mapped production -> prod); local dev has neither and falls back to
* `NODE_ENV`. This app has no AWS_PROFILE/Vercel-style env var of its own —
* `OTEL_DEPLOYMENT_ENVIRONMENT` is the existing per-tier signal already set in
* AWS Secrets Manager for dev/staging/production, so this reuses it rather
* than inventing a second one.
*/
function resolveRawDeploymentEnv(): string {
return (
process.env.OTEL_DEPLOYMENT_ENVIRONMENT ||
process.env.DEPLOYMENT_ENVIRONMENT ||
(process.env.APPCONFIG_ENVIRONMENT === 'production'
? 'prod'
: process.env.APPCONFIG_ENVIRONMENT) ||
env.NODE_ENV ||
'development'
)
}

/**
* The deployment tier this build is running in, bucketed from
* {@link resolveRawDeploymentEnv} to a fixed three-way set for UI-facing
* branches — currently just per-environment favicons, so it's obvious at a
* glance which tab is prod vs. a staging/local build. See
* `ee/whitelabeling/metadata.ts` and `next.config.ts`.
*/
export function getDeploymentEnv(): 'development' | 'staging' | 'production' {
const raw = resolveRawDeploymentEnv()
if (raw === 'staging') return 'staging'
if (raw === 'prod' || raw === 'production') return 'production'
return 'development'
}

/**
* 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).
Expand Down
5 changes: 4 additions & 1 deletion apps/sim/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,8 +437,11 @@ const nextConfig: NextConfig = {
async rewrites() {
return [
{
// Resolved per-request in app/api/favicon/route.ts, not here — see
// that file's TSDoc for why: this config only runs once during the
// shared, environment-agnostic Docker build.
source: '/favicon.ico',
destination: '/icon.svg',
destination: '/api/favicon',
},
{
source: '/r/:shortCode',
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,6 @@ export const config = {
'/invite/:path*', // Match invitation routes
'/api/:path*', // Runtime CORS
// Catch-all for other pages, excluding static assets and public directories
'/((?!api/|api$|_next/static|_next/image|ingest|favicon.ico|logo/|landing/|static/|footer/|social/|enterprise/|favicon/|twitter/|robots.txt|sitemap.xml).*)',
'/((?!api/|api$|_next/static|_next/image|ingest|favicon.ico|logo/|landing/|static/|footer/|social/|enterprise/|favicon/|favicon-dev/|favicon-staging/|twitter/|robots.txt|sitemap.xml).*)',
],
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/sim/public/favicon-dev/apple-touch-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/sim/public/favicon-dev/favicon-16x16.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/sim/public/favicon-dev/favicon-32x32.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/sim/public/favicon-dev/favicon.ico
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/sim/public/favicon-staging/favicon-16x16.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/sim/public/favicon-staging/favicon-32x32.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/sim/public/favicon-staging/favicon.ico
Binary file not shown.
Loading
Loading