diff --git a/apps/sim/.env.example b/apps/sim/.env.example index 54451772873..c7313cbaa07 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -17,6 +17,7 @@ BETTER_AUTH_URL=http://localhost:3000 # NextJS (Required) NEXT_PUBLIC_APP_URL=http://localhost:3000 +# NEXT_PUBLIC_STATUS_NOTICE_PREVIEW=true # Force the sidebar service-status notice into its critical preview state for testing # INTERNAL_API_BASE_URL=http://sim-app.default.svc.cluster.local:3000 # Optional: internal URL for server-side /api self-calls; defaults to NEXT_PUBLIC_APP_URL # TRUSTED_ORIGINS=https://www.example.com,https://app.example.com # Optional: comma-separated additional public origins to trust for auth (apex+www, alias domains). Merged into Better Auth trustedOrigins. # AUTH_TRUSTED_PROXIES=10.0.0.0/24,192.0.2.10 # Optional: reverse-proxy IPs/CIDRs in front of the app. Better Auth walks x-forwarded-for right to left, skips these hops, and uses the first untrusted address as the client IP (prevents forwarded-header spoofing). Use your proxies' actual addresses, not broad private ranges that also cover clients. diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts index e1b2ba50b3a..69b503f2cc4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts @@ -14,5 +14,6 @@ export { SidebarFooter } from './sidebar-footer' export type { SidebarNavItemData } from './sidebar-nav-chip' export { SidebarNavChip } from './sidebar-nav-chip' export { SidebarSection } from './sidebar-section' +export { StatusNotice } from './status-notice' export { WorkflowList } from './workflow-list' export { WorkspaceHeader } from './workspace-header' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/status-notice/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/status-notice/index.ts new file mode 100644 index 00000000000..56bb122f4b6 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/status-notice/index.ts @@ -0,0 +1 @@ +export { StatusNotice } from './status-notice' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/status-notice/status-notice.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/status-notice/status-notice.test.tsx new file mode 100644 index 00000000000..2a63210da6e --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/status-notice/status-notice.test.tsx @@ -0,0 +1,106 @@ +/** + * @vitest-environment jsdom + */ + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockUseStatusPage } = vi.hoisted(() => ({ + mockUseStatusPage: vi.fn(), +})) + +vi.mock('@/hooks/queries/status-page', () => ({ + useStatusPage: mockUseStatusPage, +})) + +import { StatusNotice } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/status-notice/status-notice' + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + vi.clearAllMocks() + mockUseStatusPage.mockReturnValue({ data: undefined, error: null }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.restoreAllMocks() +}) + +function render() { + act(() => root.render()) +} + +describe('StatusNotice', () => { + it('shows the local status alert without fetching live status in preview mode', () => { + act(() => root.render()) + + const notice = container.querySelector('[role="alert"]') + expect(notice?.textContent).toContain('Sim is having issues') + expect(notice?.className).toContain('bg-[var(--terminal-status-error-bg)]') + expect(notice?.className).toContain('border-[var(--terminal-status-error-border)]') + expect(container.querySelector('svg')?.classList.contains('text-[var(--text-icon)]')).toBe(true) + expect(mockUseStatusPage).toHaveBeenCalledWith({ enabled: false }) + }) + + it('stays hidden while loading and for operational or minor incidents', () => { + render() + expect(container.textContent).toBe('') + + mockUseStatusPage.mockReturnValue({ + data: { status: { description: 'All Systems Operational', indicator: 'none' } }, + error: null, + }) + render() + + expect(container.textContent).toBe('') + + mockUseStatusPage.mockReturnValue({ + data: { status: { description: 'Minor Service Outage', indicator: 'minor' } }, + error: null, + }) + render() + + expect(container.textContent).toBe('') + }) + + it('shows the notice for a major incident and opens the status page', () => { + mockUseStatusPage.mockReturnValue({ + data: { status: { description: 'Major Service Outage', indicator: 'major' } }, + error: null, + }) + + render() + + const notice = container.querySelector('[role="alert"]') + const action = container.querySelector('a') + expect(notice?.className).toContain('border-[var(--terminal-status-error-border)]') + expect(notice?.className).toContain('shadow-[var(--shadow-overlay)]') + expect(notice?.className).toContain( + '[--surface-hover:color-mix(in_srgb,var(--text-error)_8%,transparent)]' + ) + expect(action?.textContent).toContain('View status') + expect(action?.className).not.toContain('bg-[var(--text-error)]') + expect(action?.getAttribute('href')).toBe('https://status.sim.ai') + expect(action?.getAttribute('target')).toBe('_blank') + expect(action?.getAttribute('rel')).toBe('noopener noreferrer') + }) + + it('stays hidden when the optional status query fails', () => { + mockUseStatusPage.mockReturnValue({ + data: undefined, + error: new Error('status unavailable'), + }) + + render() + + expect(container.textContent).toBe('') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/status-notice/status-notice.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/status-notice/status-notice.tsx new file mode 100644 index 00000000000..5e8bfa3e72c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/status-notice/status-notice.tsx @@ -0,0 +1,51 @@ +'use client' + +import { ChipLink } from '@sim/emcn' +import { CircleAlert } from '@sim/emcn/icons' +import { STATUS_PAGE_URL } from '@/lib/status-page' +import { useStatusPage } from '@/hooks/queries/status-page' + +const PREVIEW_STATUS = { + description: 'Major Service Outage', + indicator: 'critical', +} as const + +interface StatusNoticeProps { + preview?: boolean +} + +function StatusAlert() { + return ( +
+
+ +

Sim is having issues

+
+ + View status + +
+ ) +} + +export function StatusNotice({ preview = false }: StatusNoticeProps) { + const { data } = useStatusPage({ enabled: !preview }) + + const status = preview ? PREVIEW_STATUS : data?.status + + if (status?.indicator !== 'major' && status?.indicator !== 'critical') { + return null + } + + return +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index 47fc7095c30..cbec949a489 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -38,7 +38,7 @@ import { usePostHog } from 'posthog-js/react' import { useSession } from '@/lib/auth/auth-client' import { focusVisibleBrowserOmnibox } from '@/lib/browser-agent/renderer-shortcuts' import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types' -import { isChatEnabled } from '@/lib/core/config/env-flags' +import { isChatEnabled, isHosted, isStatusNoticePreviewEnabled } from '@/lib/core/config/env-flags' import { isMacPlatform } from '@/lib/core/utils/platform' import { buildFolderTree, getFolderPathNames } from '@/lib/folders/tree' import { captureEvent } from '@/lib/posthog/client' @@ -62,6 +62,7 @@ import { SidebarNavChip, type SidebarNavItemData, SidebarSection, + StatusNotice, TablesRailFlyout, WorkflowList, WorkspaceHeader, @@ -1479,7 +1480,7 @@ export const Sidebar = memo(function Sidebar({ ref={isCollapsed ? undefined : scrollContainerRef} className={cn( SIDEBAR_DIVIDER_PAD_BELOW_CLASS, - 'flex flex-1 flex-col overflow-y-auto overflow-x-hidden border-t transition-colors duration-150', + 'flex min-h-0 flex-1 flex-col overflow-y-auto overflow-x-hidden border-t transition-colors duration-150', !hasOverflowTop && 'border-transparent' )} > @@ -1807,6 +1808,12 @@ export const Sidebar = memo(function Sidebar({ + {(isHosted || isStatusNoticePreviewEnabled) && !isCollapsed ? ( +
+ +
+ ) : null} + [...statusPageKeys.all, 'summary'] as const, + summary: () => [...statusPageKeys.summaries(), 'sim'] as const, +} + +interface UseStatusPageOptions { + enabled?: boolean +} + +/** Polls Sim's public status while a hosted workspace is open. */ +export function useStatusPage({ enabled = true }: UseStatusPageOptions = {}) { + return useQuery({ + queryKey: statusPageKeys.summary(), + queryFn: ({ signal }) => fetchStatusPageSummary(signal), + enabled, + staleTime: STATUS_PAGE_STALE_TIME, + refetchInterval: STATUS_PAGE_POLL_INTERVAL, + refetchOnWindowFocus: true, + retry: false, + }) +} diff --git a/apps/sim/lib/core/config/env-flags.ts b/apps/sim/lib/core/config/env-flags.ts index 135607925f1..95aa7b13720 100644 --- a/apps/sim/lib/core/config/env-flags.ts +++ b/apps/sim/lib/core/config/env-flags.ts @@ -85,6 +85,13 @@ export const isCopilotBillingProtocolRequired = isTruthy(env.COPILOT_BILLING_PRO */ export const isChatEnabled = !isTruthy(getEnv('NEXT_PUBLIC_CHAT_DISABLED')) +/** + * Forces the sidebar service-status notice into its critical preview state. + * This is an explicit testing override; when unset, hosted deployments read + * the live status page and other deployments do not mount the notice. + */ +export const isStatusNoticePreviewEnabled = isTruthy(getEnv('NEXT_PUBLIC_STATUS_NOTICE_PREVIEW')) + /** * Holds tools the catalog marks `requiresApproval` — shell commands, workflow * runs, sandboxed code, deployments, integration calls — behind an explicit diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 2dbf8ab149f..d41d35f4a72 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -710,6 +710,7 @@ export const env = createEnv({ NEXT_PUBLIC_DISABLE_PUBLIC_API: z.boolean().optional(), // Disable public API access UI toggle globally NEXT_PUBLIC_INBOX_ENABLED: z.boolean().optional(), // Enable inbox (Sim Mailer) on self-hosted NEXT_PUBLIC_CHAT_DISABLED: z.boolean().optional(), // Hide the Chat module (Chat is shown when unset) + NEXT_PUBLIC_STATUS_NOTICE_PREVIEW: z.boolean().optional(), // Force the sidebar service-status notice into its critical preview state NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: z.boolean().optional().default(true), // Control visibility of email/password login forms NEXT_PUBLIC_TURNSTILE_SITE_KEY: z.string().min(1).optional(), // Cloudflare Turnstile site key for captcha widget }, @@ -754,6 +755,7 @@ export const env = createEnv({ NEXT_PUBLIC_DISABLE_PUBLIC_API: process.env.NEXT_PUBLIC_DISABLE_PUBLIC_API, NEXT_PUBLIC_INBOX_ENABLED: process.env.NEXT_PUBLIC_INBOX_ENABLED, NEXT_PUBLIC_CHAT_DISABLED: process.env.NEXT_PUBLIC_CHAT_DISABLED, + NEXT_PUBLIC_STATUS_NOTICE_PREVIEW: process.env.NEXT_PUBLIC_STATUS_NOTICE_PREVIEW, NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: process.env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED, NEXT_PUBLIC_TURNSTILE_SITE_KEY: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY, NEXT_PUBLIC_E2B_ENABLED: process.env.NEXT_PUBLIC_E2B_ENABLED, diff --git a/apps/sim/lib/core/security/csp.test.ts b/apps/sim/lib/core/security/csp.test.ts index 1cf199dc08c..9dcf30850e0 100644 --- a/apps/sim/lib/core/security/csp.test.ts +++ b/apps/sim/lib/core/security/csp.test.ts @@ -272,6 +272,10 @@ describe('buildTimeCSPDirectives', () => { expect(buildTimeCSPDirectives['font-src']).toContain('https://fonts.gstatic.com') }) + it('allows the hosted app to read the Sim status page', () => { + expect(getMainCSPPolicy()).toMatch(/connect-src[^;]*https:\/\/status\.sim\.ai/) + }) + it('should allow data: and blob: for images', () => { expect(buildTimeCSPDirectives['img-src']).toContain('data:') expect(buildTimeCSPDirectives['img-src']).toContain('blob:') diff --git a/apps/sim/lib/core/security/csp.ts b/apps/sim/lib/core/security/csp.ts index ca54d1afa9a..ec03a394445 100644 --- a/apps/sim/lib/core/security/csp.ts +++ b/apps/sim/lib/core/security/csp.ts @@ -109,6 +109,7 @@ const STATIC_CONNECT_SRC = [ 'https://*.supabase.co', 'https://api.github.com', 'https://github.com/*', + 'https://status.sim.ai', 'https://challenges.cloudflare.com', // Cal.com booking embed (landing /demo) — embed XHR/availability calls 'https://app.cal.com', diff --git a/apps/sim/lib/status-page.test.ts b/apps/sim/lib/status-page.test.ts new file mode 100644 index 00000000000..02675fd74b5 --- /dev/null +++ b/apps/sim/lib/status-page.test.ts @@ -0,0 +1,56 @@ +/** + * @vitest-environment node + */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { fetchStatusPageSummary } from '@/lib/status-page' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('fetchStatusPageSummary', () => { + it('returns a validated public status summary and forwards cancellation', async () => { + const signal = new AbortController().signal + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + page: { name: 'Sim' }, + status: { description: 'Minor Service Outage', indicator: 'minor' }, + }), + { status: 200 } + ) + ) + vi.stubGlobal('fetch', fetchMock) + + await expect(fetchStatusPageSummary(signal)).resolves.toEqual({ + status: { description: 'Minor Service Outage', indicator: 'minor' }, + }) + expect(fetchMock).toHaveBeenCalledWith( + 'https://status.sim.ai/api/v2/status.json', + expect.objectContaining({ signal }) + ) + }) + + it('throws when the status endpoint fails', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 503 }))) + + await expect(fetchStatusPageSummary()).rejects.toThrow('Status page request failed with 503') + }) + + it('throws when the provider returns an unknown indicator', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + status: { description: 'Unexpected status', indicator: 'unknown' }, + }), + { status: 200 } + ) + ) + ) + + await expect(fetchStatusPageSummary()).rejects.toThrow() + }) +}) diff --git a/apps/sim/lib/status-page.ts b/apps/sim/lib/status-page.ts new file mode 100644 index 00000000000..e2044a4c551 --- /dev/null +++ b/apps/sim/lib/status-page.ts @@ -0,0 +1,35 @@ +import { z } from 'zod' + +export const STATUS_PAGE_URL = 'https://status.sim.ai' as const +const STATUS_PAGE_API_URL = `${STATUS_PAGE_URL}/api/v2/status.json` as const + +const statusPageIndicatorSchema = z.enum(['none', 'minor', 'major', 'critical']) + +const statusPageSummarySchema = z.object({ + status: z.object({ + description: z + .string() + .min(1, 'Status description cannot be empty') + .max(200, 'Status description cannot exceed 200 characters'), + indicator: statusPageIndicatorSchema, + }), +}) + +export type StatusPageIndicator = z.output +export type StatusPageSummary = z.output + +/** Loads and validates Sim's public service status. */ +export async function fetchStatusPageSummary(signal?: AbortSignal): Promise { + // boundary-raw-fetch: external Incident.io status API, not a same-origin Sim API + const response = await fetch(STATUS_PAGE_API_URL, { + cache: 'no-store', + headers: { Accept: 'application/json' }, + signal, + }) + + if (!response.ok) { + throw new Error(`Status page request failed with ${response.status}`) + } + + return statusPageSummarySchema.parse(await response.json()) +} diff --git a/packages/testing/src/mocks/env-flags.mock.ts b/packages/testing/src/mocks/env-flags.mock.ts index 3ede0b82876..404a297fb73 100644 --- a/packages/testing/src/mocks/env-flags.mock.ts +++ b/packages/testing/src/mocks/env-flags.mock.ts @@ -15,6 +15,7 @@ export interface EnvFlagsMockState { isCopilotBillingAttributionV1Enabled: boolean isCopilotBillingProtocolRequired: boolean isChatEnabled: boolean + isStatusNoticePreviewEnabled: boolean isCopilotToolPermissionsEnabled: boolean isBillingEnabled: boolean isEmailVerificationEnabled: boolean @@ -63,6 +64,7 @@ const defaultEnvFlagsState: EnvFlagsMockState = { isCopilotBillingAttributionV1Enabled: false, isCopilotBillingProtocolRequired: false, isChatEnabled: true, + isStatusNoticePreviewEnabled: false, isCopilotToolPermissionsEnabled: false, isBillingEnabled: false, isEmailVerificationEnabled: false,