@@ -161,7 +183,12 @@ export function Footer() {
-
+
© 2026 Sim. All rights reserved.
diff --git a/apps/sim/app/(landing)/components/landing-shell/landing-shell.tsx b/apps/sim/app/(landing)/components/landing-shell/landing-shell.tsx
index 37c867d69d3..abb8746bcab 100644
--- a/apps/sim/app/(landing)/components/landing-shell/landing-shell.tsx
+++ b/apps/sim/app/(landing)/components/landing-shell/landing-shell.tsx
@@ -1,4 +1,5 @@
import type { ReactNode } from 'react'
+import { isHosted } from '@/lib/core/config/env-flags'
import { getGitHubStars } from '@/lib/github/stars'
import { Footer } from '@/app/(landing)/components/footer/footer'
import { Navbar } from '@/app/(landing)/components/navbar/navbar'
@@ -46,7 +47,7 @@ export async function LandingShell({ children }: LandingShellProps) {
{children}
-
+
)
}
diff --git a/apps/sim/app/(landing)/cookie-policy/consent-preferences-link.tsx b/apps/sim/app/(landing)/cookie-policy/consent-preferences-link.tsx
index dac9ce50048..29aa12e783c 100644
--- a/apps/sim/app/(landing)/cookie-policy/consent-preferences-link.tsx
+++ b/apps/sim/app/(landing)/cookie-policy/consent-preferences-link.tsx
@@ -1,7 +1,7 @@
'use client'
import type { ReactNode } from 'react'
-import { OPEN_CONSENT_PREFERENCES_EVENT } from '@/lib/consent/constants'
+import { ConsentPreferencesTrigger } from '@/app/_shell/consent/consent-preferences-trigger'
import { PROSE_TYPE } from '@/app/(landing)/components/prose-page/constants'
interface ConsentPreferencesLinkProps {
@@ -13,19 +13,12 @@ interface ConsentPreferencesLinkProps {
* expanded, so a recorded choice can be withdrawn or changed. Wearing the
* prose link chrome, it reads as part of the sentence it sits in.
*
- * Only rendered where the consent runtime is mounted — see the call site. On a
- * self-hosted deployment nothing would listen for the event, so the Cookie
- * Policy renders the phrase as plain text rather than a control that does
- * nothing when clicked.
+ * Only rendered where the consent runtime is mounted — see the call site. The
+ * Cookie Policy renders plain text on self-hosted deployments, where there is
+ * no preferences dialog to open.
*/
export function ConsentPreferencesLink({ children }: ConsentPreferencesLinkProps) {
return (
-
diff --git a/apps/sim/app/(landing)/hubspot-page-view-tracker.test.tsx b/apps/sim/app/(landing)/hubspot-page-view-tracker.test.tsx
new file mode 100644
index 00000000000..ad0aa6afe8d
--- /dev/null
+++ b/apps/sim/app/(landing)/hubspot-page-view-tracker.test.tsx
@@ -0,0 +1,50 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act, StrictMode } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+const { navigation } = vi.hoisted(() => ({ navigation: { pathname: '/pricing' } }))
+
+vi.mock('next/navigation', () => ({ usePathname: () => navigation.pathname }))
+
+import { HubspotPageViewTracker } from '@/app/(landing)/hubspot-page-view-tracker'
+
+let root: Root | null = null
+
+afterEach(() => {
+ act(() => root?.unmount())
+ root = null
+ navigation.pathname = '/pricing'
+ window._hsq = []
+})
+
+describe('HubspotPageViewTracker', () => {
+ it('tracks later paths once without query data under Strict Mode', () => {
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ const container = document.createElement('div')
+ root = createRoot(container)
+ window._hsq = []
+
+ act(() =>
+ root?.render(
+
+
+
+ )
+ )
+ expect(window._hsq).toEqual([])
+
+ navigation.pathname = '/demo'
+ act(() =>
+ root?.render(
+
+
+
+ )
+ )
+
+ expect(window._hsq).toEqual([['setPath', '/demo'], ['trackPageView']])
+ })
+})
diff --git a/apps/sim/app/(landing)/hubspot-page-view-tracker.tsx b/apps/sim/app/(landing)/hubspot-page-view-tracker.tsx
index 2fbb35cf526..85c316cea0f 100644
--- a/apps/sim/app/(landing)/hubspot-page-view-tracker.tsx
+++ b/apps/sim/app/(landing)/hubspot-page-view-tracker.tsx
@@ -1,38 +1,31 @@
'use client'
-import { useEffect } from 'react'
-import { usePathname, useSearchParams } from 'next/navigation'
+import { useEffect, useRef } from 'react'
+import { usePathname } from 'next/navigation'
-declare global {
- interface Window {
- _hsq?: unknown[][]
- }
-}
-
-// next/script dedupes by id and never reloads on remount, so this must be
-// module-scope (not a ref) to survive LandingLayout unmounting/remounting.
let hasTrackedInitialPageView = false
/**
- * The HubSpot loader only auto-tracks the first page load; LandingLayout
- * persists across client-side navigations, so HubSpot never sees the rest.
- * Pushes a manual pageview through `_hsq` on every navigation after the first.
+ * The consent-gated HubSpot loader auto-tracks its first page. Pushes a manual
+ * pageview through `_hsq` for later client navigations.
*/
export function HubspotPageViewTracker() {
const pathname = usePathname()
- const searchParams = useSearchParams()
- const query = searchParams.toString()
+ const lastTrackedPathRef = useRef
(null)
useEffect(() => {
+ if (lastTrackedPathRef.current === pathname) return
+ lastTrackedPathRef.current = pathname
+
if (!hasTrackedInitialPageView) {
hasTrackedInitialPageView = true
return
}
window._hsq = window._hsq || []
- window._hsq.push(['setPath', query ? `${pathname}?${query}` : pathname])
+ window._hsq.push(['setPath', pathname])
window._hsq.push(['trackPageView'])
- }, [pathname, query])
+ }, [pathname])
return null
}
diff --git a/apps/sim/app/(landing)/landing-consent-tracking.tsx b/apps/sim/app/(landing)/landing-consent-tracking.tsx
new file mode 100644
index 00000000000..08997063938
--- /dev/null
+++ b/apps/sim/app/(landing)/landing-consent-tracking.tsx
@@ -0,0 +1,18 @@
+'use client'
+
+import { useConsentScript } from '@c15t/nextjs/headless'
+import { HUBSPOT_SCRIPT, X_PIXEL_SCRIPT } from '@/lib/consent/scripts'
+import { HubspotPageViewTracker } from '@/app/(landing)/hubspot-page-view-tracker'
+import { XPageViewTracker } from '@/app/(landing)/x-page-view-tracker'
+
+export function LandingConsentTracking() {
+ const hubspot = useConsentScript({ script: HUBSPOT_SCRIPT, unmountBehavior: 'keep' })
+ const xPixel = useConsentScript({ script: X_PIXEL_SCRIPT, unmountBehavior: 'keep' })
+
+ return (
+ <>
+ {hubspot.status === 'ready' && }
+ {xPixel.status === 'ready' && }
+ >
+ )
+}
diff --git a/apps/sim/app/(landing)/layout.tsx b/apps/sim/app/(landing)/layout.tsx
index 9fef6524507..608b350f322 100644
--- a/apps/sim/app/(landing)/layout.tsx
+++ b/apps/sim/app/(landing)/layout.tsx
@@ -1,22 +1,9 @@
import type { ReactNode } from 'react'
-import { Suspense } from 'react'
import type { Metadata } from 'next'
-import Script from 'next/script'
import { isHosted } from '@/lib/core/config/env-flags'
import { SITE_URL } from '@/lib/core/utils/urls'
import { LandingShell } from '@/app/(landing)/components'
-import { HubspotPageViewTracker } from '@/app/(landing)/hubspot-page-view-tracker'
-import { XPageViewTracker } from '@/app/(landing)/x-page-view-tracker'
-
-const HUBSPOT_SCRIPT_SRC = 'https://js-na2.hs-scripts.com/246720681.js' as const
-
-const X_PIXEL_ID = 'q5xbl' as const
-
-/** X (Twitter) conversion tracking base code — loads uwt.js and fires the initial PageView. */
-const X_PIXEL_BASE_CODE = `!function(e,t,n,s,u,a){e.twq||(s=e.twq=function(){s.exe?s.exe.apply(s,arguments):s.queue.push(arguments);
-},s.version='1.1',s.queue=[],u=t.createElement(n),u.async=!0,u.src='https://static.ads-twitter.com/uwt.js',
-a=t.getElementsByTagName(n)[0],a.parentNode.insertBefore(u,a))}(window,document,'script');
-twq('config','${X_PIXEL_ID}');`
+import { LandingConsentTracking } from '@/app/(landing)/landing-consent-tracking'
/**
* Route-group layout for the entire landing family - the home page, platform and
@@ -42,19 +29,7 @@ export default function LandingLayout({ children }: { children: ReactNode }) {
return (
{children}
- {/* HubSpot + X pixel tracking — hosted only */}
- {isHosted && (
- <>
-
-
-
-
-
-
- >
- )}
+ {isHosted && }
)
}
diff --git a/apps/sim/app/(landing)/models/utils.ts b/apps/sim/app/(landing)/models/utils.ts
index d2ed4dcdf60..276d9519481 100644
--- a/apps/sim/app/(landing)/models/utils.ts
+++ b/apps/sim/app/(landing)/models/utils.ts
@@ -1,4 +1,5 @@
import type { ComponentType } from 'react'
+import { slugify } from '@sim/utils/string'
import { type ModelCapabilities, PROVIDER_DEFINITIONS } from '@/providers/models'
const PROVIDER_PREFIXES: Record = {
@@ -224,14 +225,6 @@ function trimTrailingZeros(value: string): string {
return value.replace(/\.0+$/, '').replace(/(\.\d*?)0+$/, '$1')
}
-function slugify(value: string): string {
- return value
- .toLowerCase()
- .replace(/[^a-z0-9]+/g, '-')
- .replace(/^-+|-+$/g, '')
- .replace(/--+/g, '-')
-}
-
function getProviderPrefixes(providerId: string): string[] {
return PROVIDER_PREFIXES[providerId] ?? [`${providerId}/`]
}
diff --git a/apps/sim/app/(landing)/privacy/privacy-content.tsx b/apps/sim/app/(landing)/privacy/privacy-content.tsx
index bf5240e4695..1cfaec014b3 100644
--- a/apps/sim/app/(landing)/privacy/privacy-content.tsx
+++ b/apps/sim/app/(landing)/privacy/privacy-content.tsx
@@ -1,24 +1,55 @@
+import { Fragment, type ReactNode } from 'react'
import { type LegalPageConfig, ProseLink } from '@/app/(landing)/components/prose-page'
-/**
- * Privacy Policy content - the verbatim legal text, expressed as the typed
- * {@link LegalPageConfig} that {@link ProsePage} renders. The text is ported
- * unchanged from the prior Privacy document; only the layout, definition-list
- * emphasis, and inline-link chrome are re-authored onto the landing primitives.
- */
+const INLINE_PATTERN =
+ /(\*\*[^*]+\*\*|\[[^\]]+\]\([^)]+\)|https:\/\/[^\s)]+|[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,})/gi
+
+function richText(content: string): ReactNode {
+ return content.split(INLINE_PATTERN).map((part, index) => {
+ const key = `inline-${index}-${part}`
+ if (part.startsWith('**') && part.endsWith('**')) {
+ return {part.slice(2, -2)}
+ }
+
+ const link = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/)
+ if (link) {
+ return (
+
+ {link[1]}
+
+ )
+ }
+
+ if (part.startsWith('https://')) {
+ return (
+
+ {part}
+
+ )
+ }
+
+ if (part.includes('@')) {
+ return (
+
+ {part}
+
+ )
+ }
+
+ return {part}
+ })
+}
+
export const PRIVACY_CONFIG: LegalPageConfig = {
title: 'Privacy Policy',
- description:
- 'How Sim, the open-source AI workspace, collects, uses, and protects your data, including data obtained from Google APIs, and the controls you have over it.',
- lastUpdated: 'August 18, 2026',
+ description: 'Sim Studio, Inc. · Operating the Sim platform (sim.ai)',
+ lastUpdated: 'August 24, 2026',
intro: [
{
kind: 'paragraph',
- content: `This Privacy Policy describes how Sim ("we", "us", "our", or "the Service") collects, uses, discloses, and protects personal data, including data obtained from Google APIs (including Google Workspace APIs), and your rights and controls regarding that data.`,
- },
- {
- kind: 'paragraph',
- content: `By using or accessing the Service, you confirm that you have read and understood this Privacy Policy, and you consent to the collection, use, and disclosure of your information as described herein.`,
+ content: richText(
+ 'This Privacy Policy describes how Sim ("we", "us", "our", or "the Service") collects, uses, discloses, and protects personal data, including data obtained from Google APIs (including Google Workspace APIs), and your rights and controls regarding that data. This Privacy Policy is provided for transparency and information purposes only, including to satisfy the information obligations in Articles 13 and 14 of the General Data Protection Regulation ("GDPR"). It does not create contractual obligations on you. Your use of the Service is governed by the [Terms of Service](https://sim.ai/terms).'
+ ),
},
],
sections: [
@@ -29,122 +60,73 @@ export const PRIVACY_CONFIG: LegalPageConfig = {
{ kind: 'subheading', text: 'Interpretation' },
{
kind: 'paragraph',
- content: `Under the following conditions, the meanings of words with capitalized first letters are defined. The following definitions have the same meaning whether they are written in singular or plural form.`,
+ content: richText(
+ 'Under the following conditions, the meanings of words with capitalized first letters are defined. The following definitions have the same meaning whether they are written in singular or plural form.'
+ ),
},
{ kind: 'subheading', text: 'Definitions' },
- { kind: 'paragraph', content: `For the purposes of this Privacy Policy:` },
+ { kind: 'paragraph', content: richText('For the purposes of this Privacy Policy:') },
{
kind: 'list',
items: [
- <>
- Application or Service means the Sim web or mobile
- application or related services.
- >,
- <>
- Account means a unique account created for You to access our Service
- or parts of our Service.
- >,
- <>
- Affiliate means an entity that controls, is controlled by or is under
- common control with a party, where "control" means ownership of 50% or more of the
- shares, equity interest or other securities entitled to vote for election of directors
- or other managing authority.
- >,
- <>
- Business , for the purpose of the CCPA (California Consumer Privacy
- Act), refers to the Company as the legal entity that collects Consumers' personal
- information and determines the purposes and means of the processing of Consumers'
- personal information, or on behalf of which such information is collected and that
- alone, or jointly with others, determines the purposes and means of the processing of
- consumers' personal information, that does business in the State of California.
- >,
- <>
- Company (referred to as either "the Company", "We", "Us" or "Our" in
- this Agreement) refers to Sim. For the purpose of the GDPR, the Company is the Data
- Controller.
- >,
- <>
- Cookies are small files that are placed on Your computer, mobile
- device or any other device by a website, containing the details of Your browsing
- history on that website among its many uses.
- >,
- <>
- Country refers to: Quebec, Canada
- >,
- <>
- Data Controller , for the purposes of the GDPR (General Data
- Protection Regulation), refers to the Company as the legal person which alone or
- jointly with others determines the purposes and means of the processing of Personal
- Data.
- >,
- <>
- Device means any device that can access the Service such as a
- computer, a cellphone or a digital tablet.
- >,
- <>
- Do Not Track (DNT) is a concept that has been promoted by US
- regulatory authorities, in particular the U.S. Federal Trade Commission (FTC), for the
- Internet industry to develop and implement a mechanism for allowing internet users to
- control the tracking of their online activities across websites.
- >,
- <>
- Personal Data (or "Personal Information") is any information that
- relates to an identified or identifiable individual. For the purposes for GDPR,
- Personal Data means any information relating to You such as a name, an identification
- number, location data, online identifier or to one or more factors specific to the
- physical, physiological, genetic, mental, economic, cultural or social identity. For
- the purposes of the CCPA, Personal Data means any information that identifies, relates
- to, describes or is capable of being associated with, or could reasonably be linked,
- directly or indirectly, with You.
- >,
- <>
- Google Data means any data, content, or metadata obtained via Google
- APIs (including Google Workspace APIs).
- >,
- <>
- Generalized AI/ML model means an AI or ML model intended to be
- broadly trained across multiple users, not specific to a single user's data or
- behavior.
- >,
- <>
- User-facing features means features directly visible or used by the
- individual user through the app UI.
- >,
- <>
- Sale , for the purpose of the CCPA (California Consumer Privacy Act),
- means selling, renting, releasing, disclosing, disseminating, making available,
- transferring, or otherwise communicating orally, in writing, or by electronic or other
- means, a Consumer's Personal information to another business or a third party for
- monetary or other valuable consideration.
- >,
- <>
- Service Provider means any natural or legal person who processes the
- data on behalf of the Company. It refers to third-party companies or individuals
- employed by the Company to facilitate the Service, to provide the Service on behalf of
- the Company, to perform services related to the Service or to assist the Company in
- analyzing how the Service is used. For the purpose of the GDPR, Service Providers are
- considered Data Processors.
- >,
- <>
- Third-party Social Media Service refers to any website or any social
- network website through which a User can log in or create an account to use the
- Service.
- >,
- <>
- Usage Data refers to data collected automatically, either generated
- by the use of the Service or from the Service infrastructure itself (for example, the
- duration of a page visit).
- >,
- <>
- Website refers to Sim, accessible from sim.ai
- >,
- <>
- You means the individual accessing or using the Service, or the
- company, or other legal entity on behalf of which such individual is accessing or
- using the Service, as applicable. Under GDPR (General Data Protection Regulation), You
- can be referred to as the Data Subject or as the User as you are the individual using
- the Service.
- >,
+ richText(
+ '**Application** means the Sim web or mobile application or related services.'
+ ),
+ richText(
+ '**Account** means a unique account created for You to access our Service or parts of our Service.'
+ ),
+ richText(
+ '**Affiliate** means an entity that controls, is controlled by, or is under common control with a party, where "control" means ownership of 50% or more of the shares, equity interest, or other securities entitled to vote for election of directors or other managing authority.'
+ ),
+ richText(
+ '**Business**, for the purpose of the California Consumer Privacy Act ("CCPA"), refers to the Company as the legal entity that collects Consumers\' personal information and determines the purposes and means of processing that information, or on behalf of which that information is collected, and that does business in California.'
+ ),
+ richText(
+ '**Company** (referred to as "the Company", "We", "Us", or "Our") refers to Sim Studio, Inc. For the purpose of the GDPR, the Company is the Data Controller when it determines the purposes and means of processing Personal Data.'
+ ),
+ richText(
+ '**Cookies** are small files placed on Your computer, mobile device, or other device by a website, containing details of Your browsing history among their uses.'
+ ),
+ richText(
+ '**Country** refers to the United States, specifically California. Sim Studio, Inc. is a Delaware corporation with its principal place of business at 80 Langton Street, San Francisco, CA 94103, USA.'
+ ),
+ richText(
+ '**Data Controller**, for the purposes of the GDPR, refers to the person that alone or jointly with others determines the purposes and means of processing Personal Data.'
+ ),
+ richText(
+ '**Device** means any device that can access the Service, such as a computer, cellphone, or digital tablet.'
+ ),
+ richText(
+ '**Do Not Track (DNT)** is a concept promoted by U.S. regulatory authorities for mechanisms that allow internet users to control tracking of their online activities across websites.'
+ ),
+ richText(
+ "**Personal Data** or **Personal Information** means information relating to an identified or identifiable individual. Under the GDPR, this includes information such as a name, identification number, location data, online identifier, or factors specific to a person's physical, physiological, genetic, mental, economic, cultural, or social identity. Under the CCPA, it includes information that identifies, relates to, describes, is capable of being associated with, or could reasonably be linked, directly or indirectly, with You."
+ ),
+ richText(
+ '**Google Data** means any data, content, or metadata obtained via Google APIs, including Google Workspace APIs.'
+ ),
+ richText(
+ "**Generalized AI/ML Model** means an AI or machine-learning model intended to be broadly trained across multiple users and not specific to a single user's data or behavior."
+ ),
+ richText(
+ '**User-facing Features** means features directly visible or used by the individual user through the application interface.'
+ ),
+ richText(
+ "**Sale**, for the purpose of the CCPA, means selling, renting, releasing, disclosing, disseminating, making available, transferring, or otherwise communicating a Consumer's Personal Information to another business or third party for monetary or other valuable consideration."
+ ),
+ richText(
+ '**Service Provider** means a natural or legal person that processes data on behalf of the Company, including third parties engaged to facilitate, provide, support, or analyze the Service. For the purpose of the GDPR, Service Providers are Data Processors.'
+ ),
+ richText(
+ '**Third-party Social Media Service** means a website or social network through which a User can log in to or create an Account for the Service.'
+ ),
+ richText(
+ '**Usage Data** means data collected automatically, either generated through use of the Service or from the Service infrastructure itself.'
+ ),
+ richText('**Website** refers to Sim, accessible from sim.ai.'),
+ richText(
+ '**You** means the individual accessing or using the Service, or the company or other legal entity on whose behalf that individual accesses or uses the Service. Under the GDPR, You may be the Data Subject or User.'
+ ),
],
},
],
@@ -156,72 +138,164 @@ export const PRIVACY_CONFIG: LegalPageConfig = {
{ kind: 'subheading', text: 'Personal Data You Provide' },
{
kind: 'paragraph',
- content: `When you sign up, link accounts, or use features, you may provide Personal Data such as:`,
+ content: richText(
+ 'When you sign up, link accounts, or use features, you may provide Personal Data such as:'
+ ),
},
{
kind: 'list',
items: [
- `Name and email address`,
- `Phone number and mailing address`,
- `Profile picture, settings, and preferences`,
- `Content you upload (e.g., documents, files) within Sim`,
- `Any data you explicitly input or connect, including via Google integrations`,
+ richText('Name and email address'),
+ richText('Phone number and mailing address'),
+ richText('Profile picture, settings, and preferences'),
+ richText('Content you upload, including documents and files'),
+ richText('Data you explicitly input or connect, including through Google integrations'),
],
},
{ kind: 'subheading', text: 'Google Data via API Scopes' },
{
kind: 'paragraph',
- content: `If you choose to connect your Google account (e.g., Google Workspace, Gmail, Drive, Calendar, Contacts), we may request specific scopes. Types of Google Data we may access include:`,
+ content: richText(
+ 'If you choose to connect your Google account, including Google Workspace, Gmail, Drive, Calendar, or Contacts, we may request specific scopes. Google Data we may access includes:'
+ ),
},
{
kind: 'list',
items: [
- `Basic profile (name, email)`,
- `Drive files and documents`,
- `Calendar events`,
- `Contacts`,
- `Gmail messages (only if explicitly requested for a specific feature)`,
- `Other Google Workspace content or metadata as needed per feature`,
+ richText('Basic profile information, including name and email address'),
+ richText('Drive files'),
+ richText('Calendar events'),
+ richText('Contacts'),
+ richText('Gmail messages, only when explicitly requested for a specific feature'),
+ richText('Other Google Workspace content or metadata needed for an enabled feature'),
],
},
{
kind: 'paragraph',
- content: `We only request the minimal scopes necessary for the features you enable. We do not request scopes for unimplemented features.`,
+ content: richText(
+ 'We request only the minimum scopes necessary for the features you enable. We do not request scopes for unimplemented features.'
+ ),
},
{ kind: 'subheading', text: 'Usage Data' },
{
kind: 'paragraph',
- content: `We may also collect information on how the Service is accessed and used ("Usage Data"). This Usage Data may include information such as your computer's Internet Protocol address (e.g. IP address), browser type, browser version, the pages of our Service that you visit, the time and date of your visit, the time spent on those pages, unique device identifiers and other diagnostic data.`,
+ content: richText(
+ 'We may collect information about how the Service is accessed and used. Usage Data may include your Internet Protocol address, browser type, browser version, pages visited, date and time of a visit, time spent on pages, unique Device identifiers, and other diagnostic data.'
+ ),
},
{
kind: 'paragraph',
- content: `When You access the Service by or through a mobile device, We may collect certain information automatically, including, but not limited to, the type of mobile device You use, Your mobile device unique ID, the IP address of Your mobile device, Your mobile operating system, the type of mobile Internet browser You use, unique device identifiers and other diagnostic data.`,
+ content: richText(
+ 'When You access the Service through a mobile Device, we may collect the Device type, unique Device identifier, Device Internet Protocol address, mobile operating system, mobile browser type, and other diagnostic data. We may also collect information that Your browser sends when You visit or access the Service.'
+ ),
},
+ { kind: 'subheading', text: 'Tracking and Cookies Data' },
{
kind: 'paragraph',
- content: `We may also collect information that Your browser sends whenever You visit our Service or when You access the Service by or through a mobile device.`,
+ content: richText(
+ 'We use Cookies and similar tracking technologies, including beacons, tags, scripts, local storage, and pixels, to track activity and hold certain information. Where consent is required, non-essential Cookies are disabled until You make a choice through the cookie banner. Necessary Cookies remain enabled because the Service cannot operate without them. You may accept, reject, or customize non-essential Cookie categories and may later change or withdraw that choice.'
+ ),
},
- { kind: 'subheading', text: 'Tracking & Cookies Data' },
{
kind: 'paragraph',
- content: `We use cookies and similar tracking technologies to track the activity on our Service and hold certain information.`,
+ content: richText(
+ 'The [Cookie Policy](https://sim.ai/cookie-policy) lists the Cookies set by Sim and its providers, their purposes, lifetimes, providers, and the methods for changing or withdrawing a choice.'
+ ),
},
+ ],
+ },
+ {
+ id: 'legal-bases',
+ heading: '1A. Legal Bases for Processing',
+ blocks: [
{
kind: 'paragraph',
- content: `Cookies are files with small amount of data which may include an anonymous unique identifier. Cookies are sent to your browser from a website and stored on your device. Tracking technologies also used are beacons, tags, and scripts to collect and track information and to improve and analyze our Service.`,
+ content: richText(
+ 'Where the GDPR applies, Sim relies on the legal bases in Article 6(1) as follows:'
+ ),
+ },
+ {
+ kind: 'table',
+ columns: ['Processing activity', 'Personal data', 'Legal basis', 'Note'],
+ rows: [
+ [
+ 'Creating and operating an Account',
+ 'Name, email address, profile information, credentials, settings, and Account identifiers',
+ 'Contractual necessity — Article 6(1)(b)',
+ 'Required to create, authenticate, maintain, and administer the Account requested by You.',
+ ],
+ [
+ 'Delivering the Service and user-enabled integrations',
+ 'Customer content, workflow inputs and outputs, integration data, Google Data, support information, Device information, and Usage Data needed for delivery',
+ 'Contractual necessity — Article 6(1)(b)',
+ 'Required to provide the Service, integrations, and user-facing features You choose to enable.',
+ ],
+ [
+ 'Billing and payment administration',
+ 'Billing contact information, transaction details, subscription information, and payment status',
+ 'Contractual necessity — Article 6(1)(b)',
+ 'Required to administer paid products and services. Payment card details are provided directly to the payment processor and are not stored by Sim.',
+ ],
+ [
+ 'Product analytics and service improvement',
+ 'Usage Data, Device data, feature interactions, diagnostics, and aggregated or anonymized non-Google data',
+ 'Legitimate interests — Article 6(1)(f)',
+ "Sim's interest is to understand use of the Service, improve reliability and features, and measure performance. Non-essential analytics Cookies are processed on consent where consent is required.",
+ ],
+ [
+ 'Abuse, fraud, and security monitoring',
+ 'Account identifiers, Internet Protocol addresses, Usage Data, logs, Device information, and security-event data',
+ 'Legitimate interests — Article 6(1)(f)',
+ "Sim's interest is to protect users, the Service, systems, and data; prevent misuse; investigate incidents; and maintain service integrity.",
+ ],
+ [
+ 'Non-essential Cookies and similar technologies',
+ 'Online identifiers, Cookie identifiers, Device and browser information, and interaction data',
+ 'Consent — Article 6(1)(a)',
+ 'Consent may be changed or withdrawn at any time through the cookie preferences link. Necessary Cookies do not depend on consent where they are required to provide the requested Service.',
+ ],
+ [
+ 'Marketing communications',
+ 'Name, email address, communication preferences, and engagement data',
+ 'Consent — Article 6(1)(a)',
+ 'Consent may be withdrawn at any time through the unsubscribe method in the communication.',
+ ],
+ [
+ 'Behavioral remarketing',
+ 'Cookie and pixel identifiers, browser and Device data, campaign attribution, and website interaction data',
+ 'Consent — Article 6(1)(a)',
+ 'Marketing technologies are disabled until the Marketing category is accepted. Consent may be changed or withdrawn through the cookie preferences link.',
+ ],
+ [
+ 'Retaining transaction and tax records',
+ 'Billing records, transaction information, and related business records',
+ 'Legal obligation — Article 6(1)(c)',
+ 'Records are retained as required by applicable accounting, tax, and corporate laws.',
+ ],
+ [
+ 'Responding to lawful requests',
+ 'Personal Data within the scope of a binding legal request',
+ 'Legal obligation — Article 6(1)(c)',
+ 'Processing is limited to what applicable law or a valid legal process requires.',
+ ],
+ ],
},
{
kind: 'paragraph',
- content: `You can instruct your browser to refuse all cookies or to indicate when a cookie is being sent. However, if you do not accept cookies, you may not be able to use some portions of our Service.`,
+ content: richText(
+ 'For processing based on legitimate interests, Sim performs a balancing assessment that considers the interest pursued, the necessity of the processing, and the rights and reasonable expectations of the affected individuals. You may object to that processing under Article 21 of the GDPR as described in section 14.'
+ ),
},
{
kind: 'paragraph',
- content: (
- <>
- Our Cookie Policy lists every cookie we
- and our providers set, what each one does, how long it lasts, and how to change or
- withdraw your choice.
- >
+ content: richText(
+ 'Sim does not rely on public interest under Article 6(1)(e) or vital interests under Article 6(1)(d) for any current processing. Sim does not process special-category data under Article 9 as part of providing the Service.'
+ ),
+ },
+ {
+ kind: 'paragraph',
+ content: richText(
+ "Where Sim processes customer content on behalf of a customer, Sim acts as a processor, the customer is the controller, and the customer determines the applicable legal basis. That processing is governed by the Data Processing Addendum and the customer's documented instructions."
),
},
],
@@ -230,32 +304,38 @@ export const PRIVACY_CONFIG: LegalPageConfig = {
id: 'how-we-use-information',
heading: '2. How We Use Your Information',
blocks: [
- { kind: 'paragraph', content: `We use the collected data for various purposes:` },
+ {
+ kind: 'paragraph',
+ content: richText('We use collected data for the following purposes:'),
+ },
{
kind: 'list',
items: [
- `To provide and maintain our Service`,
- `To notify you about changes to our Service`,
- `To allow you to participate in interactive features of our Service when you choose to do so`,
- `To provide customer care and support`,
- `To provide analysis or valuable information so that we can improve the Service`,
- `To monitor the usage of the Service`,
- `To detect, prevent and address technical issues`,
- `To manage Your Account`,
- `For the performance of a contract`,
- `To contact You by email, telephone calls, SMS, or other equivalent forms of electronic communication`,
- `To enable and support user-enabled integrations with Google services (e.g., syncing files or calendar) and provide personalization, suggestions, and user-specific automation for that individual user.`,
- `To detect and prevent fraud, abuse, or security incidents and to comply with legal obligations.`,
+ richText('To provide and maintain the Service'),
+ richText('To notify You about changes to the Service'),
+ richText(
+ 'To allow You to participate in interactive features when You choose to do so'
+ ),
+ richText('To provide customer care and support'),
+ richText('To provide analysis or information that helps us improve the Service'),
+ richText('To monitor use of the Service'),
+ richText('To detect, prevent, and address technical issues'),
+ richText('To manage Your Account'),
+ richText('To perform our contract with You'),
+ richText(
+ 'To contact You by email, telephone, SMS, or equivalent electronic communications'
+ ),
+ richText(
+ 'To enable and support user-enabled integrations with Google services, including file or calendar synchronization, personalization, suggestions, and user-specific automation'
+ ),
+ richText('To detect and prevent fraud, abuse, and security incidents'),
+ richText('To comply with legal obligations'),
],
},
{
kind: 'paragraph',
- content: (
- <>
- Importantly: any Google Data used within Sim is used only for
- features tied to that specific user (user-facing features), and never {' '}
- for generalized AI/ML training or shared model improvement across users.
- >
+ content: richText(
+ 'Any Google Data used within Sim is used only for features tied to that specific user and is not used for generalized AI/ML training or shared model improvement across users.'
),
},
],
@@ -266,15 +346,42 @@ export const PRIVACY_CONFIG: LegalPageConfig = {
blocks: [
{
kind: 'paragraph',
- content: `Your information, including Personal Information, may be transferred to, and maintained on, computers located outside of your state, province, country or other governmental jurisdiction where the data protection laws may differ than those from your jurisdiction.`,
+ content: richText(
+ "Your information, including Personal Data, may be transferred to and maintained on computers outside Your state, province, country, or other governmental jurisdiction, where data protection laws may differ. If You are outside the United States and provide information to us, we transfer the data to the United States and process it there. Sim's primary hosting region is AWS us-east-1 in the United States. The current Service Provider list is maintained on the Sub-processors page."
+ ),
+ },
+ {
+ kind: 'paragraph',
+ content: richText(
+ 'Providing Personal Data does not by itself constitute consent to an international transfer. Where Sim relies on consent, that consent will be freely given, specific, informed, unambiguous, obtained separately through a positive action, and recorded.'
+ ),
},
{
kind: 'paragraph',
- content: `If you are located outside United States and choose to provide information to us, please note that we transfer the data, including Personal Information, to United States and process it there.`,
+ content: richText(
+ 'International transfers from the European Economic Area or United Kingdom are made using applicable transfer safeguards, including:'
+ ),
+ },
+ {
+ kind: 'list',
+ items: [
+ richText(
+ "The European Commission's Standard Contractual Clauses adopted by Implementing Decision (EU) 2021/914 and, for transfers from the United Kingdom, the UK International Data Transfer Addendum"
+ ),
+ richText(
+ 'Data Processing Addenda with each sub-processor that incorporate the applicable transfer clauses'
+ ),
+ richText('Transfer impact assessments where required'),
+ richText(
+ 'Technical and organizational measures described in section 5, including encryption in transit and at rest, access controls, and logging'
+ ),
+ ],
},
{
kind: 'paragraph',
- content: `Your consent to this Privacy Policy followed by your submission of such information represents your agreement to that transfer.`,
+ content: richText(
+ 'A copy of the applicable transfer clauses is available on request at privacy@sim.ai.'
+ ),
},
],
},
@@ -285,26 +392,32 @@ export const PRIVACY_CONFIG: LegalPageConfig = {
{ kind: 'subheading', text: 'Business Transactions' },
{
kind: 'paragraph',
- content: `If the Company is involved in a merger, acquisition or asset sale, Your Personal Data may be transferred. We will provide notice before Your Personal Data is transferred and becomes subject to a different Privacy Policy.`,
+ content: richText(
+ 'If the Company is involved in a merger, acquisition, or asset sale, Your Personal Data may be transferred. We will provide notice before Your Personal Data is transferred and becomes subject to a different Privacy Policy.'
+ ),
},
{ kind: 'subheading', text: 'Law Enforcement' },
{
kind: 'paragraph',
- content: `Under certain circumstances, the Company may be required to disclose Your Personal Data if required to do so by law or in response to valid requests by public authorities (e.g. a court or a government agency).`,
+ content: richText(
+ 'The Company may be required to disclose Your Personal Data if required by law or in response to valid requests by public authorities, including a court or government agency.'
+ ),
},
{ kind: 'subheading', text: 'Legal Requirements' },
{
kind: 'paragraph',
- content: `Sim may disclose your Personal Information in the good faith belief that such action is necessary to:`,
+ content: richText(
+ 'Sim may disclose Your Personal Data in the good-faith belief that the action is necessary to:'
+ ),
},
{
kind: 'list',
items: [
- `To comply with a legal obligation`,
- `To protect and defend the rights or property of Sim`,
- `To prevent or investigate possible wrongdoing in connection with the Service`,
- `To protect the personal safety of users of the Service or the public`,
- `To protect against legal liability`,
+ richText('Comply with a legal obligation'),
+ richText('Protect and defend the rights or property of Sim'),
+ richText('Prevent or investigate possible wrongdoing connected with the Service'),
+ richText('Protect the personal safety of users of the Service or the public'),
+ richText('Protect against legal liability'),
],
},
],
@@ -315,7 +428,9 @@ export const PRIVACY_CONFIG: LegalPageConfig = {
blocks: [
{
kind: 'paragraph',
- content: `The security of your data is important to us, but remember that no method of transmission over the Internet, or method of electronic storage is 100% secure. While we strive to use commercially acceptable means to protect your Personal Information, we cannot guarantee its absolute security.`,
+ content: richText(
+ 'The security of Your data is important to us, but no method of transmission over the Internet or method of electronic storage is completely secure. We use technical and organizational measures designed to protect Personal Data, including encryption in transit and at rest, access controls, role-based permissions, logging, and auditing. While we use commercially acceptable measures to protect Personal Data, we cannot guarantee absolute security.'
+ ),
},
],
},
@@ -325,11 +440,35 @@ export const PRIVACY_CONFIG: LegalPageConfig = {
blocks: [
{
kind: 'paragraph',
- content: `We may employ third party companies and individuals to facilitate our Service ("Service Providers"), to provide the Service on our behalf, to perform Service-related services or to assist us in analyzing how our Service is used.`,
+ content: richText(
+ 'We engage third-party companies and individuals to facilitate the Service, provide the Service on our behalf, perform Service-related services, or assist us in analyzing how the Service is used. These Service Providers may access Personal Data only to perform assigned tasks on our behalf and may not disclose or use it for another purpose.'
+ ),
+ },
+ {
+ kind: 'paragraph',
+ content: richText(
+ 'The legal basis for disclosing Personal Data to Service Providers depends on the service involved:'
+ ),
+ },
+ {
+ kind: 'list',
+ items: [
+ richText(
+ '**Contractual necessity — Article 6(1)(b):** providers required to deliver the Service, integrations, billing, or other features requested by You'
+ ),
+ richText(
+ "**Legitimate interests — Article 6(1)(f):** providers used for security, hosting, monitoring, and support tooling, where Sim's interests are to operate, protect, maintain, and support the Service"
+ ),
+ richText(
+ '**Consent — Article 6(1)(a):** analytics and advertising providers activated through non-essential Cookies or similar technologies'
+ ),
+ ],
},
{
kind: 'paragraph',
- content: `These third parties have access to your Personal Information only to perform these tasks on our behalf and are obligated not to disclose or use it for any other purpose.`,
+ content: richText(
+ "Every Service Provider that processes Personal Data on Sim's behalf is engaged under a written data processing agreement that imposes confidentiality, purpose limitation, security, and sub-processor controls. Each provider is security-reviewed before onboarding and periodically thereafter and acts only on Sim's documented instructions. The current provider list is maintained on the Sub-processors page."
+ ),
},
],
},
@@ -339,7 +478,9 @@ export const PRIVACY_CONFIG: LegalPageConfig = {
blocks: [
{
kind: 'paragraph',
- content: `We may aggregate or anonymize non-Google data (not tied to personal identity) for internal analytics, product improvement, usage trends, or performance monitoring. This data cannot be tied back to individual users and is not used for generalized AI/ML training with Google Data.`,
+ content: richText(
+ 'We may aggregate or anonymize non-Google data that is not tied to personal identity for internal analytics, product improvement, usage trends, or performance monitoring. This data cannot be tied back to individual users and is not used for generalized AI/ML training with Google Data.'
+ ),
},
],
},
@@ -349,22 +490,29 @@ export const PRIVACY_CONFIG: LegalPageConfig = {
blocks: [
{
kind: 'paragraph',
- content: `The Company uses remarketing services to advertise on third party websites to You after You visited our Service. We and Our third-party vendors use cookies to inform, optimize and serve ads based on Your past visits to our Service.`,
+ content: richText(
+ 'The Company uses Google Ads, Twitter, and Facebook remarketing services to advertise on third-party websites after You visit the Service. These services operate through non-essential Cookies and similar technologies. They are activated only after You give consent to the Marketing category in the cookie banner. No marketing Cookie is set before that consent.'
+ ),
},
- { kind: 'subheading', text: 'Google Ads (AdWords)' },
{
kind: 'paragraph',
- content: `Google Ads remarketing service is provided by Google Inc. You can opt-out of Google Analytics for Display Advertising and customize the Google Display Network ads by visiting the Google Ads Settings page.`,
+ content: richText(
+ 'You may change or withdraw consent at any time through the cookie preferences link. If Your browser or extension sends a Global Privacy Control signal, we treat it as a withdrawal of consent for analytics and marketing Cookies. The [Cookie Policy](https://sim.ai/cookie-policy) explains the technologies, providers, purposes, lifetimes, and available controls.'
+ ),
},
- { kind: 'subheading', text: 'Twitter' },
{
kind: 'paragraph',
- content: `Twitter remarketing service is provided by Twitter Inc. You can opt-out from Twitter's interest-based ads by following their instructions.`,
+ content: richText('Provider-level controls remain available as an additional route:'),
},
- { kind: 'subheading', text: 'Facebook' },
{
- kind: 'paragraph',
- content: `Facebook remarketing service is provided by Facebook Inc. You can learn more about interest-based advertising from Facebook by visiting their Privacy Policy.`,
+ kind: 'list',
+ items: [
+ richText('Google Ads: [Google Ads Settings](https://adssettings.google.com/)'),
+ richText(
+ 'Twitter: [Personalization and data settings](https://twitter.com/settings/account/personalization)'
+ ),
+ richText('Facebook: [Ad preferences](https://www.facebook.com/adpreferences/)'),
+ ],
},
],
},
@@ -374,151 +522,204 @@ export const PRIVACY_CONFIG: LegalPageConfig = {
blocks: [
{
kind: 'paragraph',
- content: `We may provide paid products and/or services within the Service. In that case, we may use third-party services for payment processing (e.g. payment processors).`,
- },
- {
- kind: 'paragraph',
- content: `We will not store or collect Your payment card details. That information is provided directly to Our third-party payment processors whose use of Your personal information is governed by their Privacy Policy. These payment processors adhere to the standards set by PCI-DSS as managed by the PCI Security Standards Council, which is a joint effort of brands like Visa, Mastercard, American Express and Discover. PCI-DSS requirements help ensure the secure handling of payment information.`,
+ content: richText(
+ 'We may provide paid products or services within the Service and may use third-party payment processors. We do not store or collect Your payment card details. Those details are provided directly to the payment processor, whose use of Personal Data is governed by its privacy policy. Payment processors adhere to the PCI Data Security Standard managed by the PCI Security Standards Council. The payment processor we use is Stripe.'
+ ),
},
- { kind: 'subheading', text: 'Payment processors we work with:' },
- { kind: 'list', items: [`Stripe`] },
],
},
{
id: 'google-workspace-apis',
heading: '10. Use of Google / Workspace APIs & Data: Limited Use',
blocks: [
- { kind: 'subheading', text: 'Affirmative Statement & Compliance' },
+ { kind: 'subheading', text: 'Affirmative Statement and Compliance' },
{
kind: 'paragraph',
- content: `Sim's use, storage, processing, and transfer of Google Data (raw or derived) strictly adheres to the Google API Services User Data Policy, including the Limited Use requirements, and to the Google Workspace API user data policy (when applicable). We explicitly affirm that:`,
+ content: richText(
+ "Sim's use, storage, processing, and transfer of Google Data, whether raw or derived, strictly adheres to the Google API Services User Data Policy, including the Limited Use requirements, and to the Google Workspace API user data policy where applicable. We affirm that:"
+ ),
},
{
kind: 'list',
items: [
- `Sim does not use, transfer, or allow Google Data to be used to train, improve, or develop generalized or non-personalized AI/ML models.`,
- `Any processing of Google Data is limited to providing or improving user-facing features visible in the app UI.`,
- `We do not allow third parties to access Google Data for purposes of training or model improvement.`,
- `Transfers of Google Data are disallowed except in limited permitted cases.`,
+ richText(
+ 'Sim does not use, transfer, or allow Google Data to be used to train, improve, or develop generalized or non-personalized AI/ML models.'
+ ),
+ richText(
+ 'Processing of Google Data is limited to providing or improving user-facing features visible in the application interface.'
+ ),
+ richText(
+ 'We do not allow third parties to access Google Data for training or model improvement.'
+ ),
+ richText(
+ 'Transfers of Google Data are disallowed except in the limited permitted cases described below.'
+ ),
],
},
- { kind: 'subheading', text: 'Permitted Transfers & Data Use' },
+ { kind: 'subheading', text: 'Permitted Transfers and Data Use' },
{
kind: 'paragraph',
- content: `We may only transfer Google Data (raw or derived) to third parties under the following limited conditions and always aligned with user disclosures and consent:`,
+ content: richText(
+ 'We may transfer Google Data, whether raw or derived, to third parties only under the following limited conditions and in line with user disclosures and consent:'
+ ),
},
{
kind: 'list',
items: [
- `To provide or improve user-facing features (with the user's explicit consent)`,
- `For security, abuse investigation, or system integrity`,
- `To comply with laws or legal obligations`,
- `As part of a merger, acquisition, divestiture, or sale of assets, with explicit user consent`,
+ richText(
+ "To provide or improve user-facing features, with the user's explicit consent"
+ ),
+ richText('For security, abuse investigation, or system integrity'),
+ richText('To comply with laws or legal obligations'),
+ richText(
+ 'As part of a merger, acquisition, divestiture, or sale of assets, with explicit user consent'
+ ),
],
},
{ kind: 'subheading', text: 'Human Access Restrictions' },
{
kind: 'paragraph',
- content: `We restrict human review of Google Data strictly. No employee, contractor, or agent may view Google Data unless one of the following is true:`,
+ content: richText(
+ 'No employee, contractor, or agent may view Google Data unless one of the following applies:'
+ ),
},
{
kind: 'list',
items: [
- `The user gave explicit, documented consent to view specific items (e.g., "Let customer support view this email/file").`,
- `It is necessary for security, abuse investigation, or legal process.`,
- `Data is aggregated, anonymized, and used for internal operations only (without re-identification).`,
+ richText(
+ 'The user gave explicit, documented consent to view specific items, such as allowing customer support to view a particular email or file.'
+ ),
+ richText('Access is necessary for security, abuse investigation, or legal process.'),
+ richText(
+ 'The data is aggregated and anonymized and used for internal operations only, without re-identification.'
+ ),
],
},
- { kind: 'subheading', text: 'Scope Minimization & Justification' },
+ { kind: 'subheading', text: 'Scope Minimization and Justification' },
{
kind: 'paragraph',
- content: `We only request scopes essential to features you opt into; we do not request broad or unused permissions. For each Google API scope we request, we maintain internal documentation justifying why that scope is needed and why narrower scopes are insufficient. Where possible, we follow incremental authorization and request additional scopes only when needed in context.`,
+ content: richText(
+ 'We request only scopes essential to features You choose to enable. We do not request broad or unused permissions. For each Google API scope requested, we maintain internal documentation explaining why the scope is needed and why a narrower scope is insufficient. Where possible, we use incremental authorization and request additional scopes only when needed in context.'
+ ),
},
- { kind: 'subheading', text: 'Secure Handling & Storage' },
+ { kind: 'subheading', text: 'Secure Handling and Storage' },
{
kind: 'list',
items: [
- `Google Data is encrypted in transit (TLS/HTTPS) and at rest.`,
- `Access controls, role-based permissions, logging, and auditing protect data.`,
- `OAuth tokens and credentials are stored securely (e.g., encrypted vault, hardware or secure key management).`,
- `We regularly review security practices and infrastructure.`,
- `If a security incident affects Google Data, we will notify Google as required and cooperate fully.`,
+ richText('Google Data is encrypted in transit using TLS/HTTPS and at rest.'),
+ richText(
+ 'Access controls, role-based permissions, logging, and auditing protect Google Data.'
+ ),
+ richText(
+ 'OAuth tokens and credentials are stored securely using encrypted vault or secure key-management controls.'
+ ),
+ richText('We regularly review security practices and infrastructure.'),
+ richText(
+ 'If a security incident affects Google Data, we notify Google as required and cooperate fully.'
+ ),
],
},
- { kind: 'subheading', text: 'Retention & Deletion' },
+ { kind: 'subheading', text: 'Retention and Deletion' },
{
kind: 'paragraph',
- content: `We retain data only as long as necessary for the purposes disclosed:`,
+ content: richText('We retain data only as long as necessary for the disclosed purposes:'),
},
{
- kind: 'list',
- items: [
- <>
- Account Data: Retained during active account + 30 days after deletion
- request
- >,
- <>
- Google API Data: Retained during feature use + 7 days after
- revocation or account deletion
- >,
- <>
- Usage Logs: 90 days for analytics; up to 1 year for security
- investigations
- >,
- <>
- Transaction Records: Up to 7 years for legal and tax compliance
- >,
+ kind: 'table',
+ columns: ['Data category', 'Retention period'],
+ rows: [
+ ['Account Data', 'During the active Account and for 30 days after a deletion request'],
+ [
+ 'Google API Data',
+ 'During use of the enabled feature and for 7 days after revocation or Account deletion',
+ ],
+ ['Usage Logs', '90 days for analytics; up to 1 year for security investigations'],
+ ['Transaction Records', 'Up to 7 years for legal and tax compliance'],
],
},
{
kind: 'paragraph',
- content: `When you revoke access, delete your account, or stop using a feature, we remove associated data within the timeframes above. You may request deletion via in-app settings or by contacting us; we will comply promptly.`,
+ content: richText(
+ 'When You revoke access, delete Your Account, or stop using a feature, we remove associated data within the timeframes above. You may request deletion through in-app settings or by contacting us.'
+ ),
},
],
},
{
- id: 'links-to-other-sites',
- heading: '11. Links To Other Sites',
+ id: 'artificial-intelligence',
+ heading: '10A. Use of Artificial Intelligence',
blocks: [
{
kind: 'paragraph',
- content: `Our Service may contain links to other sites that are not operated by us. If you click on a third party link, you will be directed to that third party's site. We strongly advise you to review the Privacy Policy of every site you visit.`,
+ content: richText(
+ 'Sim is a platform for building and running AI agents. AI models process the prompts, files, records, integration data, and other content that users choose to send through their workflows.'
+ ),
},
{
kind: 'paragraph',
- content: `We have no control over and assume no responsibility for the content, privacy policies or practices of any third party sites or services.`,
+ content: richText(
+ 'Model providers are engaged as sub-processors under Data Processing Addenda and are identified on the Sub-processors page. Customer content sent to a model provider is not used by Sim or by that provider to train or improve generalized or shared models. This commitment applies to customer content generally and restates the Google Data Limited Use commitment in section 10 for Google Data.'
+ ),
},
- ],
- },
- {
- id: 'childrens-privacy',
- heading: "12. Children's Privacy",
- blocks: [
{
kind: 'paragraph',
- content: `Our Service does not address anyone under the age of 18 ("Children").`,
+ content: richText(
+ "Users choose which models and providers their workflows call. Users may also bring their own provider credentials. When a user supplies provider credentials, the selected provider's processing remains subject to the user's arrangement with that provider as well as the workflow configuration chosen by the user."
+ ),
},
{
kind: 'paragraph',
- content: `We do not knowingly collect personally identifiable information from anyone under the age of 18. If you are a parent or guardian and you are aware that your Children has provided us with Personal Information, please contact us. If we become aware that we have collected Personal Information from children without verification of parental consent, we take steps to remove that information from our servers.`,
+ content: richText(
+ 'AI outputs are probabilistic and may be incomplete, inaccurate, or unsuitable for a particular purpose. Users should review outputs and should not rely on them as the sole basis for a decision that produces legal or similarly significant effects on an individual.'
+ ),
+ },
+ {
+ kind: 'paragraph',
+ content: richText(
+ 'Sim does not carry out automated decision-making that produces legal or similarly significant effects on individuals within the meaning of Article 22 of the GDPR. If that changes, we will update this Privacy Policy and provide the safeguards required by Article 22, including information about the logic involved and the right to obtain human intervention, express a point of view, and contest the decision where applicable.'
+ ),
+ },
+ {
+ kind: 'paragraph',
+ content: richText(
+ 'Prompts and outputs are retained according to the data category and context in which they are processed. Account and workflow content follows the Account Data period in the retention table in section 10; Google Data, Usage Logs, and Transaction Records follow their respective periods in that table. Questions about AI processing may be sent to privacy@sim.ai.'
+ ),
},
],
},
{
- id: 'changes-to-policy',
- heading: '13. Changes To This Privacy Policy',
+ id: 'links-to-other-sites',
+ heading: '11. Links To Other Sites',
blocks: [
{
kind: 'paragraph',
- content: `We may update our Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page.`,
+ content: richText(
+ "Our Service may contain links to sites not operated by us. If You follow a third-party link, You will be directed to that third party's site. We recommend reviewing the privacy policy of each site You visit. We do not control and are not responsible for the content, privacy policies, or practices of third-party sites or services."
+ ),
},
+ ],
+ },
+ {
+ id: 'childrens-privacy',
+ heading: "12. Children's Privacy",
+ blocks: [
{
kind: 'paragraph',
- content: `We will let you know via email and/or a prominent notice on our Service, prior to the change becoming effective and update the "Last updated" date at the top of this Privacy Policy.`,
+ content: richText(
+ "The Service is not directed at anyone under the age of 18, and Sim does not knowingly collect or process children's Personal Data. If children's Personal Data has been collected inadvertently without appropriate parental consent, Sim will take the necessary steps to erase it from its records. Anyone who believes that Sim has collected children's Personal Data should contact privacy@sim.ai so the matter can be addressed promptly."
+ ),
},
+ ],
+ },
+ {
+ id: 'changes-to-policy',
+ heading: '13. Changes To This Privacy Policy',
+ blocks: [
{
kind: 'paragraph',
- content: `You are advised to review this Privacy Policy periodically for any changes. Changes to this Privacy Policy are effective when they are posted on this page.`,
+ content: richText(
+ 'We may update this Privacy Policy from time to time. We will notify You by posting the revised Privacy Policy on this page and updating the "Last updated" date. For a material change, we will provide notice by email or a prominent notice on the Service before the change becomes effective. Changes take effect when posted unless the notice states otherwise.'
+ ),
},
],
},
@@ -528,34 +729,71 @@ export const PRIVACY_CONFIG: LegalPageConfig = {
blocks: [
{
kind: 'paragraph',
- content: `If you are a resident of the European Economic Area (EEA), you have certain data protection rights. Sim aims to take reasonable steps to allow you to correct, amend, delete, or limit the use of your Personal Information.`,
+ content: richText(
+ 'If You are in the European Economic Area, You have the following data protection rights:'
+ ),
+ },
+ {
+ kind: 'list',
+ items: [
+ richText(
+ '**Access:** the right to obtain confirmation of whether we process Your Personal Data and to receive a copy of that data.'
+ ),
+ richText(
+ '**Rectification:** the right to correct inaccurate Personal Data and complete incomplete Personal Data.'
+ ),
+ richText(
+ '**Erasure:** the right to request deletion of Your Personal Data where the applicable conditions are met.'
+ ),
+ richText(
+ '**Objection:** the right to object to processing based on legitimate interests and to object at any time to processing for direct marketing.'
+ ),
+ richText(
+ '**Restriction:** the right to request restriction of processing where the applicable conditions are met.'
+ ),
+ richText(
+ '**Data portability:** the right to receive Personal Data You provided in a structured, commonly used, machine-readable format and to transmit it to another controller where applicable.'
+ ),
+ richText(
+ '**Withdrawal of consent:** the right to withdraw consent at any time where Sim relies on consent. Withdrawal does not affect the lawfulness of processing before withdrawal.'
+ ),
+ ],
},
{
kind: 'paragraph',
- content: `If you wish to be informed what Personal Information we hold about you and if you want it to be removed from our systems, please contact us.`,
+ content: richText(
+ 'You may submit a request at privacy@sim.ai, through in-app Account settings for access and deletion, or by using the postal address in section 17.'
+ ),
},
{
kind: 'paragraph',
- content: `In certain circumstances, you have the following data protection rights:`,
+ content: richText(
+ 'We respond without undue delay and within one month after receiving a request. That period may be extended by up to two further months where necessary because of the complexity or number of requests. If an extension is required, we will tell You within the first month and explain the reason.'
+ ),
},
{
- kind: 'list',
- items: [
- `The right to access, update or to delete the information we have on you.`,
- `The right of rectification. You have the right to have your information rectified if that information is inaccurate or incomplete.`,
- `The right to object. You have the right to object to our processing of your Personal Information.`,
- `The right of restriction. You have the right to request that we restrict the processing of your personal information.`,
- `The right to data portability. You have the right to be provided with a copy of the information we have on you in a structured, machine-readable and commonly used format.`,
- `The right to withdraw consent. You also have the right to withdraw your consent at any time where Sim relied on your consent to process your personal information.`,
- ],
+ kind: 'paragraph',
+ content: richText(
+ 'Requests are handled free of charge. Where a request is manifestly unfounded, excessive, or repetitive, we may charge a reasonable fee based on the administrative cost or refuse to act. If we refuse or charge a fee, we will give reasons and explain the available complaint and judicial-remedy rights.'
+ ),
},
{
kind: 'paragraph',
- content: `Please note that we may ask you to verify your identity before responding to such requests.`,
+ content: richText(
+ 'We may request information needed to verify Your identity before acting on a request. Information collected for verification will be used only for that purpose.'
+ ),
},
{
- kind: 'callout',
- content: `You have the right to complain to a Data Protection Authority about our collection and use of your Personal Information. For more information, please contact your local data protection authority in the European Economic Area (EEA).`,
+ kind: 'paragraph',
+ content: richText(
+ 'You have the right to lodge a complaint with the supervisory authority in the Member State of Your residence, place of work, or place of the alleged infringement, without prejudice to any other administrative or judicial remedy.'
+ ),
+ },
+ {
+ kind: 'paragraph',
+ content: richText(
+ 'The same rights are extended to data subjects in the United Kingdom under the UK GDPR.'
+ ),
},
],
},
@@ -565,34 +803,29 @@ export const PRIVACY_CONFIG: LegalPageConfig = {
blocks: [
{
kind: 'paragraph',
- content: `If you are a California resident, you have specific rights under the California Consumer Privacy Act (CCPA) and California Privacy Rights Act (CPRA), including the right to know what personal information we collect, the right to delete your information, and the right to opt-out of the sale or sharing of your personal information.`,
+ content: richText(
+ 'If You are a California resident, You have rights under the CCPA and California Privacy Rights Act, including the right to know what Personal Information we collect, the right to delete Personal Information, and the right to opt out of the sale or sharing of Personal Information.'
+ ),
},
{ kind: 'subheading', text: 'Do Not Sell or Share My Personal Information' },
{
kind: 'paragraph',
- content: (
- <>
- We do not sell your personal information for monetary consideration. However, some
- data sharing practices (such as analytics or advertising services) may be considered a
- "sale" or "share" under CCPA/CPRA. You have the right to opt-out of such data sharing.
- To exercise this right, contact us at{' '}
- privacy@sim.ai .
- >
+ content: richText(
+ 'We do not sell Personal Information for monetary consideration. Some analytics or advertising disclosures may be considered a "sale" or "share" under California law. You may opt out by contacting privacy@sim.ai or by using the cookie preferences link.'
),
},
- { kind: 'subheading', text: 'Global Privacy Control (GPC)' },
+ { kind: 'subheading', text: 'Global Privacy Control' },
{
kind: 'paragraph',
- content: `We recognize and honor Global Privacy Control (GPC) signals. When your browser sends a GPC signal, we will treat it as a valid request to opt-out of the sale or sharing of your personal information.`,
- },
- { kind: 'subheading', text: 'Shine The Light Law' },
- {
- kind: 'paragraph',
- content: `California Civil Code Section 1798.83 permits California residents to request information about categories of personal information we disclosed to third parties for direct marketing purposes in the preceding calendar year.`,
+ content: richText(
+ 'We recognize and honor Global Privacy Control signals. When Your browser sends a Global Privacy Control signal, we treat it as a valid request to opt out of the sale or sharing of Personal Information.'
+ ),
},
{
kind: 'paragraph',
- content: `To make a request under CCPA or the Shine The Light law, please submit your request using the contact information provided below.`,
+ content: richText(
+ 'California Civil Code section 1798.83 permits California residents to request information about categories of Personal Information disclosed to third parties for direct-marketing purposes during the preceding calendar year. Requests under the CCPA, California Privacy Rights Act, or Shine the Light law may be submitted using the contact information in section 17.'
+ ),
},
],
},
@@ -600,92 +833,93 @@ export const PRIVACY_CONFIG: LegalPageConfig = {
id: 'vulnerability-disclosure',
heading: '16. Vulnerability Disclosure Policy',
blocks: [
- { kind: 'subheading', text: 'Introduction' },
{
kind: 'paragraph',
- content: `Sim is dedicated to preserving data security by preventing unauthorized disclosure of information. This policy was created to provide security researchers with instructions for conducting vulnerability discovery activities and to provide information on how to report vulnerabilities that have been discovered. This policy explains which systems and sorts of activity are covered, how to send vulnerability reports, and how long we require you to wait before publicly reporting vulnerabilities identified.`,
+ content: richText(
+ 'Sim is dedicated to preserving data security by preventing unauthorized disclosure of information. This section provides security researchers with instructions for conducting vulnerability discovery and reporting identified vulnerabilities.'
+ ),
},
{ kind: 'subheading', text: 'Guidelines' },
- { kind: 'paragraph', content: `We request that you:` },
{
kind: 'list',
items: [
- `Notify us as soon as possible after you discover a real or potential security issue.`,
- `Provide us a reasonable amount of time to resolve the issue before you disclose it publicly.`,
- `Make every effort to avoid privacy violations, degradation of user experience, disruption to production systems, and destruction or manipulation of data.`,
- `Only use exploits to the extent necessary to confirm a vulnerability's presence. Do not use an exploit to compromise or obtain data, establish command line access and/or persistence, or use the exploit to "pivot" to other systems.`,
- `Once you've established that a vulnerability exists or encounter any sensitive data (including personal data, financial information, or proprietary information or trade secrets of any party), you must stop your test, notify us immediately, and keep the data strictly confidential.`,
- `Do not submit a high volume of low-quality reports.`,
+ richText(
+ 'Notify us as soon as possible after discovering a real or potential security issue.'
+ ),
+ richText(
+ 'Give us a reasonable amount of time to resolve the issue before public disclosure.'
+ ),
+ richText(
+ 'Avoid privacy violations, degradation of user experience, disruption to production systems, and destruction or manipulation of data.'
+ ),
+ richText(
+ 'Use exploits only to the extent necessary to confirm that a vulnerability exists. Do not use an exploit to compromise or obtain data, establish command-line access or persistence, or pivot to other systems.'
+ ),
+ richText(
+ 'If You encounter sensitive data, including Personal Data, financial information, proprietary information, or trade secrets, stop testing, notify us immediately, and keep the data confidential.'
+ ),
+ richText('Do not submit a high volume of low-quality reports.'),
],
},
{ kind: 'subheading', text: 'Authorization' },
{
kind: 'paragraph',
- content: `Security research carried out in conformity with this policy is deemed permissible. We'll work with you to swiftly understand and fix the problem, and Sim will not suggest or pursue legal action in connection with your study.`,
+ content: richText(
+ 'Security research performed in conformity with this policy is considered permissible. We will work with You to understand and correct the issue, and Sim will not suggest or pursue legal action in connection with conforming research.'
+ ),
},
{ kind: 'subheading', text: 'Scope' },
{
kind: 'paragraph',
- content: `This policy applies to the following systems and services:`,
+ content: richText('This policy applies to the following systems and services:'),
},
{
kind: 'list',
- items: [`sim.ai website`, `Sim web application`, `Sim API services`],
+ items: [
+ richText('sim.ai website'),
+ richText('Sim web application'),
+ richText('Sim API services'),
+ ],
},
{
kind: 'paragraph',
- content: (
- <>
- Any service that isn't explicitly specified above, such as related services, is out of
- scope and isn't allowed to be tested. Vulnerabilities discovered in third-party
- solutions Sim interacts with are not covered by this policy and should be reported
- directly to the solution vendor in accordance with their disclosure policy (if any).
- Before beginning your inquiry, email us at{' '}
- security@sim.ai if you're unsure
- whether a system or endpoint is in scope.
- >
+ content: richText(
+ 'A service not expressly listed above, including related third-party services, is out of scope and may not be tested. Vulnerabilities in third-party products used by Sim are not covered and should be reported to the relevant provider under its disclosure policy. If You are unsure whether a system or endpoint is in scope, contact security@sim.ai before testing.'
),
},
- { kind: 'subheading', text: 'Types of testing' },
- { kind: 'paragraph', content: `The following test types are not authorized:` },
+ { kind: 'subheading', text: 'Unauthorized Testing' },
+ { kind: 'paragraph', content: richText('The following testing is not authorized:') },
{
kind: 'list',
items: [
- `Network denial of service (DoS or DDoS) tests`,
- `Physical testing (e.g., office access, open doors, tailgating), social engineering (e.g., phishing, vishing), or any other non-technical vulnerability testing`,
+ richText('Network denial-of-service or distributed denial-of-service testing'),
+ richText('Physical testing, including office access, open doors, or tailgating'),
+ richText('Social engineering, including phishing or vishing'),
+ richText('Other non-technical vulnerability testing'),
],
},
- { kind: 'subheading', text: 'Reporting a vulnerability' },
+ { kind: 'subheading', text: 'Reporting' },
{
kind: 'paragraph',
- content: (
- <>
- To report any security flaws, send an email to{' '}
- security@sim.ai . The next
- business day, we'll acknowledge receipt of your vulnerability report and keep you
- updated on our progress. Reports can be anonymously submitted.
- >
+ content: richText(
+ 'Send vulnerability reports to security@sim.ai. We will acknowledge a report by the next business day. Reports may be submitted anonymously.'
),
},
- { kind: 'subheading', text: 'Desirable information' },
- {
- kind: 'paragraph',
- content: `In order to process and react to a vulnerability report, we recommend to include the following information:`,
- },
+ { kind: 'paragraph', content: richText('A report should include, where possible:') },
{
kind: 'list',
items: [
- `Vulnerability description`,
- `Place of discovery`,
- `Potential Impact`,
- `Steps required to reproduce a vulnerability (include scripts and screenshots if possible)`,
+ richText('A description of the vulnerability'),
+ richText('The location where it was discovered'),
+ richText('Its potential impact'),
+ richText('Steps to reproduce it, including scripts and screenshots if available'),
],
},
- { kind: 'paragraph', content: `If possible, please provide your report in English.` },
- { kind: 'subheading', text: 'Our commitment' },
{
kind: 'paragraph',
- content: `If you choose to give your contact information, we promise to communicate with you in a transparent and timely manner. We will acknowledge receipt of your report within three business days. We will keep you informed on vulnerability confirmation and remedy to the best of our capabilities. We welcome a discussion of concerns and are willing to engage in a discourse.`,
+ content: richText(
+ 'Reports should be provided in English where possible. If You provide contact information, we will communicate in a transparent and timely manner. We will acknowledge receipt within three business days and will keep You informed about validation and remediation to the extent possible.'
+ ),
},
],
},
@@ -695,20 +929,59 @@ export const PRIVACY_CONFIG: LegalPageConfig = {
blocks: [
{
kind: 'paragraph',
- content: `If you have questions, requests, or complaints regarding this Privacy Policy or our data practices, you may contact us at:`,
+ content: richText(
+ 'Questions, requests, or complaints about this Privacy Policy or our data practices may be submitted to:'
+ ),
},
{
kind: 'list',
items: [
- <>
- Email: privacy@sim.ai
- >,
- `Mailing Address: Sim, 80 Langton St, San Francisco, CA 94103, USA`,
+ richText('Email: privacy@sim.ai'),
+ richText(
+ 'Mailing Address: Sim Studio, Inc., 80 Langton Street, San Francisco, CA 94103, USA'
+ ),
],
},
+ { kind: 'subheading', text: 'Our EU Representative' },
+ {
+ kind: 'paragraph',
+ content: richText(
+ 'Under Article 27 of the GDPR, Sim has appointed an EU Representative to act as its data protection agent:'
+ ),
+ },
{
kind: 'paragraph',
- content: `We will respond to your request within a reasonable timeframe.`,
+ content: (
+ <>
+ Instant EU GDPR Representative Ltd.
+
+ Adam Brogden
+
+ {richText('contact@gdprlocal.com')}
+
+ Tel: +353 1 554 9700
+
+ INSTANT EU GDPR REPRESENTATIVE LTD
+
+ Office 2, 12A Lower Main Street
+
+ Lucan, Co. Dublin, K78 X5P8
+
+ Ireland
+ >
+ ),
+ },
+ {
+ kind: 'paragraph',
+ content: richText(
+ 'Data subjects in the European Economic Area may contact the Representative on any matter relating to the processing of their Personal Data.'
+ ),
+ },
+ {
+ kind: 'paragraph',
+ content: richText(
+ 'We will respond to Your request within the timeframes stated in section 14 where those timeframes apply.'
+ ),
},
],
},
diff --git a/apps/sim/app/(landing)/x-page-view-tracker.test.tsx b/apps/sim/app/(landing)/x-page-view-tracker.test.tsx
new file mode 100644
index 00000000000..6d1d5f94860
--- /dev/null
+++ b/apps/sim/app/(landing)/x-page-view-tracker.test.tsx
@@ -0,0 +1,46 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+const { navigation, mockTwq } = vi.hoisted(() => ({
+ navigation: { pathname: '/pricing' },
+ mockTwq: vi.fn(),
+}))
+
+vi.mock('next/navigation', () => ({ usePathname: () => navigation.pathname }))
+
+import { XPageViewTracker } from '@/app/(landing)/x-page-view-tracker'
+
+let root: Root | null = null
+
+function render(): void {
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ if (!root) root = createRoot(document.createElement('div'))
+ act(() => root?.render( ))
+}
+
+afterEach(() => {
+ act(() => root?.unmount())
+ root = null
+ navigation.pathname = '/pricing'
+ window.twq = undefined
+ vi.clearAllMocks()
+})
+
+describe('XPageViewTracker', () => {
+ it('skips the pixel automatic first view and tracks later path changes once', () => {
+ window.twq = mockTwq
+ render()
+ expect(mockTwq).not.toHaveBeenCalled()
+
+ navigation.pathname = '/demo'
+ render()
+ render()
+
+ expect(mockTwq).toHaveBeenCalledOnce()
+ expect(mockTwq).toHaveBeenCalledWith('config', 'q5xbl')
+ })
+})
diff --git a/apps/sim/app/(landing)/x-page-view-tracker.tsx b/apps/sim/app/(landing)/x-page-view-tracker.tsx
index 5db1e0439fd..f216359eb47 100644
--- a/apps/sim/app/(landing)/x-page-view-tracker.tsx
+++ b/apps/sim/app/(landing)/x-page-view-tracker.tsx
@@ -1,38 +1,22 @@
'use client'
import { useEffect, useRef } from 'react'
-import { usePathname, useSearchParams } from 'next/navigation'
+import { usePathname } from 'next/navigation'
-declare global {
- interface Window {
- twq?: (...args: unknown[]) => void
- }
-}
-
-// next/script dedupes by id and never reloads on remount, so this must be
-// module-scope (not a ref) to survive LandingLayout unmounting/remounting.
let hasTrackedInitialPageView = false
/**
- * The X pixel base code only auto-tracks the first page load; LandingLayout
- * persists across client-side navigations, so the pixel never sees the rest.
- * Re-fires the pixel's PageView via `twq('config', ...)` on every navigation
- * after the first.
+ * The consent-gated X pixel tracks its first page when it loads. Re-fires the
+ * PageView for later client navigations.
*/
export function XPageViewTracker() {
const pathname = usePathname()
- const searchParams = useSearchParams()
- const query = searchParams.toString()
- // Instance-scoped (not module-scoped) so a Strict Mode replay of this
- // mount's effect is skipped, while a fresh mount — returning to the landing
- // layout from the app — starts empty and tracks the view again.
- const lastTrackedUrlRef = useRef(null)
+ const lastTrackedPathRef = useRef(null)
useEffect(() => {
- const url = query ? `${pathname}?${query}` : pathname
- if (lastTrackedUrlRef.current === url) return
- lastTrackedUrlRef.current = url
+ if (lastTrackedPathRef.current === pathname) return
+ lastTrackedPathRef.current = pathname
if (!hasTrackedInitialPageView) {
hasTrackedInitialPageView = true
@@ -40,7 +24,7 @@ export function XPageViewTracker() {
}
window.twq?.('config', 'q5xbl')
- }, [pathname, query])
+ }, [pathname])
return null
}
diff --git a/apps/sim/app/_shell/consent/consent-banner.tsx b/apps/sim/app/_shell/consent/consent-banner.tsx
index 3fbeab7f772..bd9d4bfdb7b 100644
--- a/apps/sim/app/_shell/consent/consent-banner.tsx
+++ b/apps/sim/app/_shell/consent/consent-banner.tsx
@@ -1,11 +1,9 @@
'use client'
-import { useEffect } from 'react'
import { useHeadlessConsentUI } from '@c15t/nextjs/headless'
import { Chip } from '@sim/emcn'
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
import Link from 'next/link'
-import { OPEN_CONSENT_PREFERENCES_EVENT } from '@/lib/consent/constants'
import { CONSENT_LINK_CLASS, ConsentPreferences } from '@/app/_shell/consent/consent-preferences'
/** Shared expo-out easing and timings, matching the toast stack's motion. */
@@ -30,19 +28,13 @@ const CATEGORIES_OPEN = { height: 'auto', opacity: 1 } as const
* It follows the visitor's theme. Every surface it can appear on either pins
* the light layer on `` through `ThemeProvider`'s forced theme, or is a
* themed app page where inheriting is what should happen — the card no longer
- * decides for itself. Inside the workspace it never renders at all; consent is
- * managed from Settings → Privacy there.
+ * decides for itself.
*/
export function ConsentBanner() {
const { banner, dialog, openDialog, performAction, saveCustomPreferences } =
useHeadlessConsentUI()
const prefersReducedMotion = useReducedMotion()
- useEffect(() => {
- window.addEventListener(OPEN_CONSENT_PREFERENCES_EVENT, openDialog)
- return () => window.removeEventListener(OPEN_CONSENT_PREFERENCES_EVENT, openDialog)
- }, [openDialog])
-
const isExpanded = dialog.isVisible
const surfaceName = isExpanded ? 'dialog' : 'banner'
const { allowedActions } = isExpanded ? dialog : banner
diff --git a/apps/sim/app/_shell/consent/consent-preferences-trigger.test.tsx b/apps/sim/app/_shell/consent/consent-preferences-trigger.test.tsx
new file mode 100644
index 00000000000..d72e22be78c
--- /dev/null
+++ b/apps/sim/app/_shell/consent/consent-preferences-trigger.test.tsx
@@ -0,0 +1,45 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+const { mockOpenDialog } = vi.hoisted(() => ({ mockOpenDialog: vi.fn() }))
+
+vi.mock('@c15t/nextjs/headless', () => ({
+ useHeadlessConsentUI: () => ({ openDialog: mockOpenDialog }),
+}))
+vi.mock('@sim/emcn', () => ({
+ Button: ({ children, ...props }: React.ButtonHTMLAttributes) => (
+ {children}
+ ),
+ cn: (...classes: Array) => classes.filter(Boolean).join(' '),
+}))
+
+import { ConsentPreferencesTrigger } from '@/app/_shell/consent/consent-preferences-trigger'
+
+let root: Root | null = null
+
+afterEach(() => {
+ act(() => root?.unmount())
+ root = null
+ vi.clearAllMocks()
+})
+
+describe('ConsentPreferencesTrigger', () => {
+ it('opens c15t preferences from an accessible button', () => {
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+
+ act(() => root?.render(Cookie settings ))
+ const button = container.querySelector('button')
+ act(() => button?.click())
+
+ expect(button?.type).toBe('button')
+ expect(button?.textContent).toBe('Cookie settings')
+ expect(mockOpenDialog).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/apps/sim/app/_shell/consent/consent-preferences-trigger.tsx b/apps/sim/app/_shell/consent/consent-preferences-trigger.tsx
new file mode 100644
index 00000000000..b7cfdba6e9c
--- /dev/null
+++ b/apps/sim/app/_shell/consent/consent-preferences-trigger.tsx
@@ -0,0 +1,25 @@
+'use client'
+
+import type { ReactNode } from 'react'
+import { useHeadlessConsentUI } from '@c15t/nextjs/headless'
+import { Button, cn } from '@sim/emcn'
+
+interface ConsentPreferencesTriggerProps {
+ children: ReactNode
+ className?: string
+}
+
+export function ConsentPreferencesTrigger({ children, className }: ConsentPreferencesTriggerProps) {
+ const { openDialog } = useHeadlessConsentUI()
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/apps/sim/app/_shell/consent/consent-provider.test.tsx b/apps/sim/app/_shell/consent/consent-provider.test.tsx
index b4ad6f0f87b..60045a81071 100644
--- a/apps/sim/app/_shell/consent/consent-provider.test.tsx
+++ b/apps/sim/app/_shell/consent/consent-provider.test.tsx
@@ -1,42 +1,42 @@
/**
* @vitest-environment jsdom
*/
+import type { ReactNode } from 'react'
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
-const { mockPathname, mockDynamicImport } = vi.hoisted(() => ({
- mockPathname: vi.fn(),
- mockDynamicImport: vi.fn(),
+vi.mock('@/app/_shell/consent/consent-store-provider', () => ({
+ ConsentStoreProvider: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
}))
-
-vi.mock('next/navigation', () => ({ usePathname: mockPathname }))
-
-/**
- * Stands in for the lazily-loaded runtime and records whether the chunk was
- * asked for at all — that, not just the absence of a banner, is what the
- * workspace gate is for.
- */
-vi.mock('next/dynamic', () => ({
- default: (loader: () => Promise) => {
- return function LazyRuntime() {
- mockDynamicImport(loader)
- return
- }
- },
+vi.mock('@/lib/consent/tracking-consent', () => ({
+ TrackingConsentProvider: ({ children }: { children: ReactNode }) => children,
+}))
+vi.mock('@/app/_shell/consent/consent-banner', () => ({
+ ConsentBanner: () => ,
+}))
+vi.mock('@/app/_shell/consent/google-analytics-page-view-tracker', () => ({
+ GoogleAnalyticsPageViewTracker: () => ,
}))
import { ConsentProvider } from '@/app/_shell/consent/consent-provider'
let root: Root | null = null
-function renderAt(pathname: string): HTMLDivElement {
- mockPathname.mockReturnValue(pathname)
+function render(): HTMLDivElement {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
const container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
- act(() => root?.render( ))
+ act(() =>
+ root?.render(
+
+
+
+ )
+ )
return container
}
@@ -47,23 +47,12 @@ afterEach(() => {
})
describe('ConsentProvider', () => {
- it.each(['/', '/pricing', '/login', '/cookie-policy', '/upgrade', '/workspaces'])(
- 'mounts the consent runtime on %s',
- (pathname) => {
- const container = renderAt(pathname)
-
- expect(container.querySelector('[data-testid="runtime"]')).not.toBeNull()
- expect(mockDynamicImport).toHaveBeenCalled()
- }
- )
-
- it.each(['/workspace', '/workspace/abc', '/workspace/abc/logs'])(
- 'mounts nothing on %s',
- (pathname) => {
- const container = renderAt(pathname)
-
- expect(container.querySelector('[data-testid="runtime"]')).toBeNull()
- expect(mockDynamicImport).not.toHaveBeenCalled()
- }
- )
+ it('wraps the application and presents the policy-controlled consent surface', () => {
+ const container = render()
+
+ expect(container.querySelector('[data-testid="store"]')).not.toBeNull()
+ expect(container.querySelector('[data-testid="application"]')).not.toBeNull()
+ expect(container.querySelector('[data-testid="analytics"]')).not.toBeNull()
+ expect(container.querySelector('[data-testid="banner"]')).not.toBeNull()
+ })
})
diff --git a/apps/sim/app/_shell/consent/consent-provider.tsx b/apps/sim/app/_shell/consent/consent-provider.tsx
index 97e544005c1..a30745054b2 100644
--- a/apps/sim/app/_shell/consent/consent-provider.tsx
+++ b/apps/sim/app/_shell/consent/consent-provider.tsx
@@ -1,43 +1,29 @@
'use client'
-import dynamic from 'next/dynamic'
-import { usePathname } from 'next/navigation'
+import type { ReactNode } from 'react'
+import { TrackingConsentProvider } from '@/lib/consent/tracking-consent'
+import { ConsentBanner } from '@/app/_shell/consent/consent-banner'
+import { ConsentStoreProvider } from '@/app/_shell/consent/consent-store-provider'
+import { GoogleAnalyticsPageViewTracker } from '@/app/_shell/consent/google-analytics-page-view-tracker'
-/**
- * The cookie-consent runtime, loaded on the client only and only once this
- * component renders it — the root layout renders it behind `isHosted`, so a
- * self-hosted deployment never fetches the chunk, never reaches Sim's consent
- * backend, and never sees the banner. Deferring it also keeps the third-party
- * store out of the server render and off the landing page's hydration path; the
- * banner cannot paint before its geo lookup resolves anyway.
- */
-const ConsentRuntime = dynamic(
- () => import('@/app/_shell/consent/consent-runtime').then((m) => m.ConsentRuntime),
- { ssr: false }
-)
-
-const WORKSPACE_SEGMENT = 'workspace'
+interface ConsentProviderProps {
+ children: ReactNode
+}
/**
- * Mounts the consent runtime everywhere except the workspace.
- *
- * Inside the product a floating consent card is the wrong surface — a signed-in
- * user manages this from Settings → Privacy, which mounts the same store. The
- * check sits above the `dynamic()` rather than inside the loaded module so the
- * workspace pays neither the chunk nor the consent init request: gating within
- * the module would still have downloaded it, on the surface with the most hard
- * loads.
- *
- * The gap this leaves — a visitor who reaches the workspace with no consent
- * record is not prompted — closes when the analytics scripts move behind
- * consent, since nothing non-essential loads without a record at all.
+ * Owns hosted Sim's consent lifecycle across every route. The banner stays off
+ * until the resolved jurisdiction policy requires it and then appears on every
+ * entry route, including a direct workspace visit. Privacy settings remain the
+ * durable control after the initial decision.
*/
-export function ConsentProvider() {
- const pathname = usePathname()
-
- if (pathname.split('/')[1] === WORKSPACE_SEGMENT) {
- return null
- }
-
- return
+export function ConsentProvider({ children }: ConsentProviderProps) {
+ return (
+
+
+ {children}
+
+
+
+
+ )
}
diff --git a/apps/sim/app/_shell/consent/consent-runtime.tsx b/apps/sim/app/_shell/consent/consent-runtime.tsx
deleted file mode 100644
index 243c1ed6b66..00000000000
--- a/apps/sim/app/_shell/consent/consent-runtime.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-'use client'
-
-import { ConsentBanner } from '@/app/_shell/consent/consent-banner'
-import { ConsentStoreProvider } from '@/app/_shell/consent/consent-store-provider'
-
-/**
- * The consent banner and the store it reads. Loaded lazily and client-only by
- * {@link ConsentProvider}, which also decides where it may mount.
- */
-export function ConsentRuntime() {
- return (
-
-
-
- )
-}
diff --git a/apps/sim/app/_shell/consent/consent-store-provider.test.tsx b/apps/sim/app/_shell/consent/consent-store-provider.test.tsx
index 69837317939..1c80f3b63d5 100644
--- a/apps/sim/app/_shell/consent/consent-store-provider.test.tsx
+++ b/apps/sim/app/_shell/consent/consent-store-provider.test.tsx
@@ -47,7 +47,14 @@ describe('ConsentStoreProvider', () => {
mode: 'hosted',
backendURL: 'https://sim-sim.inth.app',
consentCategories: ['necessary', 'measurement', 'marketing'],
- store: { iframeBlockerConfig: { disableAutomaticBlocking: true } },
+ scripts: [
+ expect.objectContaining({ id: 'gtag', category: 'measurement', alwaysLoad: true }),
+ expect.objectContaining({ id: 'ahrefs-analytics', category: 'measurement' }),
+ ],
+ store: {
+ reloadOnConsentRevoked: true,
+ iframeBlockerConfig: { disableAutomaticBlocking: true },
+ },
})
})
})
diff --git a/apps/sim/app/_shell/consent/consent-store-provider.tsx b/apps/sim/app/_shell/consent/consent-store-provider.tsx
index 09791fd56f4..79403e3022e 100644
--- a/apps/sim/app/_shell/consent/consent-store-provider.tsx
+++ b/apps/sim/app/_shell/consent/consent-store-provider.tsx
@@ -7,6 +7,7 @@ import {
CONSENT_CATEGORIES,
DEV_CONSENT_COUNTRY,
} from '@/lib/consent/constants'
+import { GLOBAL_CONSENT_SCRIPTS } from '@/lib/consent/scripts'
/**
* Imported from `@c15t/nextjs/headless`, not the package root: the headless
@@ -26,19 +27,18 @@ const CONSENT_OPTIONS = {
mode: 'hosted',
backendURL: CONSENT_BACKEND_URL,
consentCategories: [...CONSENT_CATEGORIES],
- store: { iframeBlockerConfig: { disableAutomaticBlocking: true } },
+ scripts: [...GLOBAL_CONSENT_SCRIPTS],
+ store: {
+ reloadOnConsentRevoked: true,
+ iframeBlockerConfig: { disableAutomaticBlocking: true },
+ },
...(DEV_CONSENT_COUNTRY ? { overrides: { country: DEV_CONSENT_COUNTRY } } : {}),
} satisfies ConsentManagerOptions
/**
- * The consent store, for the two surfaces that read it: the banner on public
- * pages and the Privacy settings page inside the workspace.
- *
- * They mount separately — the banner sits behind an `ssr: false` boundary that
- * cannot wrap the app, so nothing reaches it through React context — yet share
- * one store, because `getOrCreateConsentRuntime` caches manager and store by
- * the option values. Keeping the options private to this component is what
- * makes that structural: two call sites cannot drift into two stores.
+ * The single consent store for hosted Sim. It wraps the entire application so
+ * script loading, the public banner, and workspace privacy settings cannot
+ * observe different consent state.
*/
export function ConsentStoreProvider({ children }: { children: ReactNode }) {
return {children}
diff --git a/apps/sim/app/_shell/consent/google-analytics-page-view-tracker.test.tsx b/apps/sim/app/_shell/consent/google-analytics-page-view-tracker.test.tsx
new file mode 100644
index 00000000000..a16cd8e717d
--- /dev/null
+++ b/apps/sim/app/_shell/consent/google-analytics-page-view-tracker.test.tsx
@@ -0,0 +1,64 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+const { consent, navigation, mockTrackGooglePageView } = vi.hoisted(() => ({
+ consent: { hasFetchedBanner: false, measurement: false, gtagLoaded: false },
+ navigation: { pathname: '/pricing' },
+ mockTrackGooglePageView: vi.fn(),
+}))
+
+vi.mock('next/navigation', () => ({ usePathname: () => navigation.pathname }))
+vi.mock('@c15t/nextjs/headless', () => ({
+ useConsentManager: () => ({
+ has: (category: string) => category === 'measurement' && consent.measurement,
+ hasFetchedBanner: consent.hasFetchedBanner,
+ loadedScripts: { gtag: consent.gtagLoaded },
+ }),
+}))
+vi.mock('@/lib/analytics/google', () => ({
+ trackGooglePageView: mockTrackGooglePageView,
+}))
+
+import { GoogleAnalyticsPageViewTracker } from '@/app/_shell/consent/google-analytics-page-view-tracker'
+
+let root: Root | null = null
+
+function render(): void {
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ if (!root) root = createRoot(document.createElement('div'))
+ act(() => root?.render( ))
+}
+
+afterEach(() => {
+ act(() => root?.unmount())
+ root = null
+ consent.hasFetchedBanner = false
+ consent.measurement = false
+ consent.gtagLoaded = false
+ navigation.pathname = '/pricing'
+ vi.clearAllMocks()
+})
+
+describe('GoogleAnalyticsPageViewTracker', () => {
+ it('tracks only later path changes after consent and the automatic first view', () => {
+ render()
+ expect(mockTrackGooglePageView).not.toHaveBeenCalled()
+
+ consent.hasFetchedBanner = true
+ consent.measurement = true
+ consent.gtagLoaded = true
+ render()
+ expect(mockTrackGooglePageView).not.toHaveBeenCalled()
+
+ navigation.pathname = '/demo'
+ render()
+ render()
+
+ expect(mockTrackGooglePageView).toHaveBeenCalledOnce()
+ expect(mockTrackGooglePageView).toHaveBeenCalledWith('/demo')
+ })
+})
diff --git a/apps/sim/app/_shell/consent/google-analytics-page-view-tracker.tsx b/apps/sim/app/_shell/consent/google-analytics-page-view-tracker.tsx
new file mode 100644
index 00000000000..6e39fe972a5
--- /dev/null
+++ b/apps/sim/app/_shell/consent/google-analytics-page-view-tracker.tsx
@@ -0,0 +1,28 @@
+'use client'
+
+import { useEffect, useRef } from 'react'
+import { useConsentManager } from '@c15t/nextjs/headless'
+import { usePathname } from 'next/navigation'
+import { trackGooglePageView } from '@/lib/analytics/google'
+
+/** Tracks Next.js client navigations after c15t has loaded the consent-aware tag. */
+export function GoogleAnalyticsPageViewTracker() {
+ const pathname = usePathname()
+ const { has, hasFetchedBanner, loadedScripts } = useConsentManager()
+ const lastTrackedPathRef = useRef(null)
+
+ useEffect(() => {
+ if (!hasFetchedBanner || !has('measurement') || !loadedScripts.gtag) return
+
+ if (lastTrackedPathRef.current === null) {
+ lastTrackedPathRef.current = pathname
+ return
+ }
+ if (lastTrackedPathRef.current === pathname) return
+
+ lastTrackedPathRef.current = pathname
+ trackGooglePageView(pathname)
+ }, [has, hasFetchedBanner, loadedScripts.gtag, pathname])
+
+ return null
+}
diff --git a/apps/sim/app/_shell/providers/posthog-provider.test.tsx b/apps/sim/app/_shell/providers/posthog-provider.test.tsx
new file mode 100644
index 00000000000..229fb5b1c7b
--- /dev/null
+++ b/apps/sim/app/_shell/providers/posthog-provider.test.tsx
@@ -0,0 +1,141 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+const { consent, mockCapture, mockInit, mockOptIn, mockOptOut, mockPostHog, mockSetPostHogClient } =
+ vi.hoisted(() => {
+ const posthog = {
+ __loaded: false,
+ capture: vi.fn(),
+ init: vi.fn(),
+ opt_in_capturing: vi.fn(),
+ opt_out_capturing: vi.fn(),
+ }
+ posthog.init.mockImplementation(() => {
+ posthog.__loaded = true
+ })
+ return {
+ consent: { isResolved: false, measurement: false, marketing: false },
+ mockCapture: posthog.capture,
+ mockInit: posthog.init,
+ mockOptIn: posthog.opt_in_capturing,
+ mockOptOut: posthog.opt_out_capturing,
+ mockPostHog: posthog,
+ mockSetPostHogClient: vi.fn(),
+ }
+ })
+
+vi.mock('@/lib/consent/tracking-consent', () => ({ useTrackingConsent: () => consent }))
+vi.mock('@/lib/core/config/env', () => ({
+ getEnv: (name: string) =>
+ name === 'NEXT_PUBLIC_POSTHOG_ENABLED' ? 'true' : 'phc_test_project_key',
+ isTruthy: (value: string) => value === 'true',
+ publicEnvMissingAtModuleInit: false,
+}))
+vi.mock('@/lib/posthog/client', () => ({ setPostHogClient: mockSetPostHogClient }))
+vi.mock('@/lib/posthog/exception-filter', () => ({ preparePostHogEvent: vi.fn() }))
+vi.mock('posthog-js', () => ({
+ default: mockPostHog,
+}))
+vi.mock('posthog-js/react', () => ({
+ PostHogProvider: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+}))
+
+import { PostHogProvider } from '@/app/_shell/providers/posthog-provider'
+
+let root: Root | null = null
+let container: HTMLDivElement | null = null
+
+function render(): HTMLDivElement {
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ container ??= document.createElement('div')
+ document.body.appendChild(container)
+ root ??= createRoot(container)
+ act(() =>
+ root?.render(
+
+
+
+ )
+ )
+ return container
+}
+
+afterEach(() => {
+ act(() => root?.unmount())
+ root = null
+ container = null
+ consent.isResolved = false
+ consent.measurement = false
+ mockPostHog.__loaded = false
+ localStorage.clear()
+ sessionStorage.clear()
+ vi.clearAllMocks()
+})
+
+describe('PostHogProvider consent gating', () => {
+ it('initializes and publishes PostHog only while measurement consent is granted', async () => {
+ localStorage.setItem('ph_phc_test_project_key_posthog', 'identity')
+ localStorage.setItem('ph_other_project_posthog', 'other-identity')
+ localStorage.setItem('application_preference', 'keep')
+ const container = render()
+ const application = container.querySelector('[data-testid="application"]')
+
+ expect(mockInit).not.toHaveBeenCalled()
+ expect(localStorage.getItem('ph_phc_test_project_key_posthog')).toBe('identity')
+ expect(application).not.toBeNull()
+ expect(container.querySelector('[data-testid="posthog-provider"]')).not.toBeNull()
+
+ consent.isResolved = true
+ consent.measurement = true
+ render()
+
+ await vi.waitFor(() => expect(mockInit).toHaveBeenCalledTimes(1))
+ expect(mockInit).toHaveBeenCalledWith(
+ 'phc_test_project_key',
+ expect.objectContaining({
+ opt_out_capturing_by_default: true,
+ opt_out_persistence_by_default: true,
+ })
+ )
+ expect(mockOptIn).toHaveBeenCalledWith({ captureEventName: false })
+ expect(mockSetPostHogClient).toHaveBeenLastCalledWith(
+ expect.objectContaining({ capture: mockCapture })
+ )
+ expect(container.querySelector('[data-testid="posthog-provider"]')).not.toBeNull()
+ expect(container.querySelector('[data-testid="application"]')).toBe(application)
+
+ consent.measurement = false
+ render()
+
+ expect(mockOptOut).toHaveBeenCalledTimes(1)
+ expect(mockSetPostHogClient).toHaveBeenLastCalledWith(null)
+ expect(container.querySelector('[data-testid="posthog-provider"]')).not.toBeNull()
+ expect(container.querySelector('[data-testid="application"]')).toBe(application)
+ expect(localStorage.getItem('ph_phc_test_project_key_posthog')).toBeNull()
+ expect(localStorage.getItem('ph_other_project_posthog')).toBe('other-identity')
+ expect(localStorage.getItem('application_preference')).toBe('keep')
+ })
+
+ it('clears only this project persistence after an initial denial', () => {
+ localStorage.setItem('ph_phc_test_project_key_posthog', 'identity')
+ localStorage.setItem('__ph_opt_in_out_phc_test_project_key', '1')
+ sessionStorage.setItem('ph_phc_test_project_key_window_id', 'window-id')
+ localStorage.setItem('ph_other_project_posthog', 'other-identity')
+
+ render()
+ consent.isResolved = true
+ render()
+
+ expect(mockInit).not.toHaveBeenCalled()
+ expect(localStorage.getItem('ph_phc_test_project_key_posthog')).toBeNull()
+ expect(localStorage.getItem('__ph_opt_in_out_phc_test_project_key')).toBeNull()
+ expect(sessionStorage.getItem('ph_phc_test_project_key_window_id')).toBeNull()
+ expect(localStorage.getItem('ph_other_project_posthog')).toBe('other-identity')
+ })
+})
diff --git a/apps/sim/app/_shell/providers/posthog-provider.tsx b/apps/sim/app/_shell/providers/posthog-provider.tsx
index 095646f29de..f6a42361367 100644
--- a/apps/sim/app/_shell/providers/posthog-provider.tsx
+++ b/apps/sim/app/_shell/providers/posthog-provider.tsx
@@ -1,127 +1,173 @@
'use client'
-import { useEffect, useRef, useState } from 'react'
+import { useEffect } from 'react'
import { createLogger } from '@sim/logger'
-import type { PostHog } from 'posthog-js'
+import posthog from 'posthog-js'
+import { PostHogProvider as PHProvider } from 'posthog-js/react'
+import { useTrackingConsent } from '@/lib/consent/tracking-consent'
import { getEnv, isTruthy, publicEnvMissingAtModuleInit } from '@/lib/core/config/env'
-import { settlePostHogClient } from '@/lib/posthog/client'
-import { dropUnactionableExceptions } from '@/lib/posthog/exception-filter'
+import { setPostHogClient } from '@/lib/posthog/client'
+import { preparePostHogEvent } from '@/lib/posthog/exception-filter'
const logger = createLogger('PostHogProvider')
-export function PostHogProvider({ children }: { children: React.ReactNode }) {
- const [Provider, setProvider] = useState | null>(null)
- const clientRef = useRef(null)
+/** Removes this PostHog project's browser state after a settled analytics denial. */
+function clearPostHogBrowserState(posthogKey: string): void {
+ const persistenceKey = `ph_${posthogKey
+ .replace(/\+/g, 'PL')
+ .replace(/\//g, 'SL')
+ .replace(/=/g, 'EQ')}_posthog`
+ const storageKeys = [
+ persistenceKey,
+ `ph_${posthogKey}_window_id`,
+ `ph_${posthogKey}_primary_window_exists`,
+ `__ph_opt_in_out_${posthogKey}`,
+ ]
+
+ try {
+ for (const storage of [window.localStorage, window.sessionStorage]) {
+ for (const key of storageKeys) storage.removeItem(key)
+ }
+ } catch {}
+
+ try {
+ const simDomain =
+ window.location.hostname === 'sim.ai' || window.location.hostname.endsWith('.sim.ai')
+ ? '; Domain=.sim.ai'
+ : ''
+
+ for (const key of storageKeys) {
+ document.cookie = `${key}=; Max-Age=0; Path=/; SameSite=Lax`
+ if (simDomain) document.cookie = `${key}=; Max-Age=0; Path=/; SameSite=Lax${simDomain}`
+ }
+ } catch {}
+}
+
+interface PostHogProviderProps {
+ children: React.ReactNode
+ consentRequired?: boolean
+}
+
+export function PostHogProvider({ children, consentRequired = false }: PostHogProviderProps) {
+ const { isResolved, measurement } = useTrackingConsent()
+ const canInitialize = !consentRequired || (isResolved && measurement)
useEffect(() => {
- const posthogEnabled = getEnv('NEXT_PUBLIC_POSTHOG_ENABLED')
const posthogKey = getEnv('NEXT_PUBLIC_POSTHOG_KEY')
+ if (!canInitialize) {
+ setPostHogClient(null)
+ if (posthog.__loaded) posthog.opt_out_capturing()
+ if (consentRequired && isResolved && !measurement && posthogKey) {
+ clearPostHogBrowserState(posthogKey)
+ }
+ return () => setPostHogClient(null)
+ }
+
+ const posthogEnabled = getEnv('NEXT_PUBLIC_POSTHOG_ENABLED')
+
if (!isTruthy(posthogEnabled) || !posthogKey) {
- settlePostHogClient(null)
- return
+ setPostHogClient(null)
+ if (posthog.__loaded) posthog.opt_out_capturing()
+ return () => setPostHogClient(null)
}
- Promise.all([import('posthog-js'), import('posthog-js/react')])
- .then(([posthogModule, { PostHogProvider: PHProvider }]) => {
- const posthog = posthogModule.default
- if (!posthog.__loaded) {
- posthog.init(posthogKey, {
- api_host: '/ingest',
- ui_host: 'https://us.posthog.com',
- defaults: '2025-05-24',
- person_profiles: 'identified_only',
- autocapture: false,
- capture_pageview: false,
- capture_pageleave: false,
- capture_performance: false,
- capture_dead_clicks: false,
- enable_heatmaps: false,
- /**
- * PostHog's own error tracking, wired to `window.onerror` and
- * `unhandledrejection`. This is the app-wide net: React error
- * boundaries only see errors thrown inside the tree they wrap, and
- * a failed chunk load, a rejected promise, or anything thrown from
- * an event handler or socket callback reaches none of them.
- *
- * `capture_console_errors` stays off. It is not error reporting —
- * it captures every `console.error`, which here means React's
- * hydration and dev warnings (the ones `HydrationErrorHandler`
- * already filters out as noise) drowning the real exceptions.
- */
- capture_exceptions: {
- capture_unhandled_errors: true,
- capture_unhandled_rejections: true,
- capture_console_errors: false,
+ try {
+ if (!posthog.__loaded) {
+ posthog.init(posthogKey, {
+ api_host: '/ingest',
+ ui_host: 'https://us.posthog.com',
+ defaults: '2025-05-24',
+ person_profiles: 'identified_only',
+ autocapture: false,
+ capture_pageview: false,
+ capture_pageleave: false,
+ capture_performance: false,
+ capture_dead_clicks: false,
+ enable_heatmaps: false,
+ /**
+ * PostHog's own error tracking, wired to `window.onerror` and
+ * `unhandledrejection`. This is the app-wide net: React error
+ * boundaries only see errors thrown inside the tree they wrap, and
+ * a failed chunk load, a rejected promise, or anything thrown from
+ * an event handler or socket callback reaches none of them.
+ *
+ * `capture_console_errors` stays off. It is not error reporting —
+ * it captures every `console.error`, which here means React's
+ * hydration and dev warnings (the ones `HydrationErrorHandler`
+ * already filters out as noise) drowning the real exceptions.
+ */
+ capture_exceptions: {
+ capture_unhandled_errors: true,
+ capture_unhandled_rejections: true,
+ capture_console_errors: false,
+ },
+ /**
+ * Drops the browser artifacts that autocapture cannot help but
+ * see — resize-loop notices, opaque cross-origin failures, and
+ * cancelled requests. Filtering here rather than with a PostHog
+ * suppression rule keeps the list reviewable in the diff and stops
+ * the events before they leave the browser.
+ */
+ before_send: preparePostHogEvent,
+ opt_out_capturing_by_default: true,
+ opt_out_persistence_by_default: true,
+ disable_session_recording: true,
+ session_recording: {
+ maskAllInputs: false,
+ maskInputOptions: {
+ password: true,
+ email: false,
},
/**
- * Drops the browser artifacts that autocapture cannot help but
- * see — resize-loop notices, opaque cross-origin failures, and
- * cancelled requests. Filtering here rather than with a PostHog
- * suppression rule keeps the list reviewable in the diff and stops
- * the events before they leave the browser.
+ * None of these nodes are painted, so replay fidelity is
+ * unchanged, while each full snapshot serializes fewer nodes on
+ * the main thread and ships a smaller payload.
+ *
+ * Enumerated rather than `true`/`'all'` on purpose — those
+ * presets also enable `headTitleMutations`, which would drop
+ * `document.title` changes and lose the page identity a replay
+ * viewer reads while scrubbing.
*/
- before_send: dropUnactionableExceptions,
- disable_session_recording: true,
- session_recording: {
- maskAllInputs: false,
- maskInputOptions: {
- password: true,
- email: false,
- },
- /**
- * None of these nodes are painted, so replay fidelity is
- * unchanged, while each full snapshot serializes fewer nodes on
- * the main thread and ships a smaller payload.
- *
- * Enumerated rather than `true`/`'all'` on purpose — those
- * presets also enable `headTitleMutations`, which would drop
- * `document.title` changes and lose the page identity a replay
- * viewer reads while scrubbing.
- */
- slimDOMOptions: {
- script: true,
- comment: true,
- headFavicon: true,
- headWhitespace: true,
- headMetaDescKeywords: true,
- headMetaSocial: true,
- headMetaRobots: true,
- headMetaHttpEquiv: true,
- headMetaAuthorship: true,
- headMetaVerification: true,
- },
- recordCrossOriginIframes: false,
- recordHeaders: false,
- recordBody: false,
+ slimDOMOptions: {
+ script: true,
+ comment: true,
+ headFavicon: true,
+ headWhitespace: true,
+ headMetaDescKeywords: true,
+ headMetaSocial: true,
+ headMetaRobots: true,
+ headMetaHttpEquiv: true,
+ headMetaAuthorship: true,
+ headMetaVerification: true,
},
- persistence: 'localStorage+cookie',
- })
- }
- /**
- * Releases anything captured while the imports above were in flight.
- * Must run after `init`, since `capture` is a silent no-op until then.
- */
- settlePostHogClient(posthog)
-
- if (publicEnvMissingAtModuleInit) {
- posthog.capture('runtime_env_missing_at_module_init')
- }
- clientRef.current = posthog
- setProvider(() => PHProvider)
- })
- .catch((err) => {
- settlePostHogClient(null)
- logger.error('Failed to load PostHog', { error: err })
- })
- }, [])
-
- if (Provider && clientRef.current) {
- return {children}
- }
-
- return <>{children}>
+ recordCrossOriginIframes: false,
+ recordHeaders: false,
+ recordBody: false,
+ },
+ persistence: 'localStorage+cookie',
+ })
+ }
+ /**
+ * A prior withdrawal persists PostHog's opt-out marker. c15t is the
+ * source of truth, so a settled grant must explicitly clear that marker
+ * without emitting PostHog's synthetic opt-in event.
+ */
+ posthog.opt_in_capturing({ captureEventName: false })
+ setPostHogClient(posthog)
+
+ if (publicEnvMissingAtModuleInit) {
+ posthog.capture('runtime_env_missing_at_module_init')
+ }
+ } catch (err) {
+ setPostHogClient(null)
+ logger.error('Failed to load PostHog', { error: err })
+ }
+
+ return () => {
+ setPostHogClient(null)
+ }
+ }, [canInitialize, consentRequired, isResolved, measurement])
+
+ return {children}
}
diff --git a/apps/sim/app/api/auth/forget-password/route.ts b/apps/sim/app/api/auth/forget-password/route.ts
index 4eaf161b37f..9f0c7c1ce1f 100644
--- a/apps/sim/app/api/auth/forget-password/route.ts
+++ b/apps/sim/app/api/auth/forget-password/route.ts
@@ -97,6 +97,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json(
{
message:
+ // utils-lint-allow: returned to an unauthenticated caller, so a non-Error throw
+ // must surface the fixed copy rather than its own text — getErrorMessage would
+ // pass a thrown string straight through.
error instanceof Error
? error.message
: 'Failed to send password reset email. Please try again later.',
diff --git a/apps/sim/app/api/auth/reset-password/route.ts b/apps/sim/app/api/auth/reset-password/route.ts
index 268992e07a7..469738fd04f 100644
--- a/apps/sim/app/api/auth/reset-password/route.ts
+++ b/apps/sim/app/api/auth/reset-password/route.ts
@@ -60,6 +60,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json(
{
message:
+ // utils-lint-allow: returned to an unauthenticated caller, so a non-Error throw
+ // must surface the fixed copy rather than its own text — getErrorMessage would
+ // pass a thrown string straight through.
error instanceof Error
? error.message
: 'Failed to reset password. Please try again or request a new reset link.',
diff --git a/apps/sim/app/api/credentials/route.test.ts b/apps/sim/app/api/credentials/route.test.ts
index 0cd669ecfa2..cea9f263adb 100644
--- a/apps/sim/app/api/credentials/route.test.ts
+++ b/apps/sim/app/api/credentials/route.test.ts
@@ -143,6 +143,7 @@ describe('GET /api/credentials', () => {
type: 'env_personal',
displayName: 'MY_API_KEY',
description: null,
+ unredacted: false,
providerId: null,
accountId: null,
envKey: 'MY_API_KEY',
@@ -186,6 +187,7 @@ describe('GET /api/credentials', () => {
type: 'service_account',
displayName: 'Slack custom bot',
description: null,
+ unredacted: false,
providerId: 'slack-custom-bot',
accountId: null,
envKey: null,
@@ -201,6 +203,7 @@ describe('GET /api/credentials', () => {
type: 'oauth',
displayName: 'Google account',
description: null,
+ unredacted: false,
providerId: 'google-email',
accountId: 'google-account',
envKey: null,
@@ -317,6 +320,7 @@ describe('POST /api/credentials', () => {
type: 'service_account',
displayName: 'Service account',
description: null,
+ unredacted: false,
providerId: 'zoom-service-account',
accountId: null,
envKey: null,
@@ -351,6 +355,7 @@ describe('POST /api/credentials', () => {
type: 'service_account',
displayName: 'Zoom account acct_123',
description: null,
+ unredacted: false,
providerId: 'zoom-service-account',
accountId: null,
envKey: null,
@@ -404,6 +409,7 @@ describe('POST /api/credentials', () => {
type: 'service_account',
displayName: 'Oracle NetSuite 1234567',
description: null,
+ unredacted: false,
providerId: 'netsuite-service-account',
accountId: null,
envKey: null,
diff --git a/apps/sim/app/api/files/export/[id]/route.test.ts b/apps/sim/app/api/files/export/[id]/route.test.ts
index 069e1fc30a9..1d83cdd02f9 100644
--- a/apps/sim/app/api/files/export/[id]/route.test.ts
+++ b/apps/sim/app/api/files/export/[id]/route.test.ts
@@ -11,23 +11,28 @@ const {
mockGetFileMetadataById,
mockVerifyFileAccess,
mockDownloadFile,
- mockExtractEmbeddedImageIds,
+ mockExtractEmbeddedFileRefs,
} = vi.hoisted(() => ({
mockCheckAuth: vi.fn(),
mockGetFileMetadataById: vi.fn(),
mockVerifyFileAccess: vi.fn(),
mockDownloadFile: vi.fn(),
- mockExtractEmbeddedImageIds: vi.fn(),
+ mockExtractEmbeddedFileRefs: vi.fn(),
}))
+/** `embedded-image-refs.test.ts` covers the grammar itself. */
+function embeds(...ids: string[]) {
+ mockExtractEmbeddedFileRefs.mockReturnValue({ keys: [], ids })
+}
+
vi.mock('@/lib/auth/hybrid', () => ({ checkSessionOrInternalAuth: mockCheckAuth }))
vi.mock('@/lib/uploads/server/metadata', () => ({
getFileMetadataById: mockGetFileMetadataById,
}))
vi.mock('@/app/api/files/authorization', () => ({ verifyFileAccess: mockVerifyFileAccess }))
vi.mock('@/lib/uploads/core/storage-service', () => ({ downloadFile: mockDownloadFile }))
-vi.mock('@/lib/copilot/tools/server/files/embedded-image-refs', () => ({
- extractEmbeddedImageIds: mockExtractEmbeddedImageIds,
+vi.mock('@/lib/uploads/server/embedded-image-refs', () => ({
+ extractEmbeddedFileRefs: mockExtractEmbeddedFileRefs,
}))
vi.mock('@sim/audit', () => ({
recordAudit: vi.fn(),
@@ -58,43 +63,35 @@ function assetRecord(id: string, size: number) {
}
}
-describe('markdown export bundling', () => {
- beforeEach(() => {
- vi.clearAllMocks()
- mockCheckAuth.mockResolvedValue({ success: true, userId: 'user-1' })
- mockVerifyFileAccess.mockResolvedValue(true)
- mockGetFileMetadataById.mockImplementation(async (id: string) =>
- id === DOC_ID
- ? {
- id: DOC_ID,
- key: 'workspace/ws-1/doc.md',
- originalName: 'doc.md',
- contentType: 'text/markdown',
- context: 'workspace',
- size: 1024,
- workspaceId: 'ws-1',
- }
- : assetRecord(id, 1 * MB)
- )
- mockDownloadFile.mockResolvedValue(Buffer.from('# Doc\n'))
- mockExtractEmbeddedImageIds.mockReturnValue([])
- })
+const DOC_RECORD = {
+ id: DOC_ID,
+ key: 'workspace/ws-1/doc.md',
+ originalName: 'doc.md',
+ contentType: 'text/markdown',
+ context: 'workspace',
+ size: 1024,
+ workspaceId: 'ws-1',
+}
+
+function assetsResolveTo(assetFor: (id: string) => unknown) {
+ mockGetFileMetadataById.mockImplementation(async (id: string) =>
+ id === DOC_ID ? DOC_RECORD : assetFor(id)
+ )
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckAuth.mockResolvedValue({ success: true, userId: 'user-1' })
+ mockVerifyFileAccess.mockResolvedValue(true)
+ assetsResolveTo((id) => assetRecord(id, 1 * MB))
+ mockDownloadFile.mockResolvedValue(Buffer.from('# Doc\n'))
+ embeds()
+})
+describe('markdown export bundling', () => {
it('rejects on declared asset bytes before downloading any of them', async () => {
- mockExtractEmbeddedImageIds.mockReturnValue(['a', 'b', 'c'])
- mockGetFileMetadataById.mockImplementation(async (id: string) =>
- id === DOC_ID
- ? {
- id: DOC_ID,
- key: 'workspace/ws-1/doc.md',
- originalName: 'doc.md',
- contentType: 'text/markdown',
- context: 'workspace',
- size: 1024,
- workspaceId: 'ws-1',
- }
- : assetRecord(id, 100 * MB)
- )
+ embeds('a', 'b', 'c')
+ assetsResolveTo((id) => assetRecord(id, 100 * MB))
const response = await GET(request(), context)
@@ -106,7 +103,7 @@ describe('markdown export bundling', () => {
it('counts the document body against the export limit, not just its assets', async () => {
// Assets alone sit under the cap; the body is what carries the bundle over it.
- mockExtractEmbeddedImageIds.mockReturnValue(['a'])
+ embeds('a')
mockDownloadFile.mockResolvedValue(Buffer.alloc(250 * MB))
const response = await GET(request(), context)
@@ -116,7 +113,7 @@ describe('markdown export bundling', () => {
})
it('caps the document body read rather than loading it unbounded', async () => {
- mockExtractEmbeddedImageIds.mockReturnValue([])
+ embeds()
await GET(request(), context)
@@ -125,7 +122,7 @@ describe('markdown export bundling', () => {
})
it('reports an oversized body as a size rejection, not a server error', async () => {
- mockExtractEmbeddedImageIds.mockReturnValue([])
+ embeds()
mockDownloadFile.mockRejectedValue(
new PayloadSizeLimitError({ label: 'storage file download', maxBytes: 1 })
)
@@ -138,7 +135,7 @@ describe('markdown export bundling', () => {
})
it('caps each asset download rather than trusting its declared size', async () => {
- mockExtractEmbeddedImageIds.mockReturnValue(['a'])
+ embeds('a')
await GET(request(), context)
@@ -149,7 +146,7 @@ describe('markdown export bundling', () => {
})
it('drops an unreadable asset instead of failing the whole export', async () => {
- mockExtractEmbeddedImageIds.mockReturnValue(['good', 'bad'])
+ embeds('good', 'bad')
mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => {
if (key.endsWith('doc.md')) return Buffer.from('# Doc\n\n')
if (key.endsWith('bad')) throw new Error('storage down')
@@ -164,8 +161,31 @@ describe('markdown export bundling', () => {
expect(zip.file('assets/bad.png')).toBeNull()
})
+ /**
+ * The two id representations have to stay distinct: metadata resolves by the stored id, while the
+ * rewrite finds the embed by the spelling the document used. Collapsing them either drops the
+ * asset or bundles it behind a link still pointing at the API.
+ */
+ it('resolves and rewrites an embed whose id is percent-encoded in the document', async () => {
+ embeds('wf%5Fa')
+ assetsResolveTo((id) => (id === 'wf_a' ? assetRecord(id, 1 * MB) : null))
+ mockDownloadFile.mockImplementation(async ({ key }: { key: string }) =>
+ key.endsWith('doc.md')
+ ? Buffer.from('# Doc\n\n')
+ : Buffer.from('png-bytes')
+ )
+
+ const response = await GET(request(), context)
+
+ const zip = await JSZip.loadAsync(Buffer.from(await response.arrayBuffer()))
+ expect(zip.file('assets/wf_a.png')).not.toBeNull()
+ const md = await zip.file('doc.md')?.async('string')
+ expect(md).toContain('./assets/wf_a.png')
+ expect(md).not.toContain('/api/files/view/')
+ })
+
it('skips an asset the caller cannot read', async () => {
- mockExtractEmbeddedImageIds.mockReturnValue(['secret'])
+ embeds('secret')
mockVerifyFileAccess.mockImplementation(async (key: string) => !key.endsWith('secret'))
const response = await GET(request(), context)
@@ -177,3 +197,47 @@ describe('markdown export bundling', () => {
)
})
})
+
+describe('markdown export format', () => {
+ async function expectPlainMarkdown(response: Response) {
+ expect(response.status).toBe(200)
+ expect(response.headers.get('Content-Type')).toBe('text/markdown; charset=utf-8')
+ expect(response.headers.get('Content-Disposition')).toContain('doc.md')
+ expect(await response.text()).toBe('# Doc\n')
+ }
+
+ it('returns the document itself when it embeds nothing', async () => {
+ await expectPlainMarkdown(await GET(request(), context))
+ })
+
+ /**
+ * The reported bug: a document that references files which no longer resolve downloaded as a zip
+ * whose `assets/` folder was empty. The format follows what was bundled, not what was referenced.
+ */
+ it('returns the document itself when no embed resolves to a file', async () => {
+ embeds('gone', 'also-gone')
+ assetsResolveTo(() => null)
+
+ await expectPlainMarkdown(await GET(request(), context))
+ })
+
+ it('returns the document itself when every embed fails to download', async () => {
+ embeds('a')
+ mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => {
+ if (key.endsWith('doc.md')) return Buffer.from('# Doc\n')
+ throw new Error('storage down')
+ })
+
+ await expectPlainMarkdown(await GET(request(), context))
+ })
+
+ it('bundles a zip once at least one embed resolves', async () => {
+ embeds('a')
+
+ const response = await GET(request(), context)
+
+ expect(response.headers.get('Content-Type')).toBe('application/zip')
+ const zip = await JSZip.loadAsync(Buffer.from(await response.arrayBuffer()))
+ expect(zip.file('assets/a.png')).not.toBeNull()
+ })
+})
diff --git a/apps/sim/app/api/files/export/[id]/route.ts b/apps/sim/app/api/files/export/[id]/route.ts
index 0e578ada87b..4f67b7b8353 100644
--- a/apps/sim/app/api/files/export/[id]/route.ts
+++ b/apps/sim/app/api/files/export/[id]/route.ts
@@ -8,7 +8,6 @@ import { NextResponse } from 'next/server'
import { fileExportContract } from '@/lib/api/contracts/storage-transfer'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
-import { extractEmbeddedImageIds } from '@/lib/copilot/tools/server/files/embedded-image-refs'
import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency'
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -16,7 +15,9 @@ import { captureServerEvent } from '@/lib/posthog/server'
import type { StorageContext } from '@/lib/uploads/config'
import { getServeStoragePrefix } from '@/lib/uploads/config'
import { downloadFile } from '@/lib/uploads/core/storage-service'
+import { extractEmbeddedFileRefs } from '@/lib/uploads/server/embedded-image-refs'
import { getFileMetadataById } from '@/lib/uploads/server/metadata'
+import { storedFileId } from '@/lib/uploads/utils/embedded-image-ref'
import { formatFileSize } from '@/lib/uploads/utils/file-utils'
import { verifyFileAccess } from '@/app/api/files/authorization'
import { encodeFilenameForHeader } from '@/app/api/files/utils'
@@ -149,30 +150,18 @@ export const GET = withRouteHandler(
}
let mdContent = mdBuffer.toString('utf-8')
- const imageIds = extractEmbeddedImageIds(mdContent)
+ // Ids only: a serve-URL embed names a storage key, which the bundler has no id to rewrite the
+ // markdown against, so those images stay pointed at their original URL.
+ const { ids: imageIds } = extractEmbeddedFileRefs(mdContent)
logger.info('Exporting markdown', { id, imageCount: imageIds.length })
- if (imageIds.length === 0) {
- const mdName = safeFilename(record.originalName)
- const mdBytes = Buffer.from(mdContent, 'utf-8')
- auditExport('markdown', 0)
- return new NextResponse(new Uint8Array(mdBytes), {
- status: 200,
- headers: {
- 'Content-Type': 'text/markdown; charset=utf-8',
- 'Content-Disposition': `attachment; ${encodeFilenameForHeader(mdName)}`,
- 'Content-Length': String(mdBytes.length),
- },
- })
- }
-
// Metadata first: declared sizes bound the download before a byte is read, and the
// authorization check costs nothing to run here.
const assetTargets = (
await mapWithConcurrency(imageIds, MATERIALIZE_CONCURRENCY, async (imageId) => {
try {
- const imgRecord = await getFileMetadataById(imageId)
+ const imgRecord = await getFileMetadataById(storedFileId(imageId))
if (!imgRecord) return null
if (!(await verifyFileAccess(imgRecord.key, userId))) return null
return { imageId, record: imgRecord }
@@ -234,6 +223,21 @@ export const GET = withRouteHandler(
assetMap.set(imageId, { filename, buffer })
}
+ // Format follows what was bundled, not what was referenced: an embed can point at a file that is
+ // missing, unreadable, or oversized, and an empty `assets/` zip is a worse answer than the
+ // document itself. `mdContent` is still unrewritten here, so `mdBuffer` holds exactly its bytes.
+ if (assetMap.size === 0) {
+ auditExport('markdown', 0)
+ return new NextResponse(new Uint8Array(mdBuffer), {
+ status: 200,
+ headers: {
+ 'Content-Type': 'text/markdown; charset=utf-8',
+ 'Content-Disposition': `attachment; ${encodeFilenameForHeader(safeFilename(record.originalName))}`,
+ 'Content-Length': String(mdBuffer.length),
+ },
+ })
+ }
+
for (const [imageId, asset] of assetMap) {
const escapedId = imageId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const replacement = `./assets/${asset.filename}`
diff --git a/apps/sim/app/api/files/public/[token]/inline/route.test.ts b/apps/sim/app/api/files/public/[token]/inline/route.test.ts
index 3f2b654bda0..5d3e7871d06 100644
--- a/apps/sim/app/api/files/public/[token]/inline/route.test.ts
+++ b/apps/sim/app/api/files/public/[token]/inline/route.test.ts
@@ -67,6 +67,15 @@ describe('GET /api/files/public/[token]/inline', () => {
expect(res.headers.get('content-type')).toBe('image/png')
})
+ it('serves an image whose id is percent-encoded in the document', async () => {
+ mockDownloadFile.mockImplementation(downloadByKey(''))
+
+ const res = await GET(req('fileId=wf%5Fabc'), params)
+
+ expect(res.status).toBe(200)
+ expect(mockResolveImage).toHaveBeenCalledWith('ws-1', { fileId: 'wf_abc' })
+ })
+
it('serves a key-referenced image', async () => {
mockDownloadFile.mockImplementation(
downloadByKey(`}?context=workspace)`)
diff --git a/apps/sim/app/api/files/public/[token]/inline/route.ts b/apps/sim/app/api/files/public/[token]/inline/route.ts
index a777b953ba5..80926733a67 100644
--- a/apps/sim/app/api/files/public/[token]/inline/route.ts
+++ b/apps/sim/app/api/files/public/[token]/inline/route.ts
@@ -4,17 +4,15 @@ import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { getPublicInlineFileContract } from '@/lib/api/contracts/public-shares'
import { parseRequest } from '@/lib/api/server'
-import {
- extractEmbeddedImageIds,
- extractEmbeddedImageKeys,
-} from '@/lib/copilot/tools/server/files/embedded-image-refs'
import { validateDeploymentAuth } from '@/lib/core/security/deployment-auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit'
import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'
import { downloadFile } from '@/lib/uploads/core/storage-service'
+import { extractEmbeddedFileRefs } from '@/lib/uploads/server/embedded-image-refs'
import { resolveWorkspaceInlineImage } from '@/lib/uploads/server/inline-image'
+import { storedFileId } from '@/lib/uploads/utils/embedded-image-ref'
import { serveInlineImage } from '@/app/api/files/serve-inline-image'
import { createErrorResponse, FileNotFoundError } from '@/app/api/files/utils'
@@ -29,8 +27,9 @@ const logger = createLogger('PublicInlineFileAPI')
* instead of broken icons. The share grants the document bytes; this route extends that grant to the
* document's referenced images only, behind three gates that together hold the security boundary:
*
- * 1. Referenced-by-doc — the requested key/id must appear in the shared document's current bytes. The
- * token is a capability for the document and its embeds, never an arbitrary workspace file.
+ * 1. Referenced-by-doc — the requested key/id must be embedded as an image by the shared document's
+ * current bytes. The token is a capability for the document and its embeds, never an arbitrary
+ * workspace file, and never one the document merely links to or mentions in prose.
* 2. Same-workspace — the referenced file must be a `workspace` file in the document's own workspace
* ({@link resolveWorkspaceInlineImage}). This blocks any cross-workspace reference (which an author
* can write but must never resolve) from loading.
@@ -74,9 +73,10 @@ export const GET = withRouteHandler(
// Referenced-by-doc gate: the share grants exactly the images the document embeds.
const docText = (await downloadFile({ key: doc.key, context: 'workspace' })).toString('utf-8')
+ const { keys, ids } = extractEmbeddedFileRefs(docText)
const referenced = ref.fileId
- ? extractEmbeddedImageIds(docText).includes(ref.fileId)
- : extractEmbeddedImageKeys(docText).includes(ref.key as string)
+ ? ids.some((id) => storedFileId(id) === ref.fileId)
+ : keys.includes(ref.key as string)
if (!referenced) {
throw new FileNotFoundError('Not found')
}
diff --git a/apps/sim/app/api/function/execute/route.test.ts b/apps/sim/app/api/function/execute/route.test.ts
index 14da14675fc..19d4f5e7000 100644
--- a/apps/sim/app/api/function/execute/route.test.ts
+++ b/apps/sim/app/api/function/execute/route.test.ts
@@ -865,6 +865,114 @@ describe('Function Execute API Route', () => {
)
})
+ it('classifies exports exact-empty when the only compiled secret is exempt, still reporting its name', async () => {
+ envFlagsMock.isRemoteSandboxEnabled = true
+ mockExecuteInSandbox.mockResolvedValueOnce({
+ result: 'done',
+ stdout: '',
+ sandboxId: 'sandbox-123',
+ exportedFiles: {
+ '/home/user/secret.txt': 'Bearer secret-value',
+ '/home/user/small.jpg': '/9j/4AAQ',
+ },
+ })
+
+ const response = await POST(
+ createMockRequest(
+ 'POST',
+ {
+ code: 'print("{{API_KEY}}")',
+ language: 'python',
+ workspaceId: 'workspace-1',
+ envVars: { API_KEY: 'secret-value' },
+ unredactedSecretNames: ['API_KEY'],
+ outputs: {
+ files: [
+ {
+ path: 'files/secret.txt',
+ sandboxPath: '/home/user/secret.txt',
+ mimeType: 'text/plain',
+ },
+ {
+ path: 'files/small.jpg',
+ sandboxPath: '/home/user/small.jpg',
+ mimeType: 'image/jpeg',
+ },
+ ],
+ },
+ },
+ {
+ 'x-sim-request-private-tool-metadata': 'resolved-secret-names-durable-files-v2',
+ }
+ )
+ )
+ const data = await response.json()
+
+ expect(response.status).toBe(200)
+ // The text export carries the exempt plaintext yet records no entry for it.
+ expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith(
+ expect.objectContaining({
+ target: expect.objectContaining({ path: 'files/secret.txt' }),
+ secretProvenance: { status: 'exact', entries: [] },
+ })
+ )
+ // With only exempt material in scope the binary export must not lock as unknown.
+ expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith(
+ expect.objectContaining({
+ target: expect.objectContaining({ path: 'files/small.jpg' }),
+ secretProvenance: { status: 'exact', entries: [] },
+ })
+ )
+ // The exemption changes file classification only — the usage trail still sees the name.
+ expect(data.__resolvedSecretNames).toEqual(['API_KEY'])
+ })
+
+ it('keeps recording the non-exempt owner when an exempt name shares its plaintext', async () => {
+ envFlagsMock.isRemoteSandboxEnabled = true
+ mockExecuteInSandbox.mockResolvedValueOnce({
+ result: 'done',
+ stdout: '',
+ sandboxId: 'sandbox-123',
+ exportedFiles: { '/home/user/secret.txt': 'Bearer shared-value' },
+ })
+
+ const response = await POST(
+ createMockRequest('POST', {
+ code: 'print("{{EXEMPT_KEY}}", "{{OTHER_KEY}}")',
+ language: 'python',
+ workspaceId: 'workspace-1',
+ envVars: { EXEMPT_KEY: 'shared-value', OTHER_KEY: 'shared-value' },
+ unredactedSecretNames: ['EXEMPT_KEY'],
+ outputs: {
+ files: [
+ {
+ path: 'files/secret.txt',
+ sandboxPath: '/home/user/secret.txt',
+ mimeType: 'text/plain',
+ },
+ ],
+ },
+ })
+ )
+
+ expect(response.status).toBe(200)
+ expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith(
+ expect.objectContaining({
+ secretProvenance: {
+ status: 'exact',
+ entries: [
+ {
+ name: 'OTHER_KEY',
+ encryptedValue: 'encrypted:shared-value',
+ sourceUserId: 'user-123',
+ sourceWorkspaceId: 'workspace-1',
+ },
+ ],
+ },
+ })
+ )
+ })
+
it('classifies text exports against private mounted-file provenance', async () => {
envFlagsMock.isRemoteSandboxEnabled = true
mockExecuteInSandbox.mockResolvedValueOnce({
diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts
index f1903fd8801..770be5744f8 100644
--- a/apps/sim/app/api/function/execute/route.ts
+++ b/apps/sim/app/api/function/execute/route.ts
@@ -991,6 +991,13 @@ interface FunctionRouteExecutionContext {
outputSecretMatcher?: ResolvedSecretMatcher
outputSecretNamesByScanLiteral: Map
outputSecretPlaintextsByName: Map
+ /**
+ * In-scope names the caller's registry certified as redaction-exempt. They stay in
+ * `outputSecretPlaintextsByName` — the response's resolved-name reporting and the usage
+ * trail must not lose them — but contribute no scan literals, so exported files carrying
+ * only their values classify exact-empty instead of locking.
+ */
+ unredactedSecretNames: Set
mountedFileSecretProvenanceScanner?: MountedFileSecretProvenanceScanner
}
@@ -1191,13 +1198,23 @@ function activateReferencedSecretProvenance(context: FunctionRouteExecutionConte
}
}
+/** Compiled secret names that still demand redaction — the exempt ones don't count. */
+function countProtectedOutputSecretNames(context: FunctionRouteExecutionContext): number {
+ let count = 0
+ for (const name of context.outputSecretPlaintextsByName.keys()) {
+ if (!context.unredactedSecretNames.has(name)) count += 1
+ }
+ return count
+}
+
/**
* True when this execution compiled a secret placeholder or received a mounted file with verified
* secret provenance. Ordinary mounts without a provenance envelope are user data, not evidence that
- * a Sim secret was resolved in this call.
+ * a Sim secret was resolved in this call. Exempt names don't count: a binary export whose only
+ * in-scope secrets are redaction-exempt is deliberately classified exact-empty rather than locked.
*/
function hasSecretMaterialInScope(context: FunctionRouteExecutionContext): boolean {
- if (context.outputSecretPlaintextsByName.size > 0) return true
+ if (countProtectedOutputSecretNames(context) > 0) return true
return context.mountedFileSecretProvenanceScanner?.hasSecrets ?? false
}
@@ -1225,7 +1242,7 @@ async function getOutputFileSecretProvenance(
status: 'exact' as const,
entries: [],
}
- if (context.outputSecretPlaintextsByName.size === 0) {
+ if (countProtectedOutputSecretNames(context) === 0) {
return mountedFileProvenance
}
if (!context.outputSecretMatcher) return { status: 'unknown' }
@@ -1914,6 +1931,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
envVars: rawEnvVars = {},
secretScope,
mountedSecrets,
+ unredactedSecretNames = [],
sandboxId: selectedSandboxId,
blockData = {},
blockNameMapping = {},
@@ -2035,6 +2053,9 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
privateResolvedSecretNamesMetadataType,
outputSecretNamesByScanLiteral: new Map(),
outputSecretPlaintextsByName: new Map(),
+ unredactedSecretNames: new Set(
+ unredactedSecretNames.filter((name) => Object.hasOwn(envVars, name))
+ ),
mountedFileSecretProvenanceScanner,
}
@@ -2069,6 +2090,12 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
const plaintext = envVars[name]
if (!plaintext) continue
routeContext.outputSecretPlaintextsByName.set(name, plaintext)
+ /**
+ * Skipped per NAME, never per literal: a plaintext shared by an exempt and a non-exempt
+ * name keeps its literal through the non-exempt owner, so the export still records that
+ * owner's provenance and the file still locks.
+ */
+ if (routeContext.unredactedSecretNames.has(name)) continue
const scanLiterals = new Set([plaintext, JSON.stringify(plaintext).slice(1, -1)])
for (const scanLiteral of scanLiterals) {
const names = routeContext.outputSecretNamesByScanLiteral.get(scanLiteral) ?? []
diff --git a/apps/sim/app/api/knowledge/secret-provenance.ts b/apps/sim/app/api/knowledge/secret-provenance.ts
index 48a0ff71496..27210da78ba 100644
--- a/apps/sim/app/api/knowledge/secret-provenance.ts
+++ b/apps/sim/app/api/knowledge/secret-provenance.ts
@@ -248,6 +248,8 @@ export async function finalizeKnowledgePersistedResponse(options: {
registry,
documents: options.documents,
chunks: options.chunks,
+ ...(options.workspaceId ? { workspaceId: options.workspaceId } : {}),
+ actorUserId: options.userId,
})
return finalizeKnowledgeRegistryResponse({
request: options.request,
diff --git a/apps/sim/app/api/logs/export/route.test.ts b/apps/sim/app/api/logs/export/route.test.ts
new file mode 100644
index 00000000000..ab31532d74a
--- /dev/null
+++ b/apps/sim/app/api/logs/export/route.test.ts
@@ -0,0 +1,239 @@
+/**
+ * @vitest-environment node
+ */
+import { workflowExecutionLogs } from '@sim/db/schema'
+import {
+ authMockFns,
+ createMockRequest,
+ dbChainMockFns,
+ queueTableRows,
+ resetDbChainMock,
+} from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockCheckWorkspaceAccess,
+ mockExpandFolderIdsWithDescendants,
+ mockMapWithConcurrency,
+ mockMaterializeExecutionDataForDisplay,
+} = vi.hoisted(() => ({
+ mockCheckWorkspaceAccess: vi.fn(),
+ mockExpandFolderIdsWithDescendants: vi.fn(),
+ mockMapWithConcurrency: vi.fn(),
+ mockMaterializeExecutionDataForDisplay: vi.fn(),
+}))
+
+vi.mock('@/lib/workspaces/permissions/utils', () => ({
+ checkWorkspaceAccess: mockCheckWorkspaceAccess,
+}))
+
+vi.mock('@/lib/logs/folder-expansion', () => ({
+ expandFolderIdsWithDescendants: mockExpandFolderIdsWithDescendants,
+}))
+
+vi.mock('@/lib/logs/execution/trace-store', () => ({
+ materializeExecutionDataForDisplay: mockMaterializeExecutionDataForDisplay,
+}))
+
+vi.mock('@/lib/core/utils/concurrency', () => ({
+ MATERIALIZE_CONCURRENCY: 20,
+ mapWithConcurrency: mockMapWithConcurrency,
+}))
+
+import { GET } from '@/app/api/logs/export/route'
+
+const mockGetSession = authMockFns.mockGetSession
+const STARTED_AT = new Date('2026-08-23T12:00:00.000Z')
+
+function makeRequest() {
+ return createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ 'http://localhost:3000/api/logs/export?workspaceId=workspace-1'
+ )
+}
+
+function logRow(index: number, overrides: Record = {}) {
+ const startedAt = new Date(STARTED_AT.getTime() - index * 1000)
+ return {
+ id: `log-${index.toString().padStart(4, '0')}`,
+ workflowId: 'workflow-1',
+ executionId: `execution-${index}`,
+ level: 'info',
+ trigger: 'manual',
+ startedAt,
+ startedAtCursor: startedAt.toISOString(),
+ endedAt: new Date(STARTED_AT.getTime() - index * 1000 + 500),
+ totalDurationMs: 500,
+ costTotal: '0.01',
+ executionData: { message: `message-${index}` },
+ workflowName: 'Workflow',
+ ...overrides,
+ }
+}
+
+function flattenConditions(condition: unknown): Array> {
+ if (!condition || typeof condition !== 'object') return []
+ const node = condition as Record
+ if (Array.isArray(node.conditions)) {
+ return node.conditions.flatMap(flattenConditions)
+ }
+ return [node]
+}
+
+describe('GET /api/logs/export', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ resetDbChainMock()
+ mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
+ mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: true })
+ mockExpandFolderIdsWithDescendants.mockImplementation(
+ async (_workspaceId: string, folderIds: string | undefined) => folderIds
+ )
+ mockMaterializeExecutionDataForDisplay.mockImplementation(
+ async (executionData: Record | null | undefined) => executionData ?? {}
+ )
+ mockMapWithConcurrency.mockImplementation(
+ async (
+ items: unknown[],
+ _limit: number,
+ mapper: (item: unknown, index: number) => Promise
+ ) => Promise.all(items.map(mapper))
+ )
+ })
+
+ it('rejects unauthenticated exports before checking workspace access', async () => {
+ mockGetSession.mockResolvedValueOnce(null)
+
+ const response = await GET(makeRequest())
+
+ expect(response.status).toBe(401)
+ expect(mockCheckWorkspaceAccess).not.toHaveBeenCalled()
+ expect(dbChainMockFns.where).not.toHaveBeenCalled()
+ expect(mockMaterializeExecutionDataForDisplay).not.toHaveBeenCalled()
+ })
+
+ it('returns only the CSV header when workspace access is denied', async () => {
+ mockCheckWorkspaceAccess.mockResolvedValueOnce({ hasAccess: false })
+
+ const response = await GET(makeRequest())
+
+ expect(response.status).toBe(200)
+ expect(await response.text()).toBe(
+ 'startedAt,level,workflow,trigger,durationMs,costTotal,workflowId,executionId,message,traceSpans\n'
+ )
+ expect(dbChainMockFns.where).not.toHaveBeenCalled()
+ expect(mockMaterializeExecutionDataForDisplay).not.toHaveBeenCalled()
+ })
+
+ it('materializes bounded chunks while preserving CSV row order', async () => {
+ queueTableRows(
+ workflowExecutionLogs,
+ Array.from({ length: 45 }, (_, index) => logRow(index))
+ )
+
+ const response = await GET(makeRequest())
+ const lines = (await response.text()).trimEnd().split('\n')
+
+ expect(response.status).toBe(200)
+ expect(mockMapWithConcurrency.mock.calls.map(([items]) => items.length)).toEqual([20, 20, 5])
+ expect(lines).toHaveLength(46)
+ expect(lines[1]).toContain('execution-0')
+ expect(lines.at(-1)).toContain('execution-44')
+ })
+
+ it('resumes full pages by startedAt and id without using OFFSET', async () => {
+ const firstPage = Array.from({ length: 100 }, (_, index) => logRow(index))
+ firstPage[99] = logRow(99, { startedAtCursor: '2026-08-23 11:58:21.000123' })
+ const last = firstPage.at(-1)!
+ const secondPage = [
+ logRow(100, {
+ id: 'log-0000-second',
+ startedAt: last.startedAt,
+ startedAtCursor: '2026-08-23 11:58:21.000122',
+ }),
+ ]
+ queueTableRows(workflowExecutionLogs, firstPage)
+ queueTableRows(workflowExecutionLogs, secondPage)
+
+ const response = await GET(makeRequest())
+ const lines = (await response.text()).trimEnd().split('\n')
+
+ expect(lines).toHaveLength(102)
+ expect(dbChainMockFns.offset).not.toHaveBeenCalled()
+ expect(dbChainMockFns.where).toHaveBeenCalledTimes(2)
+ expect(dbChainMockFns.orderBy).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({
+ type: 'desc',
+ column: workflowExecutionLogs.startedAt,
+ }),
+ expect.objectContaining({
+ type: 'desc',
+ column: workflowExecutionLogs.id,
+ })
+ )
+
+ const cursorConditions = flattenConditions(dbChainMockFns.where.mock.calls[1][0])
+ const timestampConditions = cursorConditions.filter(
+ (condition) => condition.left === workflowExecutionLogs.startedAt
+ )
+ expect(timestampConditions.map((condition) => condition.type)).toEqual(['lt', 'eq'])
+ for (const condition of timestampConditions) {
+ expect(condition.right).not.toBeInstanceOf(Date)
+ expect(condition.right).toEqual(
+ expect.objectContaining({ values: expect.arrayContaining([last.startedAtCursor]) })
+ )
+ }
+ expect(cursorConditions).toContainEqual(
+ expect.objectContaining({
+ type: 'lt',
+ left: workflowExecutionLogs.id,
+ right: last.id,
+ })
+ )
+ })
+
+ it('does not load the next database page until the current row is consumed', async () => {
+ queueTableRows(
+ workflowExecutionLogs,
+ Array.from({ length: 100 }, (_, index) => logRow(index))
+ )
+ queueTableRows(workflowExecutionLogs, [logRow(1)])
+
+ const response = await GET(makeRequest())
+ const reader = response.body!.getReader()
+
+ await reader.read()
+ expect(dbChainMockFns.where).not.toHaveBeenCalled()
+
+ await reader.read()
+ expect(dbChainMockFns.where).toHaveBeenCalledTimes(1)
+
+ await reader.cancel()
+ expect(dbChainMockFns.where).toHaveBeenCalledTimes(1)
+ })
+
+ it('stops a pending pull cleanly when the reader cancels', async () => {
+ queueTableRows(workflowExecutionLogs, [logRow(0)])
+ let resolveMaterialization: ((value: unknown[]) => void) | undefined
+ mockMapWithConcurrency.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveMaterialization = resolve
+ })
+ )
+
+ const response = await GET(makeRequest())
+ const reader = response.body!.getReader()
+ await reader.read()
+
+ const pendingRead = reader.read()
+ await vi.waitFor(() => expect(mockMapWithConcurrency).toHaveBeenCalledTimes(1))
+ const cancellation = reader.cancel()
+ resolveMaterialization?.([{ message: 'message-0' }])
+
+ await expect(Promise.all([pendingRead, cancellation])).resolves.toBeDefined()
+ })
+})
diff --git a/apps/sim/app/api/logs/export/route.ts b/apps/sim/app/api/logs/export/route.ts
index 781aaaf7c3a..a2819700aed 100644
--- a/apps/sim/app/api/logs/export/route.ts
+++ b/apps/sim/app/api/logs/export/route.ts
@@ -2,7 +2,7 @@ import { dbReplica } from '@sim/db'
import { workflow, workflowExecutionLogs } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
-import { and, desc, eq, sql } from 'drizzle-orm'
+import { and, desc, eq, lt, or, type SQL, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency'
@@ -14,9 +14,25 @@ import { expandFolderIdsWithDescendants } from '@/lib/logs/folder-expansion'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('LogsExportAPI')
+const LOG_EXPORT_PAGE_SIZE = 100
export const revalidate = 0
+interface LogExportRow {
+ id: string
+ workflowId: string | null
+ executionId: string
+ level: string
+ trigger: string
+ startedAt: Date
+ startedAtCursor: string
+ endedAt: Date | null
+ totalDurationMs: number | null
+ costTotal: string | null
+ executionData: unknown
+ workflowName: string
+}
+
export const GET = withRouteHandler(async (request: NextRequest) => {
try {
const session = await getSession()
@@ -35,6 +51,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
level: workflowExecutionLogs.level,
trigger: workflowExecutionLogs.trigger,
startedAt: workflowExecutionLogs.startedAt,
+ startedAtCursor: sql`${workflowExecutionLogs.startedAt}::text`,
endedAt: workflowExecutionLogs.endedAt,
totalDurationMs: workflowExecutionLogs.totalDurationMs,
costTotal: workflowExecutionLogs.costTotal,
@@ -78,97 +95,118 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
}
const encoder = new TextEncoder()
- const stream = new ReadableStream({
- start: async (controller) => {
- controller.enqueue(encoder.encode(`${header}\n`))
- const pageSize = 1000
- let offset = 0
- try {
- while (true) {
- const rows = await dbReplica
- .select(selectColumns)
- .from(workflowExecutionLogs)
- .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
- .where(conditions)
- .orderBy(desc(workflowExecutionLogs.startedAt))
- .limit(pageSize)
- .offset(offset)
-
- if (!rows.length) break
-
- // Heavy execution data may live in object storage; materialize per
- // row with bounded concurrency so a 1000-row page doesn't fan out
- // into 1000 simultaneous reads.
- const materialized = await mapWithConcurrency(
- rows as any[],
- MATERIALIZE_CONCURRENCY,
- (r) =>
- materializeExecutionDataForDisplay(
- r.executionData as Record | null,
- {
- workspaceId: params.workspaceId,
- workflowId: r.workflowId,
- executionId: r.executionId,
- userId: session.user.id,
- }
- )
+ const csvChunks = (async function* () {
+ yield encoder.encode(`${header}\n`)
+ const pageSize = LOG_EXPORT_PAGE_SIZE
+ let cursor: { startedAt: string; id: string } | null = null
+ while (true) {
+ const cursorCondition: SQL | undefined = cursor
+ ? or(
+ lt(workflowExecutionLogs.startedAt, sql`${cursor.startedAt}::timestamp`),
+ and(
+ eq(workflowExecutionLogs.startedAt, sql`${cursor.startedAt}::timestamp`),
+ lt(workflowExecutionLogs.id, cursor.id)
+ )
)
-
- for (let j = 0; j < rows.length; j++) {
- const r = rows[j] as any
- const ed = materialized[j] as Record
- // A single malformed/unserializable row must not abort the whole CSV
- // stream — derive the message/trace columns defensively and fall back
- // to empty on error so the row's metadata still exports.
- let message = ''
- let tracesJson = ''
- try {
- if (ed) {
- if (ed.finalOutput)
- message =
- typeof ed.finalOutput === 'string'
- ? ed.finalOutput
- : JSON.stringify(ed.finalOutput)
- if (ed.message) message = ed.message
- if (ed.traceSpans) tracesJson = JSON.stringify(ed.traceSpans)
- }
- } catch (rowError) {
- logger.warn('Skipping unserializable execution data for export row', {
- executionId: r.executionId,
- error: getErrorMessage(rowError),
- })
+ : undefined
+ const rows: LogExportRow[] = await dbReplica
+ .select(selectColumns)
+ .from(workflowExecutionLogs)
+ .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
+ .where(and(conditions, cursorCondition))
+ .orderBy(desc(workflowExecutionLogs.startedAt), desc(workflowExecutionLogs.id))
+ .limit(pageSize)
+
+ if (!rows.length) break
+
+ for (let chunkStart = 0; chunkStart < rows.length; chunkStart += MATERIALIZE_CONCURRENCY) {
+ const chunk = rows.slice(chunkStart, chunkStart + MATERIALIZE_CONCURRENCY)
+ const materialized = await mapWithConcurrency(chunk, MATERIALIZE_CONCURRENCY, (row) =>
+ materializeExecutionDataForDisplay(
+ row.executionData as Record | null,
+ {
+ workspaceId: params.workspaceId,
+ workflowId: row.workflowId,
+ executionId: row.executionId,
+ userId: session.user.id,
+ }
+ )
+ )
+
+ for (let index = 0; index < chunk.length; index++) {
+ const row = chunk[index]
+ const executionData = materialized[index]
+ let message: unknown = ''
+ let tracesJson = ''
+ try {
+ if (executionData.finalOutput) {
+ message =
+ typeof executionData.finalOutput === 'string'
+ ? executionData.finalOutput
+ : (JSON.stringify(executionData.finalOutput) ?? '')
}
- const line = toCsvRow([
- formatCsvValue(r.startedAt?.toISOString?.() || r.startedAt),
- formatCsvValue(r.level),
- formatCsvValue(r.workflowName),
- formatCsvValue(r.trigger),
- formatCsvValue(r.totalDurationMs ?? ''),
- formatCsvValue(r.costTotal ?? ''),
- formatCsvValue(r.workflowId ?? ''),
- formatCsvValue(r.executionId ?? ''),
- formatCsvValue(message),
- formatCsvValue(tracesJson),
- ])
- controller.enqueue(encoder.encode(`${line}\n`))
+ if (executionData.message) message = executionData.message
+ if (executionData.traceSpans) {
+ tracesJson = JSON.stringify(executionData.traceSpans) ?? ''
+ }
+ } catch (rowError) {
+ logger.warn('Skipping unserializable execution data for export row', {
+ executionId: row.executionId,
+ error: getErrorMessage(rowError),
+ })
}
-
- offset += pageSize
+ const line = toCsvRow([
+ formatCsvValue(row.startedAt),
+ formatCsvValue(row.level),
+ formatCsvValue(row.workflowName),
+ formatCsvValue(row.trigger),
+ formatCsvValue(row.totalDurationMs ?? ''),
+ formatCsvValue(row.costTotal ?? ''),
+ formatCsvValue(row.workflowId ?? ''),
+ formatCsvValue(row.executionId),
+ formatCsvValue(message),
+ formatCsvValue(tracesJson),
+ ])
+ yield encoder.encode(`${line}\n`)
}
- controller.close()
- } catch (e: any) {
- logger.error('Export stream error', { error: e?.message })
- try {
- controller.error(e)
- } catch {}
}
+
+ const last = rows.at(-1)
+ if (!last || rows.length < pageSize) break
+ cursor = { startedAt: last.startedAtCursor, id: last.id }
+ }
+ })()
+
+ let cancelled = false
+ const stream = new ReadableStream(
+ {
+ pull: async (controller) => {
+ try {
+ const next = await csvChunks.next()
+ if (cancelled) return
+ if (next.done) {
+ controller.close()
+ return
+ }
+ controller.enqueue(next.value)
+ } catch (error) {
+ if (cancelled) return
+ logger.error('Export stream error', { error: getErrorMessage(error) })
+ controller.error(error)
+ }
+ },
+ cancel: async () => {
+ cancelled = true
+ await csvChunks.return(undefined)
+ },
},
- })
+ { highWaterMark: 0 }
+ )
const ts = new Date().toISOString().replace(/[:.]/g, '-')
const filename = `logs-${ts}.csv`
- return new NextResponse(stream as any, {
+ return new NextResponse(stream, {
status: 200,
headers: {
'Content-Type': 'text/csv; charset=utf-8',
@@ -176,8 +214,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
'Cache-Control': 'no-cache',
},
})
- } catch (error: any) {
- logger.error('Export error', { error: error?.message })
+ } catch (error) {
+ logger.error('Export error', { error: getErrorMessage(error) })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
diff --git a/apps/sim/app/api/memory/secret-provenance.test.ts b/apps/sim/app/api/memory/secret-provenance.test.ts
index 866c9474af6..8132ed5e5ad 100644
--- a/apps/sim/app/api/memory/secret-provenance.test.ts
+++ b/apps/sim/app/api/memory/secret-provenance.test.ts
@@ -18,6 +18,7 @@ vi.mock('@/lib/execution/durable-secret-provenance-enforcement', () => ({
reportUnrecordedDurableProvenance: mockReport,
}))
+import { memoryListQuerySchema } from '@/lib/api/contracts/memory'
import { AuthType } from '@/lib/auth/hybrid'
import {
PRIVATE_SECRET_PROVENANCE_BUNDLE_V1,
@@ -341,3 +342,11 @@ describe('memory write secret provenance', () => {
expect(mockReport).not.toHaveBeenCalled()
})
})
+
+describe('memory list query contract', () => {
+ it('rejects a limit past the page ceiling and keeps the default below it', () => {
+ expect(memoryListQuerySchema.safeParse({ limit: '2000' }).success).toBe(false)
+ expect(memoryListQuerySchema.parse({})).toMatchObject({ limit: 50 })
+ expect(memoryListQuerySchema.parse({ limit: '1000' })).toMatchObject({ limit: 1000 })
+ })
+})
diff --git a/apps/sim/app/api/providers/vllm/models/route.test.ts b/apps/sim/app/api/providers/vllm/models/route.test.ts
new file mode 100644
index 00000000000..48cc94eb65d
--- /dev/null
+++ b/apps/sim/app/api/providers/vllm/models/route.test.ts
@@ -0,0 +1,74 @@
+/**
+ * @vitest-environment node
+ */
+import { createMockRequest, resetEnvMock, setEnv } from '@sim/testing'
+import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockFetch, mockFilterBlacklistedModels, mockIsProviderBlacklisted } = vi.hoisted(() => ({
+ mockFetch: vi.fn(),
+ mockFilterBlacklistedModels: vi.fn((models: string[]) => models),
+ mockIsProviderBlacklisted: vi.fn(() => false),
+}))
+
+vi.mock('@/providers/utils', () => ({
+ filterBlacklistedModels: mockFilterBlacklistedModels,
+ isProviderBlacklisted: mockIsProviderBlacklisted,
+}))
+
+import { GET } from '@/app/api/providers/vllm/models/route'
+
+const request = () => createMockRequest('GET')
+
+describe('vLLM models route', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockFilterBlacklistedModels.mockImplementation((models: string[]) => models)
+ mockIsProviderBlacklisted.mockReturnValue(false)
+ mockFetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({ data: [{ id: 'local-model' }] }),
+ })
+ vi.stubGlobal('fetch', mockFetch)
+ setEnv({ VLLM_BASE_URL: 'http://localhost:8000', VLLM_API_KEY: undefined })
+ })
+
+ afterAll(() => {
+ vi.unstubAllGlobals()
+ resetEnvMock()
+ })
+
+ it('discovers and prefixes models from a server-root URL', async () => {
+ const response = await GET(request())
+
+ await expect(response.json()).resolves.toEqual({ models: ['vllm/local-model'] })
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'http://localhost:8000/v1/models',
+ expect.objectContaining({ headers: { 'Content-Type': 'application/json' } })
+ )
+ })
+
+ it('uses an existing /v1 prefix once and forwards bearer authentication', async () => {
+ setEnv({ VLLM_BASE_URL: 'http://localhost:1234/v1', VLLM_API_KEY: 'lm-token' })
+
+ await GET(request())
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'http://localhost:1234/v1/models',
+ expect.objectContaining({
+ headers: {
+ Authorization: 'Bearer lm-token',
+ 'Content-Type': 'application/json',
+ },
+ })
+ )
+ })
+
+ it('returns an empty model list when the configured base URL is unsupported', async () => {
+ setEnv({ VLLM_BASE_URL: 'http://localhost:1234?token=value' })
+
+ const response = await GET(request())
+
+ await expect(response.json()).resolves.toEqual({ models: [] })
+ expect(mockFetch).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/api/providers/vllm/models/route.ts b/apps/sim/app/api/providers/vllm/models/route.ts
index 6e2e167220e..05939b4862c 100644
--- a/apps/sim/app/api/providers/vllm/models/route.ts
+++ b/apps/sim/app/api/providers/vllm/models/route.ts
@@ -7,6 +7,7 @@ import {
} from '@/lib/api/contracts/providers'
import { env } from '@/lib/core/config/env'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { getOpenAICompatibleApiBaseUrl } from '@/providers/openai-compat/base-url'
import { filterBlacklistedModels, isProviderBlacklisted } from '@/providers/utils'
const logger = createLogger('VLLMModelsAPI')
@@ -20,7 +21,7 @@ export const GET = withRouteHandler(async (_request: NextRequest) => {
return NextResponse.json({ models: [] })
}
- const baseUrl = (env.VLLM_BASE_URL || '').replace(/\/$/, '')
+ const baseUrl = env.VLLM_BASE_URL?.trim()
if (!baseUrl) {
logger.info('VLLM_BASE_URL not configured')
@@ -28,6 +29,7 @@ export const GET = withRouteHandler(async (_request: NextRequest) => {
}
try {
+ const apiBaseUrl = getOpenAICompatibleApiBaseUrl(baseUrl)
logger.info('Fetching vLLM models', {
baseUrl,
})
@@ -40,7 +42,7 @@ export const GET = withRouteHandler(async (_request: NextRequest) => {
headers.Authorization = `Bearer ${env.VLLM_API_KEY}`
}
- const response = await fetch(`${baseUrl}/v1/models`, {
+ const response = await fetch(`${apiBaseUrl}/models`, {
headers,
next: { revalidate: 60 },
})
diff --git a/apps/sim/app/api/users/me/settings/route.test.ts b/apps/sim/app/api/users/me/settings/route.test.ts
new file mode 100644
index 00000000000..f11f71ff96f
--- /dev/null
+++ b/apps/sim/app/api/users/me/settings/route.test.ts
@@ -0,0 +1,48 @@
+/**
+ * @vitest-environment node
+ */
+import { createMockRequest, dbChainMockFns, resetDbChainMock } from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockGetSession } = vi.hoisted(() => ({
+ mockGetSession: vi.fn(),
+}))
+
+vi.mock('@/lib/auth', () => ({
+ auth: { api: { getSession: vi.fn() } },
+ getSession: mockGetSession,
+}))
+
+import { PATCH } from '@/app/api/users/me/settings/route'
+
+describe('PATCH /api/users/me/settings', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ resetDbChainMock()
+ mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
+ })
+
+ it('reports success when the write lands', async () => {
+ const response = await PATCH(createMockRequest('PATCH', { theme: 'dark' }))
+
+ expect(response.status).toBe(200)
+ expect(await response.json()).toEqual({ success: true })
+ })
+
+ /**
+ * The regression this guards: the catch answered `{ success: true }` with 200, so
+ * `useUpdateGeneralSetting`'s optimistic rollback in `onError` could never run —
+ * a failed write showed as applied until the next refetch, including for
+ * consent-shaped settings the user believes they changed.
+ */
+ it('reports failure when the write throws', async () => {
+ dbChainMockFns.insert.mockImplementationOnce(() => {
+ throw new Error('connection terminated unexpectedly')
+ })
+
+ const response = await PATCH(createMockRequest('PATCH', { theme: 'dark' }))
+
+ expect(response.status).toBe(500)
+ expect(await response.json()).not.toMatchObject({ success: true })
+ })
+})
diff --git a/apps/sim/app/api/users/me/settings/route.ts b/apps/sim/app/api/users/me/settings/route.ts
index 24ccacceb62..69e06dd689c 100644
--- a/apps/sim/app/api/users/me/settings/route.ts
+++ b/apps/sim/app/api/users/me/settings/route.ts
@@ -74,6 +74,10 @@ export const PATCH = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json({ success: true }, { status: 200 })
} catch (error: any) {
logger.error(`[${requestId}] Settings update error`, error)
- return NextResponse.json({ success: true }, { status: 200 })
+ /* The client mutation is optimistic: it writes the new value into the cache in
+ `onMutate` and restores it in `onError`. Answering 200 here left that rollback
+ unreachable, so a failed write showed as applied until the next refetch —
+ including for consent-shaped settings the user believes they changed. */
+ return NextResponse.json({ error: 'Failed to update settings' }, { status: 500 })
}
})
diff --git a/apps/sim/app/api/v1/admin/organizations/route.ts b/apps/sim/app/api/v1/admin/organizations/route.ts
index 26a2a652868..17987e13fb6 100644
--- a/apps/sim/app/api/v1/admin/organizations/route.ts
+++ b/apps/sim/app/api/v1/admin/organizations/route.ts
@@ -25,6 +25,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db, dbReplica } from '@sim/db'
import { member, organization, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
+import { slugify } from '@sim/utils/string'
import { count, eq } from 'drizzle-orm'
import {
adminV1CreateOrganizationContract,
@@ -142,12 +143,7 @@ export const POST = withRouteHandler(
)
}
- const slug =
- requestedSlug?.trim() ||
- name
- .toLowerCase()
- .replace(/[^a-z0-9]+/g, '-')
- .replace(/^-|-$/g, '')
+ const slug = requestedSlug?.trim() || slugify(name)
const { organizationId, memberId } = await createOrganizationWithOwner({
ownerUserId: ownerId,
diff --git a/apps/sim/app/api/v2/secrets/[name]/route.test.ts b/apps/sim/app/api/v2/secrets/[name]/route.test.ts
index 2d47fb464de..914f853eeab 100644
--- a/apps/sim/app/api/v2/secrets/[name]/route.test.ts
+++ b/apps/sim/app/api/v2/secrets/[name]/route.test.ts
@@ -83,6 +83,7 @@ const secret = {
updatedAt: new Date('2026-01-02T00:00:00Z'),
hasServiceAccountKey: false,
role: 'admin' as const,
+ unredacted: false,
}
const context = { params: Promise.resolve({ name: SECRET_NAME }) }
diff --git a/apps/sim/app/api/v2/secrets/route.test.ts b/apps/sim/app/api/v2/secrets/route.test.ts
index 7de65d992ba..e9de08c9293 100644
--- a/apps/sim/app/api/v2/secrets/route.test.ts
+++ b/apps/sim/app/api/v2/secrets/route.test.ts
@@ -81,6 +81,7 @@ const secret = {
updatedAt: new Date('2026-01-02T00:00:00Z'),
hasServiceAccountKey: false,
role: 'admin' as const,
+ unredacted: false,
}
describe('GET /api/v2/secrets', () => {
@@ -92,6 +93,7 @@ describe('GET /api/v2/secrets', () => {
mocks.gate.mockResolvedValue(null)
mocks.list.mockResolvedValue({
secrets: [secret],
+ values: {},
userId: 'user-1',
nextCursorKeys: null,
sortBy: 'name',
@@ -114,6 +116,7 @@ describe('GET /api/v2/secrets', () => {
name: 'STRIPE_API_KEY',
scope: 'workspace',
description: null,
+ unredacted: false,
role: 'admin',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-02T00:00:00.000Z',
@@ -121,7 +124,7 @@ describe('GET /api/v2/secrets', () => {
],
nextCursor: null,
})
- expect(JSON.stringify(body)).not.toContain('value')
+ expect(JSON.stringify(body)).not.toContain('"value"')
expect(mocks.list).toHaveBeenCalledWith({
principal: PRINCIPAL,
input: {
@@ -138,6 +141,72 @@ describe('GET /api/v2/secrets', () => {
})
})
+ it('carries the stored value for exactly the rows marked visible', async () => {
+ mocks.list.mockResolvedValue({
+ secrets: [
+ secret,
+ {
+ ...secret,
+ id: 'secret-3',
+ displayName: 'STAGING_BASE_URL',
+ envKey: 'STAGING_BASE_URL',
+ unredacted: true,
+ },
+ ],
+ values: { STAGING_BASE_URL: 'https://staging.example.com' },
+ userId: 'user-1',
+ nextCursorKeys: null,
+ sortBy: 'name',
+ sortOrder: 'asc',
+ })
+
+ const response = await GET(
+ new NextRequest(`http://localhost:3000/api/v2/secrets?workspaceId=${WORKSPACE_ID}`, {
+ headers: { 'x-api-key': 'key' },
+ })
+ )
+ const body = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(body.data[0]).not.toHaveProperty('value')
+ expect(body.data[1]).toMatchObject({
+ name: 'STAGING_BASE_URL',
+ unredacted: true,
+ value: 'https://staging.example.com',
+ })
+ })
+
+ it('never attaches an inherited prototype member as a missing value', async () => {
+ mocks.list.mockResolvedValue({
+ secrets: [
+ {
+ ...secret,
+ id: 'secret-proto',
+ displayName: 'constructor',
+ envKey: 'constructor',
+ unredacted: true,
+ },
+ ],
+ /** The name is legal but its value is absent — a bare index would read Object's constructor. */
+ values: {},
+ userId: 'user-1',
+ nextCursorKeys: null,
+ sortBy: 'name',
+ sortOrder: 'asc',
+ })
+
+ const response = await GET(
+ new NextRequest(`http://localhost:3000/api/v2/secrets?workspaceId=${WORKSPACE_ID}`, {
+ headers: { 'x-api-key': 'key' },
+ })
+ )
+ const body = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(body.data[0]).toMatchObject({ name: 'constructor', unredacted: true })
+ expect(body.data[0]).not.toHaveProperty('value')
+ })
+
/**
* Pins the binding end-to-end — the mint in `present` and the read in
* `mapInput` — because the contract-level sweep only checks a hand-maintained
@@ -157,6 +226,7 @@ describe('GET /api/v2/secrets', () => {
description: 'leaked from a workspace mirror',
},
],
+ values: {},
userId: 'user-1',
nextCursorKeys: null,
sortBy: 'name',
@@ -178,6 +248,7 @@ describe('GET /api/v2/secrets', () => {
it('refuses a cursor minted under a different filter', async () => {
mocks.list.mockResolvedValue({
secrets: [secret],
+ values: {},
userId: 'user-1',
nextCursorKeys: ['STRIPE_API_KEY', 'secret-1'],
sortBy: 'name',
@@ -209,6 +280,7 @@ describe('GET /api/v2/secrets', () => {
it('resumes a cursor replayed under the filters it was minted with', async () => {
mocks.list.mockResolvedValue({
secrets: [secret],
+ values: {},
userId: 'user-1',
nextCursorKeys: ['STRIPE_API_KEY', 'secret-1'],
sortBy: 'name',
diff --git a/apps/sim/app/api/v2/secrets/route.ts b/apps/sim/app/api/v2/secrets/route.ts
index bd9a44c1d39..a1562502b41 100644
--- a/apps/sim/app/api/v2/secrets/route.ts
+++ b/apps/sim/app/api/v2/secrets/route.ts
@@ -23,7 +23,7 @@ function secretCursorFilters(query: { workspaceId: string; scope?: string; searc
})
}
-/** GET /api/v2/secrets — List secret names and metadata without reading their values. */
+/** GET /api/v2/secrets — List secret metadata; visible (unredacted) secrets carry their value. */
export const GET = defineV2JsonRoute({
contract: v2ListSecretsContract,
operation: secretOperations.list,
@@ -40,8 +40,19 @@ export const GET = defineV2JsonRoute({
),
}),
useCase: listSecretsUseCase,
- present: ({ secrets, userId, nextCursorKeys }, { query }) => ({
- data: secrets.map((secret) => toV2Secret(secret, userId)),
+ present: ({ secrets, values, userId, nextCursorKeys }, { query }) => ({
+ data: secrets.map((secret) =>
+ toV2Secret(
+ secret,
+ userId,
+ /**
+ * Own-property read: a secret may legally be named `constructor` or `toString`,
+ * and a bare index on a missing key would hand the inherited function to the
+ * serializer and fail response validation for the whole page.
+ */
+ secret.envKey && Object.hasOwn(values, secret.envKey) ? values[secret.envKey] : undefined
+ )
+ ),
nextCursor: writeSortedCursor(
nextCursorKeys,
query.sortBy,
diff --git a/apps/sim/app/api/v2/secrets/utils.ts b/apps/sim/app/api/v2/secrets/utils.ts
index ba2d370c780..cae24c92695 100644
--- a/apps/sim/app/api/v2/secrets/utils.ts
+++ b/apps/sim/app/api/v2/secrets/utils.ts
@@ -1,8 +1,17 @@
-import type { V2Secret } from '@/lib/api/contracts/v2/secrets'
+import type { V2SecretWithValue } from '@/lib/api/contracts/v2/secrets'
import type { VisibleWorkspaceCredential } from '@/lib/credentials/queries'
-/** Serialize environment credential metadata as a secret without exposing its stored value. */
-export function toV2Secret(row: VisibleWorkspaceCredential, userId: string): V2Secret {
+/**
+ * Serialize environment credential metadata as a secret. The stored value is
+ * attached only when supplied AND the row is a workspace secret marked visible
+ * (unredacted) — the guard here, not only at the caller, so no code path can
+ * hand a value to a row whose flag does not disclose it.
+ */
+export function toV2Secret(
+ row: VisibleWorkspaceCredential,
+ userId: string,
+ value?: string
+): V2SecretWithValue {
if (!row.envKey || (row.type !== 'env_workspace' && row.type !== 'env_personal')) {
throw new Error(`Credential ${row.id} is not a secret`)
}
@@ -10,12 +19,15 @@ export function toV2Secret(row: VisibleWorkspaceCredential, userId: string): V2S
throw new Error(`Personal secret ${row.id} is not owned by the caller`)
}
+ const unredacted = row.type === 'env_workspace' ? row.unredacted : false
return {
name: row.envKey,
scope: row.type === 'env_workspace' ? 'workspace' : 'personal',
description: row.type === 'env_workspace' ? row.description : null,
+ unredacted,
role: row.role,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
+ ...(value !== undefined && unredacted ? { value } : {}),
}
}
diff --git a/apps/sim/app/layout.tsx b/apps/sim/app/layout.tsx
index 6a338b43a47..81aed9feb9b 100644
--- a/apps/sim/app/layout.tsx
+++ b/apps/sim/app/layout.tsx
@@ -33,11 +33,21 @@ export const viewport: Viewport = {
export const metadata: Metadata = generateBrandedMetadata()
-const GTM_ID = 'GTM-T7PHSRX5' as const
-const GA_ID = 'G-DR7YBE70VS' as const
-
export default function RootLayout({ children }: { children: React.ReactNode }) {
const themeCSS = generateThemeCSS()
+ const application = (
+
+
+
+
+
+ {children}
+
+
+
+
+
+ )
return (
@@ -226,70 +236,13 @@ export default function RootLayout({ children }: { children: React.ReactNode })
- {/* Google Tag Manager — hosted only */}
- {isHosted && (
-
- )}
-
- {/* Google Analytics (gtag.js) — hosted only */}
- {isHosted && (
- <>
-
-
- >
- )}
-
{isHosted ? : }
- {/* Google Tag Manager (noscript) — hosted only */}
- {isHosted && (
-
-
-
- )}
-
-
-
-
-
- {children}
- {/* Cookie consent — hosted only */}
- {isHosted && }
-
-
-
-
-
+ {isHosted ? {application} : application}
diff --git a/apps/sim/app/workspace/[workspaceId]/chat/[chatId]/loading.tsx b/apps/sim/app/workspace/[workspaceId]/chat/[chatId]/loading.tsx
deleted file mode 100644
index ff1e96e036e..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/chat/[chatId]/loading.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import { HomeFallback } from '@/app/workspace/[workspaceId]/home/home-fallback'
-
-/**
- * Route-level loading boundary for a chat.
- *
- * Its real job is prefetching, not painting. With `cacheComponents` off, a
- * default ` ` prefetch degrades to Next's LoadingBoundary strategy, which
- * prefetches a dynamic route only as far as its nearest `loading` segment — so
- * a route without one is prefetched as nothing, and clicking a chat leaves the
- * previous chat frozen on screen until the server responds. This file is what
- * makes that click commit immediately.
- *
- * Renders the same surface `HomeFallback` gives the Suspense boundary inside
- * the page, so the loading frame and the mounted frame share a background and
- * the transition reads as one step rather than two.
- */
-export default function ChatLoading() {
- return
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.test.tsx
new file mode 100644
index 00000000000..31a82cd10ce
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.test.tsx
@@ -0,0 +1,226 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act, type ReactNode } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({
+ createDraft: vi.fn(),
+ connectOAuthService: vi.fn(),
+ onConnect: vi.fn(),
+}))
+
+vi.mock('@sim/emcn', () => ({
+ Badge: ({ children }: { children?: ReactNode }) => {children} ,
+ ChipModal: ({ open, children }: { open: boolean; children?: ReactNode }) =>
+ open ? {children}
: null,
+ ChipModalBody: ({ children }: { children?: ReactNode }) => {children}
,
+ ChipModalError: ({ children }: { children?: ReactNode }) => {children}
,
+ ChipModalField: ({ title, children }: { title: string; children?: ReactNode }) => (
+
+ ),
+ ChipModalFooter: ({
+ primaryAction,
+ }: {
+ primaryAction: { label: string; onClick: () => void; disabled: boolean }
+ }) => (
+
+ {primaryAction.label}
+
+ ),
+ ChipModalHeader: ({ children }: { children?: ReactNode }) => ,
+ InfoCard: ({ children }: { children?: ReactNode }) => {children}
,
+ InfoCardItem: ({ children }: { children?: ReactNode }) => {children}
,
+ InfoCardList: ({ children }: { children?: ReactNode }) => {children}
,
+}))
+
+vi.mock('@/lib/auth/auth-client', () => ({
+ useSession: () => ({ data: { user: { name: 'Test User' } } }),
+}))
+
+vi.mock('@/lib/credentials/client-state', () => ({
+ ADD_CONNECTOR_SEARCH_PARAM: 'addConnector',
+ writeOAuthReturnContext: vi.fn(),
+}))
+
+vi.mock('@/lib/credentials/display-name', () => ({
+ defaultCredentialDisplayName: () => 'Test credential',
+}))
+
+vi.mock('@/lib/oauth', () => ({
+ getProviderIdFromServiceId: (serviceId: string) => serviceId,
+ OAUTH_PROVIDERS: {
+ slack: {
+ name: 'Slack',
+ icon: null,
+ services: {},
+ },
+ },
+ parseProvider: (provider: string) => ({ baseProvider: provider }),
+}))
+
+vi.mock('@/lib/oauth/utils', () => ({
+ getScopeDescription: (scope: string) => scope,
+ getServiceConfigByProviderId: () => null,
+}))
+
+vi.mock('@/blocks/brand-icon', () => ({
+ withBrandIcon: () => null,
+}))
+
+vi.mock('@/hooks/queries/credentials', () => ({
+ useCreateCredentialDraft: () => ({
+ mutateAsync: mocks.createDraft,
+ isPending: false,
+ }),
+ useWorkspaceCredentials: () => ({
+ data: [],
+ isPending: false,
+ }),
+}))
+
+vi.mock('@/hooks/queries/oauth/oauth-connections', () => ({
+ useConnectOAuthService: () => ({
+ mutateAsync: mocks.connectOAuthService,
+ isPending: false,
+ }),
+}))
+
+import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal'
+
+let container: HTMLDivElement
+let root: Root
+
+function renderReauthorizeModal({
+ reconnectTarget,
+ onConnect,
+}: {
+ reconnectTarget?: {
+ workspaceId: string
+ credentialId: string
+ displayName: string
+ }
+ onConnect?: () => Promise | void
+} = {}) {
+ act(() => {
+ root.render(
+
+ )
+ })
+}
+
+async function clickConnect() {
+ const button = container.querySelector('[data-testid="connect"]')
+ expect(button).not.toBeNull()
+ await act(async () => {
+ button?.click()
+ })
+}
+
+describe('ConnectOAuthModal reauthorization', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.createDraft.mockResolvedValue({ success: true, draftId: 'draft-exact' })
+ mocks.connectOAuthService.mockResolvedValue({ success: true })
+ mocks.onConnect.mockResolvedValue(undefined)
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ })
+
+ afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+ })
+
+ it('binds the selected credential draft to the OAuth launch', async () => {
+ renderReauthorizeModal({
+ reconnectTarget: {
+ workspaceId: 'workspace-1',
+ credentialId: 'credential-slack',
+ displayName: 'Team Slack',
+ },
+ })
+
+ await clickConnect()
+
+ expect(mocks.createDraft).toHaveBeenCalledWith({
+ workspaceId: 'workspace-1',
+ providerId: 'slack',
+ credentialId: 'credential-slack',
+ displayName: 'Team Slack',
+ })
+ expect(mocks.connectOAuthService).toHaveBeenCalledWith({
+ providerId: 'slack',
+ callbackURL: window.location.href,
+ draftId: 'draft-exact',
+ })
+ expect(mocks.createDraft.mock.invocationCallOrder[0]).toBeLessThan(
+ mocks.connectOAuthService.mock.invocationCallOrder[0]
+ )
+ })
+
+ it('does not launch OAuth when the reconnect draft cannot be created', async () => {
+ mocks.createDraft.mockRejectedValue(new Error('Draft creation failed'))
+ renderReauthorizeModal({
+ reconnectTarget: {
+ workspaceId: 'workspace-1',
+ credentialId: 'credential-slack',
+ displayName: 'Team Slack',
+ },
+ })
+
+ await clickConnect()
+
+ expect(mocks.connectOAuthService).not.toHaveBeenCalled()
+ expect(container).toHaveTextContent('Draft creation failed')
+ })
+
+ it('preserves provider-only reauthorization without creating a draft', async () => {
+ renderReauthorizeModal()
+
+ await clickConnect()
+
+ expect(mocks.createDraft).not.toHaveBeenCalled()
+ expect(mocks.connectOAuthService).toHaveBeenCalledWith({
+ providerId: 'slack',
+ callbackURL: window.location.href,
+ draftId: undefined,
+ })
+ })
+
+ it('keeps an onConnect override ahead of credential-bound reauthorization', async () => {
+ renderReauthorizeModal({
+ reconnectTarget: {
+ workspaceId: 'workspace-1',
+ credentialId: 'credential-slack',
+ displayName: 'Team Slack',
+ },
+ onConnect: mocks.onConnect,
+ })
+
+ await clickConnect()
+
+ expect(mocks.onConnect).toHaveBeenCalledOnce()
+ expect(mocks.createDraft).not.toHaveBeenCalled()
+ expect(mocks.connectOAuthService).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx
index d18cbf39970..552637626c8 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx
@@ -112,6 +112,11 @@ interface ConnectOAuthModalReauthorizeProps extends ConnectOAuthModalBaseProps {
toolName: string
requiredScopes?: readonly string[]
newScopes?: readonly string[]
+ reconnectTarget?: {
+ workspaceId: string
+ credentialId: string
+ displayName: string
+ }
onConnect?: () => Promise | void
}
@@ -316,6 +321,16 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
handleClose()
return
} else {
+ if (props.reconnectTarget) {
+ const draft = await createDraft.mutateAsync({
+ workspaceId: props.reconnectTarget.workspaceId,
+ providerId,
+ credentialId: props.reconnectTarget.credentialId,
+ displayName: props.reconnectTarget.displayName,
+ })
+ draftId = draft.draftId
+ }
+
logger.info('Reauthorizing OAuth2', {
providerId,
requiredScopes,
@@ -341,7 +356,8 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
}
}
- const isPending = (isConnect && createDraft.isPending) || connectOAuthService.isPending
+ const createsDraft = isConnect || (!isConnect && Boolean(props.reconnectTarget))
+ const isPending = (createsDraft && createDraft.isPending) || connectOAuthService.isPending
const isDisabled = isConnect
? !displayName.trim() || isPending || Boolean(existingCredential)
: isPending
diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-credential-detail-form.ts b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-credential-detail-form.ts
index fc36c039bad..c7119a9ca52 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-credential-detail-form.ts
+++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-credential-detail-form.ts
@@ -56,15 +56,17 @@ export function useCredentialDetailForm({
const [displayNameDraft, setDisplayNameDraft] = useState('')
const [descriptionDraft, setDescriptionDraft] = useState('')
+ const [unredactedDraft, setUnredactedDraft] = useState(false)
const [seededCredentialId, setSeededCredentialId] = useState(null)
// Seed drafts when the credential first resolves (or the route id changes); a
// background refetch of the same credential must not clobber an in-progress
// edit — Discard is the one way to reset.
- /** Applies a credential to both drafts — the one definition of "reset to server state". */
+ /** Applies a credential to every draft — the one definition of "reset to server state". */
const seedDrafts = useCallback((source: WorkspaceCredential) => {
setDisplayNameDraft(source.displayName)
setDescriptionDraft(source.description ?? '')
+ setUnredactedDraft(source.unredacted)
}, [])
if (credential && credential.id !== seededCredentialId) {
@@ -76,7 +78,8 @@ export function useCredentialDetailForm({
const isDescriptionDirty = credential
? descriptionDraft !== (credential.description || '')
: false
- const isMetadataDirty = isDisplayNameDirty || isDescriptionDirty
+ const isUnredactedDirty = credential ? unredactedDraft !== credential.unredacted : false
+ const isMetadataDirty = isDisplayNameDirty || isDescriptionDirty || isUnredactedDirty
const isSectionDirty = section?.isDirty ?? false
const isDirty = isMetadataDirty || isSectionDirty
const isSaving = updateCredential.isPending || (section?.isSaving ?? false)
@@ -93,6 +96,7 @@ export function useCredentialDetailForm({
credentialId: credential.id,
...(isDisplayNameDirty ? { displayName: displayNameDraft.trim() } : {}),
...(isDescriptionDirty ? { description: descriptionDraft.trim() || null } : {}),
+ ...(isUnredactedDirty ? { unredacted: unredactedDraft } : {}),
})
if (isDisplayNameDirty) setDisplayNameDraft((value) => value.trim())
if (isDescriptionDirty) setDescriptionDraft((value) => value.trim())
@@ -111,8 +115,10 @@ export function useCredentialDetailForm({
section,
isDisplayNameDirty,
isDescriptionDirty,
+ isUnredactedDirty,
displayNameDraft,
descriptionDraft,
+ unredactedDraft,
updateCredential.mutateAsync,
])
@@ -126,6 +132,8 @@ export function useCredentialDetailForm({
setDisplayNameDraft,
descriptionDraft,
setDescriptionDraft,
+ unredactedDraft,
+ setUnredactedDraft,
isDirty,
save,
discard,
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/delete-confirm-modal/delete-confirm-modal.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/delete-confirm-modal/delete-confirm-modal.tsx
index 8e02ebfcd69..2ac5a08bd99 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/delete-confirm-modal/delete-confirm-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/delete-confirm-modal/delete-confirm-modal.tsx
@@ -39,6 +39,7 @@ export const DeleteConfirmModal = memo(function DeleteConfirmModal({
onOpenChange={onOpenChange}
srTitle={title}
title={title}
+ defaultAction={totalCount === 1 && !hasFolders ? 'confirm' : 'dismiss'}
text={[
'Are you sure you want to delete ',
fileName
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.test.ts
index fff5f035a81..15f4f1fbd28 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.test.ts
@@ -11,7 +11,6 @@ import {
import { createMarkdownEditorExtensions } from './editor-extensions'
import {
extractImageFiles,
- extractImgSrcs,
findHostedImageAttrs,
hasHostedImageHtml,
htmlReferencesSrc,
@@ -151,19 +150,6 @@ describe('hasHostedImageHtml', () => {
})
})
-describe('extractImgSrcs', () => {
- it('extracts every img src in document order, including duplicates', () => {
- expect(
- extractImgSrcs('text
')
- ).toEqual(['/a.png', '/b.png', '/a.png'])
- })
-
- it('returns an empty array for html with no img', () => {
- expect(extractImgSrcs('hello
')).toEqual([])
- expect(extractImgSrcs('')).toEqual([])
- })
-})
-
describe('shouldSkipFileUpload (shared by paste and drop)', () => {
const isHosted = (src: string) => src.startsWith('/api/files/view/')
const hostedHtml = ' '
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.ts
index c3edc938661..9bd35bfc1fa 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.ts
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.ts
@@ -1,3 +1,5 @@
+import { extractImgSrcs } from '@/lib/uploads/utils/embedded-image-ref'
+
/**
* Extract image `File` objects from a paste/drop payload. Reads `files` first, then falls back to
* `items` — many browsers expose a pasted or copied image (e.g. a screenshot) only through
@@ -13,13 +15,6 @@ export function extractImageFiles(transfer: DataTransfer | null): File[] {
.filter((file): file is File => file !== null)
}
-/**
- * Matches ` ` `src` attribute values: double-quoted, single-quoted, or (validly) unquoted per
- * the HTML spec — the browser's own clipboard serialization always quotes it, but other producers
- * of `text/html` are not obligated to.
- */
-const IMG_SRC_RE = / ]*\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/gi
-
/** Query params under which the inline route addresses a workspace file. */
const INLINE_ROUTE_QUERY_KEYS = new Set(['key', 'fileId'])
@@ -80,18 +75,6 @@ export function isInlineRouteSrc(src: string, origin = runtimeOrigin()): boolean
}
}
-/**
- * Extracts every ` ` `src` value found in `html`, in document order (may contain duplicates).
- */
-export function extractImgSrcs(html: string): string[] {
- const srcs: string[] = []
- for (const match of html.matchAll(IMG_SRC_RE)) {
- const src = match[1] ?? match[2] ?? match[3]
- if (src) srcs.push(src)
- }
- return srcs
-}
-
/**
* True when `html` contains an ` ` whose `src` is already one of our own hosted workspace file
* references. Copying a rendered ` ` that's already on the page (e.g. Cmd+C after clicking it to
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.test.ts
index a2879b6da6f..78c1662dc38 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.test.ts
@@ -28,6 +28,18 @@ describe('content-source resolveImageSrc', () => {
)
})
+ it('normalizes a percent-encoded id before building an inline request', () => {
+ const workspace = createWorkspaceFileContentSource('ws-1')
+ const publicShare = createPublicFileContentSource('tok_1', '/api/files/public/tok_1/content')
+
+ expect(workspace.resolveImageSrc('/api/files/view/wf%5Fabc')).toBe(
+ '/api/workspaces/ws-1/files/inline?fileId=wf_abc'
+ )
+ expect(publicShare.resolveImageSrc('/api/files/view/wf%5Fabc')).toBe(
+ '/api/files/public/tok_1/inline?fileId=wf_abc'
+ )
+ })
+
it('passes external/data srcs through unchanged in both sources', () => {
const ws = createWorkspaceFileContentSource('ws-1')
const pub = createPublicFileContentSource('tok_1', '/c')
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.ts
index 4470187fefa..fd46d579527 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.ts
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.ts
@@ -76,21 +76,24 @@ export function applyFrontmatter(frontmatter: string, body: string): string {
return frontmatter + body
}
-/** A leading `scheme://` URL (network protocol). */
-const SCHEME_URL = /^([a-z][a-z0-9+.-]*):\/\//i
/** A leading `scheme:` token (per the URL grammar). */
const HAS_SCHEME = /^[a-z][a-z0-9+.-]*:/i
/** A bare `host:port` (digits after the colon) — looks scheme-like but is really a domain. */
const HOST_PORT = /^[a-z0-9.-]+:\d+(?:[/?#]|$)/i
+/**
+ * The only schemes a document link may target — an allowlist, because `scheme://` is well-formed for
+ * every scheme: rejecting just the ones known to be dangerous leaves the next one through, and
+ * `javascript://…` is a valid URL whose `//` run is merely a comment.
+ */
+const SAFE_SCHEME = /^(?:(?:https?|ftps?):\/\/|(?:mailto|tel):)/i
+
/**
* Normalize a user-entered link target: prefix a bare domain with `https://` so it doesn't resolve
* as an in-app relative URL, while leaving already-qualified, relative (`./other.md`, `../doc.md`), and
- * protocol-relative URLs intact. Dangerous schemes are rejected outright rather than trusted or mangled:
- * any `scheme:` without `//` other than `mailto:`/`tel:` (so `javascript:`, `data:`, `vbscript:`,
- * `blob:`, …), and `file://` (local file access). Other network `scheme://` URLs (`http(s)`, `ftp`, …)
- * pass through. A bare `host:port` (digits after the colon) is a domain, not a scheme, so it still gets
- * the `https://` prefix.
+ * protocol-relative URLs intact. A scheme is kept only when {@link SAFE_SCHEME} matches; every other
+ * one is dropped to `''`, which callers render as inert text rather than a link. A bare `host:port`
+ * (digits after the colon) is a domain, not a scheme, so it still gets the `https://` prefix.
*/
export function normalizeLinkHref(href: string): string {
const trimmed = href.trim()
@@ -99,9 +102,7 @@ export function normalizeLinkHref(href: string): string {
if (trimmed.startsWith('//')) return `https:${trimmed}`
if (trimmed.startsWith('/')) return trimmed
if (trimmed.startsWith('./') || trimmed.startsWith('../')) return trimmed
- if (/^(?:mailto|tel):/i.test(trimmed)) return trimmed
- const schemed = trimmed.match(SCHEME_URL)
- if (schemed) return /^file$/i.test(schemed[1]) ? '' : trimmed
+ if (SAFE_SCHEME.test(trimmed)) return trimmed
if (HAS_SCHEME.test(trimmed) && !HOST_PORT.test(trimmed)) return ''
return `https://${trimmed}`
}
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-editing.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-editing.test.ts
new file mode 100644
index 00000000000..e66652f8e54
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-editing.test.ts
@@ -0,0 +1,46 @@
+import type { ChainedCommands } from '@tiptap/core'
+import { describe, expect, it, vi } from 'vitest'
+import { applyLink } from './link-editing'
+
+function chainSpy() {
+ const calls: string[] = []
+ const chain = {
+ extendMarkRange: vi.fn(() => chain),
+ setLink: vi.fn(({ href }: { href: string }) => {
+ calls.push(`setLink:${href}`)
+ return chain
+ }),
+ unsetLink: vi.fn(() => {
+ calls.push('unsetLink')
+ return chain
+ }),
+ run: vi.fn(() => true),
+ }
+ return { chain: chain as unknown as ChainedCommands, calls }
+}
+
+describe('applyLink', () => {
+ it('sets a link for a target that survives normalization', () => {
+ const { chain, calls } = chainSpy()
+ applyLink(chain, ' sim.ai ')
+ expect(calls).toEqual(['setLink:https://sim.ai'])
+ })
+
+ it('removes the link when the field is cleared', () => {
+ const { chain, calls } = chainSpy()
+ applyLink(chain, ' ')
+ expect(calls).toEqual(['unsetLink'])
+ })
+
+ /**
+ * The field is seeded with the raw href, so committing one untouched must not be read as "remove".
+ * Dropping an unsafe target is a refusal to link, not an instruction to delete what is already there.
+ */
+ it('leaves the existing link untouched when the target normalizes away', () => {
+ for (const target of ['javascript://%0aalert(1)', 'customproto://host/path']) {
+ const { chain, calls } = chainSpy()
+ applyLink(chain, target)
+ expect(calls).toEqual([])
+ }
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-editing.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-editing.tsx
index f294e88a950..8cbd0dc7b6d 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-editing.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-editing.tsx
@@ -4,11 +4,17 @@ import { normalizeLinkHref } from '../markdown-fidelity'
/**
* Applies a link to the chain's current selection: normalizes `rawHref`, expands to the full link
- * mark, and sets it — or removes the link when the href is empty/unsafe. The caller supplies a chain
- * already focused with the target selection (the captured bubble-menu range / the hovered link range).
+ * mark, and sets it. Clearing the field removes the link; a target that survives normalization
+ * replaces it. A target that normalizes away is neither set nor removed — the editor seeds this field
+ * with the raw href, so committing an untouched one would otherwise delete a link the user only
+ * opened, and dropping an unsafe target is not the same instruction as "remove this link". The
+ * caller supplies a chain already focused with the target selection (the captured bubble-menu range /
+ * the hovered link range).
*/
export function applyLink(chain: ChainedCommands, rawHref: string): void {
- const href = normalizeLinkHref(rawHref.trim())
+ const trimmed = rawHref.trim()
+ const href = normalizeLinkHref(trimmed)
+ if (!href && trimmed) return
chain.extendMarkRange('link')
if (href) chain.setLink({ href })
else chain.unsetLink()
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx
index 0a53b21cce5..92e05442fac 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx
@@ -14,7 +14,7 @@ import {
truncateSelectionText,
} from '@/lib/copilot/chat/selection-context'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
-import { extractEmbeddedFileRef } from '@/lib/uploads/utils/embedded-image-ref'
+import { extractEmbeddedFileRef, extractImgSrcs } from '@/lib/uploads/utils/embedded-image-ref'
import { isUntitledName } from '@/app/workspace/[workspaceId]/files/untitled-title'
import { useUploadWorkspaceFile } from '@/hooks/queries/workspace-files'
import { useAddToChat } from '@/hooks/use-add-to-chat'
@@ -40,12 +40,7 @@ import { useFileDocCollaboration } from './collaboration/use-file-doc-collaborat
import { createMarkdownEditorExtensions } from './editor-extensions'
import { findHeadingPos } from './heading-anchors'
import { moveDraggedImageNode } from './image-drag-move'
-import {
- extractImageFiles,
- extractImgSrcs,
- findHostedImageAttrs,
- shouldSkipFileUpload,
-} from './image-paste'
+import { extractImageFiles, findHostedImageAttrs, shouldSkipFileUpload } from './image-paste'
import {
applyFrontmatter,
normalizeLinkHref,
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip.test.ts
index c8745e5e861..961fd1d86df 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip.test.ts
@@ -5,6 +5,7 @@
* be idempotent (a second pass changes nothing) so autosave never churns. Mirrors the exact
* pipeline the editor uses: split frontmatter out, serialize the body, re-attach + clean up.
*/
+import type { JSONContent } from '@tiptap/core'
import { Editor } from '@tiptap/core'
import { afterEach, describe, expect, it } from 'vitest'
import { createMarkdownContentExtensions } from './extensions'
@@ -14,6 +15,7 @@ import {
postProcessSerializedMarkdown,
splitFrontmatter,
} from './markdown-fidelity'
+import { parseMarkdownToDoc } from './markdown-parse'
let editor: Editor | null = null
@@ -126,6 +128,66 @@ describe('markdown-fidelity utils', () => {
expect(normalizeLinkHref('blob:https://x.com/uuid')).toBe('')
expect(normalizeLinkHref('vbscript:msgbox(1)')).toBe('')
expect(normalizeLinkHref('localhost:3000/path')).toBe('https://localhost:3000/path')
+ // Adding `//` doesn't make a scheme safe, and an unknown scheme is dropped rather than trusted —
+ // the allowlist is the whole rule.
+ expect(normalizeLinkHref('javascript://%0aalert(1)')).toBe('')
+ expect(normalizeLinkHref('customproto://host/path')).toBe('')
+ })
+
+ /**
+ * The property that matters, stated over the spellings a browser collapses before it resolves a
+ * scheme: whatever comes back must not be executable. Padding and interior tabs/newlines are the
+ * usual way a blocked scheme is smuggled past a matcher that only reads the literal text.
+ */
+ it('never returns a target that resolves to an executable scheme', () => {
+ const tab = String.fromCharCode(9)
+ const lf = String.fromCharCode(10)
+ const nbsp = String.fromCharCode(160)
+ const inputs = [
+ 'javascript://%0aalert(1)',
+ 'javascript:alert(1)',
+ 'JAVASCRIPT://x',
+ ' javascript:alert(1) ',
+ `${nbsp}javascript:alert(1)`,
+ `java${tab}script://alert(1)`,
+ `java${lf}script:alert(1)`,
+ 'data://text/html,