diff --git a/apps/sim/app/api/favicon/route.ts b/apps/sim/app/api/favicon/route.ts new file mode 100644 index 00000000000..275f2d89439 --- /dev/null +++ b/apps/sim/app/api/favicon/route.ts @@ -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)) +}) diff --git a/apps/sim/app/layout.tsx b/apps/sim/app/layout.tsx index baf60659e7e..358ba0d5fa7 100644 --- a/apps/sim/app/layout.tsx +++ b/apps/sim/app/layout.tsx @@ -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() +} const GTM_ID = 'GTM-T7PHSRX5' as const const GA_ID = 'G-DR7YBE70VS' as const diff --git a/apps/sim/app/manifest.ts b/apps/sim/app/manifest.ts index 23e600614a0..4e85e36d2ba 100644 --- a/apps/sim/app/manifest.ts +++ b/apps/sim/app/manifest.ts @@ -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: @@ -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' }, + ], categories: ['productivity', 'developer', 'business'], shortcuts: [ { diff --git a/apps/sim/ee/whitelabeling/index.ts b/apps/sim/ee/whitelabeling/index.ts index 6cd22b7b1c3..0d7ff675d0e 100644 --- a/apps/sim/ee/whitelabeling/index.ts +++ b/apps/sim/ee/whitelabeling/index.ts @@ -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' diff --git a/apps/sim/ee/whitelabeling/metadata.ts b/apps/sim/ee/whitelabeling/metadata.ts index 72ce29b3242..ff3acdeeaa4 100644 --- a/apps/sim/ee/whitelabeling/metadata.ts +++ b/apps/sim/ee/whitelabeling/metadata.ts @@ -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, 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 { 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.` @@ -89,24 +139,23 @@ export function generateBrandedMetadata(override: Partial = {}): Metad }, manifest: '/manifest.webmanifest', 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' }] + : [ + { 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, 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..0f8ad38aa93 --- /dev/null +++ b/apps/sim/lib/core/config/env-flags.test.ts @@ -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') + }) +}) diff --git a/apps/sim/lib/core/config/env-flags.ts b/apps/sim/lib/core/config/env-flags.ts index 92b7b26219c..589da7fb306 100644 --- a/apps/sim/lib/core/config/env-flags.ts +++ b/apps/sim/lib/core/config/env-flags.ts @@ -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). diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index c11bcd6cc2c..8e6712cb775 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -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', diff --git a/apps/sim/proxy.ts b/apps/sim/proxy.ts index 3c8247d2706..96e2f79c8aa 100644 --- a/apps/sim/proxy.ts +++ b/apps/sim/proxy.ts @@ -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).*)', ], } diff --git a/apps/sim/public/favicon-dev/android-chrome-192x192.png b/apps/sim/public/favicon-dev/android-chrome-192x192.png new file mode 100644 index 00000000000..e402a619130 Binary files /dev/null and b/apps/sim/public/favicon-dev/android-chrome-192x192.png differ diff --git a/apps/sim/public/favicon-dev/android-chrome-512x512.png b/apps/sim/public/favicon-dev/android-chrome-512x512.png new file mode 100644 index 00000000000..37a47cac005 Binary files /dev/null and b/apps/sim/public/favicon-dev/android-chrome-512x512.png differ diff --git a/apps/sim/public/favicon-dev/apple-touch-icon.png b/apps/sim/public/favicon-dev/apple-touch-icon.png new file mode 100644 index 00000000000..a1938cec1f2 Binary files /dev/null and b/apps/sim/public/favicon-dev/apple-touch-icon.png differ diff --git a/apps/sim/public/favicon-dev/favicon-16x16.png b/apps/sim/public/favicon-dev/favicon-16x16.png new file mode 100644 index 00000000000..0e1341d6c15 Binary files /dev/null and b/apps/sim/public/favicon-dev/favicon-16x16.png differ diff --git a/apps/sim/public/favicon-dev/favicon-32x32.png b/apps/sim/public/favicon-dev/favicon-32x32.png new file mode 100644 index 00000000000..bbb5019e77b Binary files /dev/null and b/apps/sim/public/favicon-dev/favicon-32x32.png differ diff --git a/apps/sim/public/favicon-dev/favicon.ico b/apps/sim/public/favicon-dev/favicon.ico new file mode 100644 index 00000000000..84730b762f7 Binary files /dev/null and b/apps/sim/public/favicon-dev/favicon.ico differ diff --git a/apps/sim/public/favicon-staging/android-chrome-192x192.png b/apps/sim/public/favicon-staging/android-chrome-192x192.png new file mode 100644 index 00000000000..8a2f1a7c4bf Binary files /dev/null and b/apps/sim/public/favicon-staging/android-chrome-192x192.png differ diff --git a/apps/sim/public/favicon-staging/android-chrome-512x512.png b/apps/sim/public/favicon-staging/android-chrome-512x512.png new file mode 100644 index 00000000000..b7bddf3095d Binary files /dev/null and b/apps/sim/public/favicon-staging/android-chrome-512x512.png differ diff --git a/apps/sim/public/favicon-staging/apple-touch-icon.png b/apps/sim/public/favicon-staging/apple-touch-icon.png new file mode 100644 index 00000000000..50ba7a48f72 Binary files /dev/null and b/apps/sim/public/favicon-staging/apple-touch-icon.png differ diff --git a/apps/sim/public/favicon-staging/favicon-16x16.png b/apps/sim/public/favicon-staging/favicon-16x16.png new file mode 100644 index 00000000000..4d88135a7cb Binary files /dev/null and b/apps/sim/public/favicon-staging/favicon-16x16.png differ diff --git a/apps/sim/public/favicon-staging/favicon-32x32.png b/apps/sim/public/favicon-staging/favicon-32x32.png new file mode 100644 index 00000000000..5ee6ff6bfe9 Binary files /dev/null and b/apps/sim/public/favicon-staging/favicon-32x32.png differ diff --git a/apps/sim/public/favicon-staging/favicon.ico b/apps/sim/public/favicon-staging/favicon.ico new file mode 100644 index 00000000000..a83810ecfdd Binary files /dev/null and b/apps/sim/public/favicon-staging/favicon.ico differ diff --git a/apps/sim/public/icon-dev.svg b/apps/sim/public/icon-dev.svg new file mode 100644 index 00000000000..d70aaa35860 --- /dev/null +++ b/apps/sim/public/icon-dev.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/sim/public/icon-staging.svg b/apps/sim/public/icon-staging.svg new file mode 100644 index 00000000000..6b5d56f222a --- /dev/null +++ b/apps/sim/public/icon-staging.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index d8694b3f6bc..bbc209f93d9 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 927, - zodRoutes: 927, + totalRoutes: 928, + zodRoutes: 928, nonZodRoutes: 0, } as const @@ -55,6 +55,9 @@ const INDIRECT_ZOD_ROUTES = new Set([ // tokens and there are no params, query, or body to validate. Previously had // no-op `validateSchema(noInputSchema, {})` guards. 'apps/sim/app/api/health/route.ts', + // Redirect target for the legacy /favicon.ico rewrite in next.config.ts — + // reads only the server's own deployment-tier env var, no params/query/body. + 'apps/sim/app/api/favicon/route.ts', 'apps/sim/app/api/settings/allowed-providers/route.ts', 'apps/sim/app/api/settings/allowed-integrations/route.ts', 'apps/sim/app/api/settings/allowed-mcp-domains/route.ts',