diff --git a/.agents/skills/babysit/SKILL.md b/.agents/skills/babysit/SKILL.md index 8f324d190b3..42a57b5ecd8 100644 --- a/.agents/skills/babysit/SKILL.md +++ b/.agents/skills/babysit/SKILL.md @@ -134,9 +134,21 @@ round. Always check both conditions freshly after every push. When the loop ends, summarize: how many rounds it took, what was actually fixed (one line each), what was pushed back on as a false positive and why, and the final Greptile score / thread count. +## Public-repo hygiene + +Every reply, comment and commit you post here is public and permanent, and review bots quote +your replies back so a leak propagates. Before each post, strip anything that ties the change to +a tenant: customer/company names, workspace/user/org/KB/connector IDs, emails, tenant hostnames, +verbatim document/sheet/folder names, log lines, and per-tenant DB output. Cite the mechanism and +aggregate numbers instead — see `/ship`'s "What to Omit" for the full list and the pre-publish +grep. Triaging a finding often means pasting evidence you gathered from prod; that is exactly the +moment this gets violated. Check before posting, not after: editing a comment does not unsend its +notification email. + ## Hard rules - Never post the two re-review mentions as a single combined comment. +- Never paste prod evidence into a reply without scrubbing it first (see above). - Never resolve a thread without replying to it first. - Never fix a finding with a hacky workaround — if the clean fix isn't obvious, find the sibling pattern elsewhere in the codebase solving the same class of problem and match it. diff --git a/.agents/skills/emcn-design-review/SKILL.md b/.agents/skills/emcn-design-review/SKILL.md index 78253e5e772..09a9932d4b1 100644 --- a/.agents/skills/emcn-design-review/SKILL.md +++ b/.agents/skills/emcn-design-review/SKILL.md @@ -39,7 +39,7 @@ Use CSS variable pattern (`text-[var(--text-primary)]`), never Tailwind semantic **Surfaces**: `--bg`, `--surface-1` through `--surface-7`, `--surface-hover`, `--surface-active` **Borders**: `--border`, `--border-1`, `--border-muted` **Brand/accent**: `--brand-secondary`, `--brand-accent` -**Z-Index**: `--z-dropdown` (100), `--z-modal` (200), `--z-popover` (300), `--z-tooltip` (400), `--z-toast` (500) +**Z-Index**: `--z-dropdown` (100), `--z-toast` (150), `--z-modal` (200), `--z-popover` (300), `--z-tooltip` (400), `--z-takeover` (500), `--z-shell-gate` (600) **Shadows**: `shadow-subtle`, `shadow-medium`, `shadow-overlay`, `shadow-card` **Badges**: `--badge-*` semantic families (success/error/gray/blue/purple/orange/amber/teal/cyan/pink, each with `-bg`/`-text`) diff --git a/.agents/skills/ship/SKILL.md b/.agents/skills/ship/SKILL.md index 8d30b1b63d2..76c5bbf3643 100644 --- a/.agents/skills/ship/SKILL.md +++ b/.agents/skills/ship/SKILL.md @@ -102,13 +102,20 @@ chore(scope): description for maintenance ## What to Omit -The repo is public. Keep the title and description to the code change and its reasoning — never: +The repo is public. **Everything you publish — title, description, commit messages, and every later comment — must stand on its own without the incident that produced it.** Never include: -- Customer, company, or user names; workspace/user/org IDs; email addresses +- Customer, company, or user names; workspace/user/org/KB/connector IDs; email addresses - Prod or staging operational data: log lines, DB rows, metrics, timestamps, incident details, canary/alert output -- Infrastructure specifics: hostnames, ARNs, internal URLs, env var values, secret names +- Infrastructure specifics: hostnames (incl. tenant subdomains), ARNs, internal URLs, env var values, secret names +- Verbatim customer content: file names, document titles, sheet/column names, folder paths -Describe the bug by its mechanism, not by how you found it. "Expired OAuth credentials fail to refresh in the worker" — not "the Sheets canary failed at 16:31Z for workspace abc-123". +Describe the bug by its mechanism, not by how you found it. "Expired OAuth credentials fail to refresh in the worker" — not "the Sheets canary failed at 16:31Z for workspace abc-123". Aggregate counts are fine once detached from the tenant ("1,379 PDFs failed"); the same number attributed to a named customer is not. Replace real examples with placeholders (``) rather than cutting them — the illustration is usually the useful part. + +**Scrub before publishing, not after** — a leak is public the instant it posts, and editing later does not unsend the notification email. This applies to every PR you open, including ones created directly with `gh pr create` rather than through this skill. Grep the title, body, and `git log origin/staging..HEAD` before publishing: + +```bash +grep -niE 'customer-or-company-name|@[a-z0-9.-]+\.(com|io|ai)|[0-9a-f]{8}-[0-9a-f]{4}-|\.sharepoint\.com|arn:aws|https?://[a-z0-9.-]*\.internal' +``` ## PR Description Format diff --git a/apps/docs/app/[lang]/[[...slug]]/page.tsx b/apps/docs/app/[lang]/[[...slug]]/page.tsx index 7b3f114b592..87436d3f664 100644 --- a/apps/docs/app/[lang]/[[...slug]]/page.tsx +++ b/apps/docs/app/[lang]/[[...slug]]/page.tsx @@ -1,5 +1,5 @@ import type React from 'react' -import { getHighlighter, highlight } from 'fumadocs-core/highlight' +import { highlight } from 'fumadocs-core/highlight' import type { Root } from 'fumadocs-core/page-tree' import { findNeighbour } from 'fumadocs-core/page-tree' import type { ApiPageProps } from 'fumadocs-openapi/ui' @@ -18,7 +18,6 @@ import { Heading } from '@/components/ui/heading' import { ResponseSection } from '@/components/ui/response-section' import { i18n } from '@/lib/i18n' import { getApiSpecContent, getAuthenticatedCodeSamples, openapi } from '@/lib/openapi' -import { curlJsonBodyGrammar } from '@/lib/shiki-curl-json' import { simShikiOptions } from '@/lib/shiki-theme' import { type PageData, source } from '@/lib/source' import { DOCS_BASE_URL } from '@/lib/urls' @@ -77,17 +76,8 @@ function stripLocalePrefix(url: string, lang: string): string { * rather than fumadocs-openapi's built-in one, so those blocks get the emcn copy control * instead of fumadocs' lucide clipboard. Mirrors the default renderer — same `highlight` call, * same `Pre` component, same `my-0` — differing only in which shell wraps the result. - * - * One asymmetry: `highlight` resolves fumadocs' shared `defaultShikiFactory`, while the renderer - * this replaces uses whatever `shiki` factory the page was configured with. They are the same - * object because that factory is also the default; passing a custom one would be honored on API - * markdown and ignored here. */ async function ApiCodeBlock({ lang, code }: { lang: string; code: string }) { - // Registers the injection on the shared highlighter `highlight` resolves; an injection is a - // property of the highlighter, not a per-call option. Idempotent — already-loaded grammars are - // skipped. - await getHighlighter('js', { langs: [curlJsonBodyGrammar] }) return ( {await highlight(code, { lang, ...simShikiOptions, components: { pre: Pre } })} diff --git a/apps/docs/app/global.css b/apps/docs/app/global.css index 8d1dfc1277e..4e493df5ce9 100644 --- a/apps/docs/app/global.css +++ b/apps/docs/app/global.css @@ -1603,15 +1603,17 @@ main article tbody tr:last-child td { /* Code blocks — platform field chrome. - The shell itself (radius, border, fill) is owned by components/ui/code-block.tsx, the way - an emcn component owns its chrome. What remains here is the styling of fumadocs internals - that component cannot reach: the title row it renders from a `title` prop, the scroll - viewport, and the tab strip that `` puts above a fence. */ - -/* Local aliases, not new design values: each is an existing platform token whose light and - dark halves differ. Naming the pair once lets the rules below be written without a `.dark` - twin, which would otherwise have to restate their `:has()` selectors — the double-`:has()` - the tabbed group is matched by, and the sibling-combinator one the gutter is matched by. */ + Three renderers emit these figures and only two go through components/ui/code-block.tsx, so + the shell and the fumadocs internals that component cannot reach — the title row, the scroll + viewport, the tab strip `` puts above a fence — are all defined here. The component + keeps only the copy control and the prose margin. */ + +/* Local aliases, not new design values: each names an existing platform token pair whose light + and dark halves differ (`--surface-5`/`--code-bg`, `--text-muted`/`--code-line-number`). + + `--code-surface` has three consumers. `--code-gutter` has one, and is an alias anyway because + a `.dark` twin would have to restate the sibling-combinator `:has()` its consumer is matched + by — the expensive form, which re-checks on every line insertion. */ :root { --code-surface: var(--surface-5); --code-gutter: var(--text-muted); @@ -1689,10 +1691,9 @@ figure.shiki > div:first-child:has(figcaption) svg { The `!important` and the viewport selector are belt-and-braces, not strictly required — fumadocs' declaration is (0,2,0) and these selectors are (0,3,1) and (0,4,1), so they - already win, and nothing declares this property on the viewport. They are kept because - getting this wrong puts the line numbers on top of the code, which shipped once already, and - because the specificity of `:has()` and `:not()` is easy to miscount in exactly the - direction that reintroduces it. Remove them only alongside a visual check. + already win, and nothing declares this property on the viewport. They are kept because the + specificity of `:has()` and `:not()` is easy to miscount in the direction that puts the line + numbers on top of the code. Remove them only alongside a visual check. */ figure.shiki:has(.line ~ .line), figure.shiki:has(.line ~ .line) > div[role="region"], @@ -1733,6 +1734,9 @@ figure.shiki code:has(.line ~ .line) .line::before { the code surface. The viewport is the one box all three renderers agree on. */ figure.shiki > div[role="region"] { background-color: var(--code-surface); + /* fumadocs ships 14px of vertical padding, the platform's viewer 8px; 10px splits them and + keeps a single-line fence from looking hollow at the tighter 21px line box. */ + padding-block: 10px; } /* Shiki emits `--shiki-*-bg` custom properties under `defaultColor: false`; keep the `pre` clear @@ -1741,13 +1745,6 @@ figure.shiki pre { background-color: transparent; } -/* Viewport padding — fumadocs ships 14px vertical; the platform's viewer uses 8px. Split the - difference at 10px, which keeps a single-line fence from looking hollow at the tighter - 21px line box. */ -figure.shiki > div[role="region"] { - padding-block: 10px; -} - /* Untitled blocks float the copy control over the code, so the last column has to clear it: an 8px offset plus emcn's 20px icon button, with room to breathe. fumadocs reserves 32px, which the glyphs run into. @@ -1802,12 +1799,6 @@ figure.shiki button[aria-label="Copied Text"]:has(> svg[class*="lucide"]) { color 150ms; } -figure.shiki button[aria-label="Copy Text"] > svg[class*="lucide"], -figure.shiki button[aria-label="Copied Text"] > svg[class*="lucide"] { - width: 14px; - height: 14px; -} - figure.shiki button[aria-label="Copy Text"]:has(> svg[class*="lucide"]) { @variant hover-hover { background-color: var(--surface-active); @@ -1849,11 +1840,11 @@ figure.shiki button[aria-label$="Text"]:has(> svg[class*="lucide"])::before { -webkit-mask-size: contain; } -figure.shiki button[aria-label="Copy Text"] { +figure.shiki button[aria-label="Copy Text"]:has(> svg[class*="lucide"]) { --copy-glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='-1 -2 24 24' fill='none' stroke='%23000' stroke-width='1.25' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M14.25 0.75H2.75C1.64543 0.75 0.75 1.64543 0.75 2.75V14.25'/%3E%3Crect x='5.25' y='5.25' width='14' height='14' rx='2'/%3E%3C/svg%3E"); } -figure.shiki button[aria-label="Copied Text"] { +figure.shiki button[aria-label="Copied Text"]:has(> svg[class*="lucide"]) { --copy-glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='-1 -2 24 24' fill='none' stroke='%23000' stroke-width='1.25' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18.25 2.75L7.25 15.75L1.75 10.25'/%3E%3C/svg%3E"); } @@ -1901,6 +1892,9 @@ div:has(> div > figure.shiki) > [role="tablist"] button { color 150ms; } +/* Kept as its own rule rather than nested into the block above: biome's + `noDuplicateProperties` reads a nested `@variant` setting the same property as a duplicate + declaration and fails the build. */ div:has(> div > figure.shiki) > [role="tablist"] button { @variant hover-hover { color: var(--text-body); diff --git a/apps/docs/components/ui/api-example-selector.tsx b/apps/docs/components/ui/api-example-selector.tsx index b59743385f5..7f517ceba88 100644 --- a/apps/docs/components/ui/api-example-selector.tsx +++ b/apps/docs/components/ui/api-example-selector.tsx @@ -2,8 +2,14 @@ import type { ComponentProps } from 'react' import { useId } from 'react' -import { chipFieldSurfaceClass, chipFieldTextClass, chipHoverSurfaceClass, cn } from '@sim/emcn' -import { ChevronDown } from '@sim/emcn/icons' +import { + ChipChevronDown, + chipFieldSurfaceClass, + chipFieldTextClass, + chipGeometryClass, + chipHoverSurfaceClass, + cn, +} from '@sim/emcn' import type { APIPageClientOptions } from 'fumadocs-openapi/ui/client' type FumadocsAPIExampleSelector = NonNullable< @@ -35,10 +41,11 @@ export function APIExampleSelector({ items, value, onValueChange }: APIExampleSe * screen-reader behavior a custom listbox would have to rebuild. */ className={cn( + chipGeometryClass, chipFieldSurfaceClass, chipFieldTextClass, chipHoverSurfaceClass, - 'h-[30px] w-full appearance-none ps-2 pe-8 text-left' + 'w-full appearance-none pe-8' )} > {items.map((item) => ( @@ -47,7 +54,7 @@ export function APIExampleSelector({ items, value, onValueChange }: APIExampleSe ))} - + {selectedItem?.description && (

{selectedItem.description}

diff --git a/apps/docs/components/ui/code-block.tsx b/apps/docs/components/ui/code-block.tsx index f01416fd231..43239464dae 100644 --- a/apps/docs/components/ui/code-block.tsx +++ b/apps/docs/components/ui/code-block.tsx @@ -33,21 +33,24 @@ function CopyButton({ getCode }: { getCode: () => string }) { } /** - * Docs code block for prose fences, wired into the MDX `pre` mapping. + * Docs code block for prose fences and the API reference's request/response samples — the MDX + * `pre` mapping and fumadocs-openapi's `renderCodeBlock` both render it. * - * The shell — radius, hairline, fill — is not set here. Request and response samples in the - * API reference come from fumadocs-openapi's own renderer, so the two share chrome through a - * `figure.shiki` rule in `global.css` instead; see the note there. What stays here is the - * part only this path has: the copy control, and the `my-4` prose rhythm that API samples, - * which sit flush in their panel, must not inherit. + * The shell — radius, hairline, fill — is not set here. A third renderer, fumadocs-openapi's + * `UsageTab`, emits these figures without going through any component, so all three share + * chrome through a `figure.shiki` rule in `global.css` instead; see the note there. What stays + * here is the copy control, and the `my-4` prose rhythm that API samples, which sit flush in + * their panel, override with `my-0`. */ export function CodeBlock({ title, ...props }: React.ComponentProps) { const figureRef = useRef(null) /** - * Reads the block's text the way fumadocs does: from a clone, with `.nd-copy-ignore` nodes - * replaced by newlines. Those nodes carry rendered gutter and diff markers, so copying the - * live `textContent` would paste line numbers along with the code. + * Reads the block's text the way fumadocs' own `CopyButton` does: from a clone, with + * `.nd-copy-ignore` nodes replaced by newlines — kept in step with upstream so a fence that + * gains such a node copies the same text there and here. (The line-number gutter is a + * `::before`, and pseudo-element content never reaches `textContent`, so it is not what this + * guards.) */ function getCode() { const pre = figureRef.current?.getElementsByTagName('pre').item(0) @@ -65,8 +68,9 @@ export function CodeBlock({ title, ...props }: React.ComponentProps (
diff --git a/apps/docs/content/docs/en/logs-debugging/logging.mdx b/apps/docs/content/docs/en/logs-debugging/logging.mdx index e9212e61e0f..49d9f379413 100644 --- a/apps/docs/content/docs/en/logs-debugging/logging.mdx +++ b/apps/docs/content/docs/en/logs-debugging/logging.mdx @@ -94,7 +94,7 @@ import { FAQ } from '@/components/ui/faq' -Execution-log masking is activated only when Sim successfully resolves a value from **Settings → Secrets** through `{{KEY}}`. A hardcoded literal, direct `environmentVariables['KEY']` read, or shell `$KEY` read does not activate log masking by itself. Model-bound projection also checks the run's authorized secret catalog, including direct reads, but both protections match only exact values. Encoded, hashed, fragmented, or otherwise transformed versions are not matched. Do not deliberately return or print secrets. +Direct reads are found by reading the code, not by running it, so recognition stops where the code stops being readable ahead of time. An unrecognized read is not masked, and does not appear under **See usage**. + +Where a read is recognized, Sim reports it rather than trying to prove it is not one. Code that shadows the environment binding with its own object, overwrites a variable before reading it, or assigns to the name instead of reading it is still reported. Naming a secret costs only an exact value the code never emits; failing to name one leaves it unmasked. **See usage** can therefore occasionally list a secret the code had available but did not read. + +Assigning to the injected binding does not change the stored secret — it is an ordinary object built from the run's payload and discarded when the run ends. Edit a secret under **Settings → Secrets**. + +A read is **not** recognized when: + +- **The name is built at runtime.** `environmentVariables[keyName]`, `$@`, `${!indirect}`, `eval`, `printenv`, or a sourced file hide which secret is being read. +- **The read is of a different object.** `other.environmentVariables['KEY']` reads something that merely shares the name. +- **The read cannot be told apart from text.** A `$KEY` inside single quotes or a quoted heredoc (`<<'EOF'`) never expands, and Sim treats anything its scanner cannot place as not running. + +Both masking and model-bound projection match only exact values in either case. Encoded, hashed, fragmented, or otherwise transformed versions are not matched, and a value assembled or emitted piece by piece cannot be matched at all — determining whether arbitrary code will eventually reveal a value is not decidable in general. Treat these as a safety net, not a boundary: do not deliberately return, print, or transmit secrets. ### Copilot code execution @@ -105,9 +119,22 @@ From here you can: - View the **Key** and edit the **Value** - Edit the **Description** — an optional note telling teammates what the secret is for. Workspace secrets only; a personal secret is not shared, so it has none - Manage **Members** — invite teammates by email and assign them an **Admin** or **Member** role +- Open **See usage** — where this secret has actually been used Click **Save** to apply changes, or **Back** to return to the list. +### See usage + +**See usage** lists the runs that resolved this secret: when it was last used, what used it (a workflow, the Sim agent, or an MCP server), how it was triggered, who it resolved under, and a link to the most recent run in Logs. Rows are grouped by day, so a workflow on a schedule reads as one row per day rather than thousands. + +This answers the question worth asking before rotating a key: who has been using it, inside what, and how recently. + +Only people who can read the value can see it — a Credential Admin on a workspace secret, or the owner of a personal one. For everyone else the action is visible but disabled, because the trail names workflows, people, and run IDs, which is the same information masking withholds. Two people who each hold a personal secret under the same name see only their own runs. + + +Usage is recorded independently of execution logs, so it outlives them: logs expire under your workspace's retention setting, while the record of who touched a credential does not. It records what a run resolved, subject to the recognition limits under [Execution log protection](#execution-log-protection) — a read Sim cannot attribute is left out rather than guessed at, so treat an empty trail as "nothing recognized," not proof a secret was never used. + + ## Workspace vs. Personal | | Workspace | Personal | diff --git a/apps/docs/content/docs/en/platform/enterprise/forks.mdx b/apps/docs/content/docs/en/platform/enterprise/forks.mdx index 7185eb39627..ebaa22fb6ea 100644 --- a/apps/docs/content/docs/en/platform/enterprise/forks.mdx +++ b/apps/docs/content/docs/en/platform/enterprise/forks.mdx @@ -174,7 +174,7 @@ How each resource behaves at **fork** time vs **sync** time. Use this when you a | [Excluded workflows](#excluded-workflows) | Never | Never — not sent, not overwritten, not archived | | Files | Optional copy (default on) | Map or copy | | Tables | Optional copy (default on) | Map or copy | -| Knowledge bases (+ documents) | Optional copy; referenced docs come with the KB | Map or copy; documents follow the KB | +| Knowledge bases (+ documents) | Optional copy; uploaded documents come with the KB, [connector-synced ones do not](#connector-synced-documents-are-not-copied) | Map or copy; documents follow the KB | | Custom tools | Optional copy (default on) | Map or copy | | Skills | Optional copy (default on) | Map or copy | | External MCP servers | Optional copy (config only; sign-in cleared) | Map or copy (config only; sign-in cleared) | @@ -227,10 +227,27 @@ Only **deployed** workflows move. Deploy is the commit; sync is the force push/p | | Behavior | |---|----------| -| **Fork** | Optional copy (default on). Tag definitions come with the knowledge base. Documents that the forked workflows actually reference are included. Deselect → knowledge base / document fields clear. | +| **Fork** | Optional copy (default on). Tag definitions come with the knowledge base, along with every **uploaded** document in it. Deselect → knowledge base / document fields clear. | | **Sync** | Map or copy the knowledge base. Documents are not mapped by themselves — they follow the knowledge base (copied with it, or re-picked when you map to an existing one). | -**Example:** An agent searches knowledge base “Product docs.” Fork with that knowledge base selected → the child gets the base, tags, and the documents the agent used. On sync, mapping to the child’s existing “Product docs” means re-picking which document the tool should use. +**Example:** An agent searches knowledge base “Product docs.” Fork with that knowledge base selected → the child gets the base, tags, and the uploaded documents. On sync, mapping to the child’s existing “Product docs” means re-picking which document the tool should use. + +#### Connector-synced documents are not copied + +Connectors themselves never cross a fork edge — the child gets no Confluence, Notion, Google Drive, or other sync running against it. Documents that a **connector** put in the knowledge base are therefore not copied either. Only documents you **uploaded** come across. + + + Fork a knowledge base whose content is entirely connector-synced and the child gets the base, its tags, and its settings — but **no documents**. Add the connector in the child to fill it. + + +This is deliberate. A copied connector document would arrive detached from any connector, so nothing would ever update, re-sync, or remove it — and when you added the connector in the child it would ingest every page again *alongside* the stale copy. Chain a few forks (prod → UAT → staging) and each hop leaves another dead generation behind, so one page comes back several times in a single knowledge search. Skipping them keeps the child’s own connector the single owner of that content. + +| To get connector content into the child | Do this | +|---|---| +| Keep it live | Add the same connector in the child and let it sync. It re-ingests everything, so nothing is lost. | +| Keep a frozen snapshot | Download the documents from the source and upload them to the child’s knowledge base — uploaded documents copy on every later fork. | + +A document whose connector was **deleted** in the source is no longer connector-managed, so it copies like any other uploaded document. --- diff --git a/apps/docs/content/docs/en/workflows/blocks/function.mdx b/apps/docs/content/docs/en/workflows/blocks/function.mdx index e42ebb9eb4e..2b130f2ad7b 100644 --- a/apps/docs/content/docs/en/workflows/blocks/function.mdx +++ b/apps/docs/content/docs/en/workflows/blocks/function.mdx @@ -279,10 +279,13 @@ packages, and 10 managed CLI tools. When a Function block is used as an Agent tool, its code can read every workspace secret by default — both `{{MY_SECRET}}` and `environmentVariables['MY_SECRET']`. -Use `{{MY_SECRET}}` when the value may appear in execution logs: a successful -double-brace substitution activates [execution-trace masking](/platform/credentials#execution-log-protection), -while direct `environmentVariables['MY_SECRET']` access alone does not activate -it by itself. +Prefer `{{MY_SECRET}}` when the value may appear in execution logs. A successful +double-brace substitution always activates +[execution-trace masking](/platform/credentials#execution-log-protection). A direct +`environmentVariables['MY_SECRET']` read activates it too, but only when Sim can +recognize the read in the code beforehand — a name built at runtime, or a file that +reassigns `environmentVariables` itself, is not recognized. See +[the recognition limits](/platform/credentials#execution-log-protection). To narrow that, set **Secret access** to *Selected secrets* in the block's tool configuration and pick the names the code may read. Two things change: diff --git a/apps/docs/lib/shiki-curl-json.ts b/apps/docs/lib/shiki-curl-json.ts index 9cb2b6b0275..14ea4a52660 100644 --- a/apps/docs/lib/shiki-curl-json.ts +++ b/apps/docs/lib/shiki-curl-json.ts @@ -14,10 +14,18 @@ import type { LanguageRegistration } from 'shiki' * when it owns the opening brace. Entering mid-string, keys keep `string.quoted.double.json` * and stay string-colored, which is the entire difference this exists to remove. Hence the * hand-written patterns below, which name that scope directly. - * - **A Shiki transformer.** A transformer can re-tokenize the body correctly, but it is a - * function, and the API reference's request tabs highlight in the browser off a `shikiOptions` - * object passed through RSC — where "Functions cannot be passed directly to Client - * Components". A grammar is plain data, so it reaches the client path too. + * - **A Shiki transformer.** A transformer re-tokenizes the body correctly, but it is a function, + * and `shikiOptions` is forwarded into a client component — "Functions cannot be passed + * directly to Client Components" takes down every API reference page. A grammar is plain data, + * so it survives that boundary. + * + * Applies to prose fences only, via `langs` on the MDX pipeline. Not the API reference: + * fumadocs-openapi calls `renderCodeBlock` with a hard-coded `"json"` for request and response + * samples, so a shell injection can never fire there, and its cURL usage tabs highlight in the + * browser off fumadocs' own factory — `ClientCodeBlockProvider` sits in a `"use client"` module + * the package does not expose through its `exports` map, so reaching it means importing + * `fumadocs-openapi/ui/base` from client code and dragging `remark` and + * `@fumari/json-schema-ts` into the browser bundle. That broke the deployment once. * * The opening brace requires a `}`, a quoted key, or end-of-line after it. That is what keeps * `awk '{print $1}'` out, while still matching a body whose brace ends the line — Oniguruma diff --git a/apps/sim/AGENTS.md b/apps/sim/AGENTS.md index ded59456771..3e302f53509 100644 --- a/apps/sim/AGENTS.md +++ b/apps/sim/AGENTS.md @@ -243,3 +243,13 @@ export function useEntityList(workspaceId?: string) { - **Create `utils.ts` when** 2+ files need the same helper - **Check existing sources** before duplicating (`lib/` has many utilities) - **Location**: `lib/` (app-wide) → `feature/utils/` (feature-scoped) → inline (single-use) + + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/apps/sim/app/(landing)/components/prose-page/components/legal-block-group/components/legal-block/legal-block.tsx b/apps/sim/app/(landing)/components/prose-page/components/legal-block-group/components/legal-block/legal-block.tsx index 8cbee9c18ad..0c3f4cda18d 100644 --- a/apps/sim/app/(landing)/components/prose-page/components/legal-block-group/components/legal-block/legal-block.tsx +++ b/apps/sim/app/(landing)/components/prose-page/components/legal-block-group/components/legal-block/legal-block.tsx @@ -5,8 +5,9 @@ import type { LegalBlock } from '@/app/(landing)/components/prose-page/types' /** * Renders a single {@link LegalBlock} into its canonical chrome. The block's * `kind` discriminant selects the element (paragraph / subheading `

` / - * bulleted list / callout box); all sizing and color come from `PROSE_TYPE`, so - * Terms and Privacy share one visual treatment for every block type. Content + * bulleted list / callout box / reference table); all sizing and color come + * from `PROSE_TYPE`, so Terms and Privacy share one visual treatment for every + * block type. Content * only - no layout knob. Server Component. */ @@ -35,6 +36,46 @@ export function LegalBlockView({ block }: LegalBlockViewProps) { ) case 'callout': return
{block.content}
+ case 'table': + return ( +
+ + {block.caption ? ( + + ) : null} + {block.columnWidths ? ( + + {block.columnWidths.map((width, index) => ( + + ))} + + ) : null} + + + {block.columns.map((column) => ( + + ))} + + + + {block.rows.map((row, rowIndex) => { + const rowKey = `row-${rowIndex}` + return ( + + {row.map((cell, cellIndex) => ( + + ))} + + ) + })} + +
{block.caption}
+ {column} +
+ {block.codeColumns?.includes(cellIndex) ? {cell} : cell} +
+
+ ) default: return null } diff --git a/apps/sim/app/(landing)/components/prose-page/constants.ts b/apps/sim/app/(landing)/components/prose-page/constants.ts index d388afa1f11..4fda51fc303 100644 --- a/apps/sim/app/(landing)/components/prose-page/constants.ts +++ b/apps/sim/app/(landing)/components/prose-page/constants.ts @@ -36,6 +36,16 @@ export const PROSE_SPACING = { listIndent: 'pl-6', } as const +/** + * Column widths for the cookie-inventory tables. Sibling tables sharing a header + * must be given a fixed layout or each sizes itself to its own content and the + * group reads as unaligned grids — so the widths are chrome, and live here + * rather than as class strings in a content config. + */ +export const PROSE_TABLE_WIDTHS = { + cookieInventory: ['w-[24%]', 'w-[16%]', 'w-[45%]', 'w-[15%]'], +} as const + /** * Prose type tokens - the single source of truth for every heading size, body * color, list, callout, and inline-link treatment. Centralized alongside the @@ -53,4 +63,11 @@ export const PROSE_TYPE = { callout: 'rounded-lg border border-[var(--border)] bg-[var(--surface-2)] px-4 py-3 text-[14px] text-[var(--text-body)] leading-[1.6]', link: 'text-[var(--text-primary)] underline underline-offset-2 transition-colors hover:text-[var(--text-body)]', + tableWrap: 'w-full overflow-x-auto', + tableCaption: 'pb-2 text-left text-[15px] text-[var(--text-primary)]', + table: 'w-full min-w-[560px] table-fixed border-collapse text-left', + tableHeadCell: + 'border-[var(--border)] border-b px-3 py-2 align-bottom font-medium text-[13px] text-[var(--text-primary)] first:pl-0 last:pr-0', + tableCell: + 'border-[var(--border)] border-b px-3 py-2.5 align-top text-[14px] text-[var(--text-body)] leading-[1.55] first:pl-0 last:pr-0 [&_code]:font-mono [&_code]:text-[13px]', } as const diff --git a/apps/sim/app/(landing)/components/prose-page/types.ts b/apps/sim/app/(landing)/components/prose-page/types.ts index bf8d7853e2e..b8cfc2cc505 100644 --- a/apps/sim/app/(landing)/components/prose-page/types.ts +++ b/apps/sim/app/(landing)/components/prose-page/types.ts @@ -22,6 +22,33 @@ export type LegalBlock = | { kind: 'list'; items: ReactNode[] } /** An emphasized callout box (e.g. the arbitration / GDPR notices). */ | { kind: 'callout'; content: ReactNode } + /** + * A reference table — the cookie inventory's name / provider / purpose / + * retention grid. Rows are positional against `columns`, so every row must + * have the same length as the header. + */ + | { + kind: 'table' + caption?: string + columns: string[] + /** + * Tailwind width fragments applied per column, e.g. `['w-[22%]', …]`. Set + * them whenever a page renders sibling tables with the same columns: + * without a fixed layout each table sizes itself to its own content and + * the group reads as three unaligned grids. + */ + columnWidths?: string[] + /** + * Indices of columns rendered as inline code — cookie names, config keys. + * A marker rather than a `` in the row data, because a row carries + * content and the renderer owns chrome. It is also what keeps the rows + * plain strings: biome's `useJsxKeyInIterable` fires on JSX inside an + * array literal, and the key it wants means nothing to a cell the + * renderer already keys by column. + */ + codeColumns?: number[] + rows: ReactNode[][] + } /** A numbered (or named) legal section - an `

` plus its ordered blocks. */ export interface LegalSection { diff --git a/apps/sim/app/(landing)/cookie-policy/consent-preferences-link.tsx b/apps/sim/app/(landing)/cookie-policy/consent-preferences-link.tsx new file mode 100644 index 00000000000..dac9ce50048 --- /dev/null +++ b/apps/sim/app/(landing)/cookie-policy/consent-preferences-link.tsx @@ -0,0 +1,31 @@ +'use client' + +import type { ReactNode } from 'react' +import { OPEN_CONSENT_PREFERENCES_EVENT } from '@/lib/consent/constants' +import { PROSE_TYPE } from '@/app/(landing)/components/prose-page/constants' + +interface ConsentPreferencesLinkProps { + children: ReactNode +} + +/** + * Inline control that reopens the consent banner with its category switches + * 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. + */ +export function ConsentPreferencesLink({ children }: ConsentPreferencesLinkProps) { + return ( + + ) +} diff --git a/apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx b/apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx new file mode 100644 index 00000000000..bb8bec8c565 --- /dev/null +++ b/apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx @@ -0,0 +1,295 @@ +import type { ReactNode } from 'react' +import { isHosted } from '@/lib/core/config/env-flags' +import { + type LegalBlock, + type LegalPageConfig, + ProseLink, +} from '@/app/(landing)/components/prose-page' +import { PROSE_TABLE_WIDTHS } from '@/app/(landing)/components/prose-page/constants' +import { ConsentPreferencesLink } from '@/app/(landing)/cookie-policy/consent-preferences-link' + +/** + * One cookie-inventory table per consent category. The three share a header and + * a column layout, so they are built from one shape rather than repeated. + */ +/** + * The withdrawal control, or the bare phrase on a self-hosted deployment. The + * consent runtime is hosted-only, so there the button would have no listener + * and clicking it would do nothing. + */ +const CHANGE_CHOICES: ReactNode = isHosted ? ( + change your cookie choices +) : ( + 'change your cookie choices' +) + +function cookieTable(caption: string, rows: ReactNode[][]): LegalBlock { + return { + kind: 'table', + caption, + columns: ['Cookie', 'Provider', 'Purpose', 'Retention'], + columnWidths: [...PROSE_TABLE_WIDTHS.cookieInventory], + codeColumns: [0], + rows, + } +} + +/** + * Cookie Policy content — the inventory a consent banner has to stand on, + * expressed as the typed {@link LegalPageConfig} that `ProsePage` renders, so it + * shares its layout and rhythm with Terms and Privacy and cannot drift. + * + * The tables describe what Sim and its providers actually set, grouped by the + * three categories the banner offers. Keep them in step with the banner's + * categories (`lib/consent/constants`) and with the tags configured in Google + * Tag Manager: naming a cookie the site no longer sets is as wrong as omitting + * one it does. + */ +export const COOKIE_POLICY_CONFIG: LegalPageConfig = { + title: 'Cookie Policy', + description: + 'What cookies Sim sets, why, how long they last, and how to change your choice at any time.', + lastUpdated: 'August 18, 2026', + intro: [ + { + kind: 'paragraph', + content: ( + <> + This Cookie Policy explains how Sim uses cookies and similar technologies on sim.ai and in + the Sim application, what each one does, and the choices you have. It forms part of our{' '} + Privacy Policy, which describes how we handle + personal data more broadly. + + ), + }, + { + kind: 'paragraph', + content: ( + <> + If you are in the EU, the UK, or another region where consent is required, we ask before + setting anything that is not strictly necessary. You can {CHANGE_CHOICES} at any time. + + ), + }, + ], + sections: [ + { + id: 'what-are-cookies', + heading: 'What are cookies?', + blocks: [ + { + kind: 'paragraph', + content: `A cookie is a small text file a site stores on your device so it can recognize your browser on a later request. Cookies are how a site keeps you signed in between pages, remembers a preference, or counts a visit.`, + }, + { + kind: 'list', + items: [ + <> + Session cookies are deleted when you close your browser.{' '} + Persistent cookies stay until they expire or you delete them. + , + <> + First-party cookies are set by the site you are visiting.{' '} + Third-party cookies are set by another company whose code the site + loads, such as an analytics or advertising provider. + , + ], + }, + { + kind: 'paragraph', + content: `We also use technologies that behave like cookies without being one. Local storage and session storage keep data in your browser rather than sending it with each request; pixels (also called web beacons or tags) are tiny images or scripts that record that a page or email was opened. Where this policy says "cookies", it means all of these.`, + }, + ], + }, + { + id: 'how-we-use-cookies', + heading: 'How we use cookies', + blocks: [ + { + kind: 'paragraph', + content: `We group cookies into the three categories the consent banner offers. Necessary cookies are always on because the service cannot run without them. The other two are off until you turn them on.`, + }, + { + kind: 'list', + items: [ + <> + Necessary — sign-in, session security, abuse prevention, and + remembering the choice you made in the consent banner. These do not require consent + because the service you asked for cannot be delivered without them. + , + <> + Analytics — how many people use Sim, which pages and features they + reach, and where errors happen, so we can improve the product. Measurement only; we do + not use these to target advertising. + , + <> + Marketing — measuring which campaigns bring builders to Sim and + showing relevant ads on other sites. + , + ], + }, + ], + }, + { + id: 'cookies-we-use', + heading: 'Cookies we use', + blocks: [ + { + kind: 'paragraph', + content: `Retention periods are the maximum lifetime set when the cookie is written; a cookie can be cleared sooner at any time. Third-party providers occasionally rename or re-scope their cookies, so treat the provider column as the authoritative reference for anything not set by Sim.`, + }, + cookieTable('Necessary', [ + [ + 'better-auth.session_token', + 'Sim', + 'Keeps you signed in and identifies your session.', + '30 days', + ], + [ + 'better-auth.session_data', + 'Sim', + 'Short-lived signed cache of your session so each page load does not re-read the database.', + '5 minutes', + ], + [ + 'c15t', + 'Sim (via c15t)', + 'Records the cookie choice you made so the banner is not shown again.', + '365 days', + ], + [ + 'sidebar_collapsed', + 'Sim', + 'Remembers whether the workspace sidebar is collapsed, so the layout does not jump on load.', + '1 year', + ], + [ + '__cf_bm', + 'Cloudflare', + 'Bot-management check on requests to providers we load, such as HubSpot and X.', + '30 minutes', + ], + ]), + cookieTable('Analytics', [ + ['_ga', 'Google Analytics', 'Distinguishes one visitor from another.', '13 months'], + [ + '_ga_*', + 'Google Analytics', + 'Holds the session state for a specific Analytics property.', + '13 months', + ], + ['__hstc', 'HubSpot', 'Tracks visits across sessions for the main tracker.', '6 months'], + ['hubspotutk', 'HubSpot', 'Identifies a visitor across form submissions.', '6 months'], + ['__hssc', 'HubSpot', 'Tracks the current session.', '30 minutes'], + ['__hssrc', 'HubSpot', 'Detects whether the visitor restarted their browser.', 'Session'], + ]), + cookieTable('Marketing', [ + [ + 'guest_id', + 'X (Twitter)', + 'Identifies a browser to the X conversion pixel.', + '13 months', + ], + ['guest_id_ads', 'X (Twitter)', 'Measures conversions from X advertising.', '13 months'], + [ + 'guest_id_marketing', + 'X (Twitter)', + 'Measures the performance of X marketing campaigns.', + '13 months', + ], + ['personalization_id', 'X (Twitter)', 'Personalizes the ads shown on X.', '13 months'], + ['muc_ads', 'X (Twitter)', 'Measures ad conversions across X domains.', '13 months'], + ['_gcl_*', 'Google Ads', 'Attributes a sign-up to the ad that led to it.', '90 days'], + ]), + ], + }, + { + id: 'your-choices', + heading: 'Your choices', + blocks: [ + { + kind: 'paragraph', + content: ( + <> + Where consent is required, the banner appears on your first visit with accept and + reject offered equally, and "Customize" lets you turn each category on or off + individually. To revisit that decision later — including withdrawing consent you + already gave — {CHANGE_CHOICES}. We ask again after 365 days. + + ), + }, + { + kind: 'paragraph', + content: `Independently of the banner, every major browser lets you block or delete cookies from its privacy settings, and can be set to clear them each time you close it. Blocking necessary cookies will sign you out and prevent parts of Sim from working.`, + }, + { + kind: 'paragraph', + content: `We honor Global Privacy Control (GPC). If your browser or an extension sends a GPC signal, we treat it as an instruction to opt out of analytics and marketing cookies without your having to use the banner.`, + }, + { + kind: 'paragraph', + content: ( + <> + You can also opt out with the providers directly:{' '} + + Google Analytics + + , Google Ads,{' '} + X (Twitter), + and HubSpot. + + ), + }, + ], + }, + { + id: 'third-party-cookies', + heading: 'Third-party cookies', + blocks: [ + { + kind: 'paragraph', + content: `Some cookies above are set by companies we work with rather than by Sim. We choose these providers and decide when their code loads, but the data they collect is also governed by their own policies, which we cannot change on your behalf.`, + }, + { + kind: 'paragraph', + content: ( + <> + The providers currently in use are{' '} + Google{' '} + (Analytics, Tag Manager, and Ads),{' '} + HubSpot,{' '} + X (Twitter),{' '} + Ahrefs, and{' '} + Cloudflare. + + ), + }, + ], + }, + { + id: 'changes-to-this-policy', + heading: 'Changes to this policy', + blocks: [ + { + kind: 'paragraph', + content: `We update this policy when the cookies we set change, and we revise the "Last updated" date above whenever we do. If a change materially widens what we collect, we will ask for your consent again rather than rely on a choice you made under the previous version.`, + }, + ], + }, + { + id: 'contact', + heading: 'Contact', + blocks: [ + { + kind: 'paragraph', + content: ( + <> + Questions about this policy, or about how we use cookies, can go to{' '} + privacy@sim.ai. + + ), + }, + ], + }, + ], +} diff --git a/apps/sim/app/(landing)/cookie-policy/cookie-policy.tsx b/apps/sim/app/(landing)/cookie-policy/cookie-policy.tsx new file mode 100644 index 00000000000..1214fb5b2d0 --- /dev/null +++ b/apps/sim/app/(landing)/cookie-policy/cookie-policy.tsx @@ -0,0 +1,12 @@ +import { ProsePage } from '@/app/(landing)/components/prose-page' +import { COOKIE_POLICY_CONFIG } from '@/app/(landing)/cookie-policy/cookie-policy-content' + +/** + * Cookie Policy page - a thin consumer of the shared {@link ProsePage} + * primitive, alongside Terms and Privacy. The whole document is one typed + * config ({@link COOKIE_POLICY_CONFIG}) rendered inside the shared route-group + * layout chrome, so the three legal pages share a layout and cannot drift. + */ +export default function CookiePolicy() { + return +} diff --git a/apps/sim/app/(landing)/cookie-policy/page.tsx b/apps/sim/app/(landing)/cookie-policy/page.tsx new file mode 100644 index 00000000000..703faf0b2d4 --- /dev/null +++ b/apps/sim/app/(landing)/cookie-policy/page.tsx @@ -0,0 +1,18 @@ +import { buildLandingMetadata } from '@/lib/landing/seo' +import CookiePolicy from '@/app/(landing)/cookie-policy/cookie-policy' + +export const revalidate = 3600 + +const TITLE = 'Cookie Policy | Sim, the AI Workspace' +const DESCRIPTION = + 'What cookies Sim sets, why, how long they last, and how to change your choice at any time.' + +export const metadata = buildLandingMetadata({ + title: TITLE, + description: DESCRIPTION, + path: '/cookie-policy', +}) + +export default function Page() { + return +} diff --git a/apps/sim/app/(landing)/privacy/privacy-content.tsx b/apps/sim/app/(landing)/privacy/privacy-content.tsx index f72fb96d3b5..bf5240e4695 100644 --- a/apps/sim/app/(landing)/privacy/privacy-content.tsx +++ b/apps/sim/app/(landing)/privacy/privacy-content.tsx @@ -10,7 +10,7 @@ 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: 'October 11, 2025', + lastUpdated: 'August 18, 2026', intro: [ { kind: 'paragraph', @@ -214,6 +214,16 @@ export const PRIVACY_CONFIG: LegalPageConfig = { 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.`, }, + { + 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. + + ), + }, ], }, { diff --git a/apps/sim/app/_shell/consent/consent-banner.tsx b/apps/sim/app/_shell/consent/consent-banner.tsx new file mode 100644 index 00000000000..3fbeab7f772 --- /dev/null +++ b/apps/sim/app/_shell/consent/consent-banner.tsx @@ -0,0 +1,123 @@ +'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. */ +const EASE = [0.22, 1, 0.36, 1] as const +const ENTER_TRANSITION = { duration: 0.28, ease: EASE } as const +const EXPAND_TRANSITION = { duration: 0.22, ease: EASE } as const + +const CATEGORIES_COLLAPSED = { height: 0, opacity: 0 } as const +const CATEGORIES_OPEN = { height: 'auto', opacity: 1 } as const + +/** + * Cookie consent banner — a non-modal card docked bottom-left, opposite the + * toast stack and wearing the same chrome. It never dims, blocks, or reflows + * the page, and "Customize" expands this same card into the per-category + * switches rather than opening a dialog over the app. + * + * Visibility and the available actions come from the jurisdiction policy the + * consent runtime resolves, so the banner is absent entirely where no consent + * is required and never offers an action the policy does not allow. Accept and + * reject carry identical weight, which GDPR requires. + * + * 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. + */ +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 + const enterOffset = prefersReducedMotion ? 0 : 8 + + return ( + + {(banner.isVisible || dialog.isVisible) && ( + +
+

Cookies

+

+ We use cookies to run Sim, understand how it is used, and improve it. Read our{' '} + + Cookie Policy + + . +

+
+ + + {isExpanded && ( + + + + )} + + + {/* Two clusters, not `mr-auto` on the chip: chips carry no outer margin. */} +
+
+ {!isExpanded && allowedActions.includes('customize') && ( + Customize + )} +
+
+ {allowedActions.includes('reject') && ( + void performAction('reject', { surface: surfaceName })} + > + Reject all + + )} + {allowedActions.includes('accept') && ( + void performAction('accept', { surface: surfaceName })} + > + Accept all + + )} + {isExpanded && ( + void saveCustomPreferences()}> + Save + + )} +
+
+
+ )} +
+ ) +} diff --git a/apps/sim/app/_shell/consent/consent-preferences.tsx b/apps/sim/app/_shell/consent/consent-preferences.tsx new file mode 100644 index 00000000000..faf620b1767 --- /dev/null +++ b/apps/sim/app/_shell/consent/consent-preferences.tsx @@ -0,0 +1,108 @@ +'use client' + +import { useConsentManager } from '@c15t/nextjs/headless' +import { Label, Switch } from '@sim/emcn' +import type { ConsentCategory } from '@/lib/consent/constants' + +/** + * Inline link chrome for the consent surfaces, matching `PROSE_TYPE.link` on the + * legal pages. Copied rather than imported because both consumers sit outside + * the landing route group that owns that token, and defined here — the module + * they already share — so the copy exists once. + */ +export const CONSENT_LINK_CLASS = + 'text-[var(--text-primary)] underline underline-offset-2 transition-colors hover:text-[var(--text-body)]' + +interface ConsentCategoryCopy { + title: string + description: string +} + +/** + * Sim's own wording per category. The runtime ships generic descriptions; these + * say what the cookies actually do here. + * + * Typed by name rather than by {@link ConsentCategory} because the runtime's + * union is wider than the three categories we configure — a policy that adds + * one server-side falls back to the runtime's description instead of + * disappearing. The `satisfies` still requires an entry for each of ours. + */ +const CONSENT_CATEGORY_COPY: Record = { + necessary: { + title: 'Necessary', + description: 'Sign-in and security. Always on.', + }, + measurement: { + title: 'Analytics', + description: 'Shows us how Sim is used so we can make it better.', + }, + marketing: { + title: 'Marketing', + description: 'Measures which campaigns bring builders to Sim.', + }, +} satisfies Record + +/** The runtime's category union, without re-declaring it. */ +type ConsentCategoryName = Parameters['setSelectedConsent']>[0] + +interface ConsentPreferencesProps { + /** + * Called after a switch stages its new value, for a surface that commits per + * toggle. `revert` puts the category back, for a commit that then fails. The + * banner omits this and commits from its own footer instead. + */ + onChange?: (change: { name: ConsentCategoryName; revert: () => void }) => void + /** Locks every switch, e.g. while a commit is in flight. */ + disabled?: boolean +} + +/** + * The per-category consent switches, shared by the two surfaces that offer + * them: the banner's expanded state and the Privacy settings page. Both write + * to `selectedConsents`; whether that is then committed is the caller's, via + * {@link ConsentPreferencesProps.onChange}. + * + * Must be rendered inside a `ConsentStoreProvider`. + */ +export function ConsentPreferences({ onChange, disabled = false }: ConsentPreferencesProps) { + const { consents, selectedConsents, setSelectedConsent, getDisplayedConsents } = + useConsentManager() + + /** + * The store's own selector, not a hand-rolled filter over `consentTypes`: the + * shipped defaults mark every category except `necessary` as `display: false`, + * so filtering on that flag silently renders a one-row list. + */ + const categories = getDisplayedConsents() + + return ( +
    + {categories.map((type) => { + const copy = CONSENT_CATEGORY_COPY[type.name] + const inputId = `consent-${type.name}` + return ( +
  • +
    + +

    + {copy?.description ?? type.description} +

    +
    + { + setSelectedConsent(type.name, checked) + onChange?.({ + name: type.name, + revert: () => setSelectedConsent(type.name, !checked), + }) + }} + /> +
  • + ) + })} +
+ ) +} diff --git a/apps/sim/app/_shell/consent/consent-provider.test.tsx b/apps/sim/app/_shell/consent/consent-provider.test.tsx new file mode 100644 index 00000000000..b4ad6f0f87b --- /dev/null +++ b/apps/sim/app/_shell/consent/consent-provider.test.tsx @@ -0,0 +1,69 @@ +/** + * @vitest-environment jsdom + */ +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('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 + } + }, +})) + +import { ConsentProvider } from '@/app/_shell/consent/consent-provider' + +let root: Root | null = null + +function renderAt(pathname: string): HTMLDivElement { + mockPathname.mockReturnValue(pathname) + ;(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()) + return container +} + +afterEach(() => { + act(() => root?.unmount()) + root = null + vi.clearAllMocks() +}) + +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() + } + ) +}) diff --git a/apps/sim/app/_shell/consent/consent-provider.tsx b/apps/sim/app/_shell/consent/consent-provider.tsx new file mode 100644 index 00000000000..97e544005c1 --- /dev/null +++ b/apps/sim/app/_shell/consent/consent-provider.tsx @@ -0,0 +1,43 @@ +'use client' + +import dynamic from 'next/dynamic' +import { usePathname } from 'next/navigation' + +/** + * 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' + +/** + * 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. + */ +export function ConsentProvider() { + const pathname = usePathname() + + if (pathname.split('/')[1] === WORKSPACE_SEGMENT) { + return null + } + + return +} diff --git a/apps/sim/app/_shell/consent/consent-runtime.tsx b/apps/sim/app/_shell/consent/consent-runtime.tsx new file mode 100644 index 00000000000..243c1ed6b66 --- /dev/null +++ b/apps/sim/app/_shell/consent/consent-runtime.tsx @@ -0,0 +1,16 @@ +'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 new file mode 100644 index 00000000000..69837317939 --- /dev/null +++ b/apps/sim/app/_shell/consent/consent-store-provider.test.tsx @@ -0,0 +1,53 @@ +/** + * @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 { mockConsentManagerProvider } = vi.hoisted(() => ({ mockConsentManagerProvider: vi.fn() })) + +vi.mock('@c15t/nextjs/headless', () => ({ + ConsentManagerProvider: (props: { children: ReactNode; options: unknown }) => { + mockConsentManagerProvider(props.options) + return props.children + }, +})) + +import { ConsentStoreProvider } from '@/app/_shell/consent/consent-store-provider' + +let root: Root | null = null + +afterEach(() => { + act(() => root?.unmount()) + root = null + vi.clearAllMocks() +}) + +describe('ConsentStoreProvider', () => { + it('configures the hosted consent backend with the iframe blocker off', () => { + ;(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( + + + + ) + ) + + expect(container.querySelector('[data-testid="child"]')).not.toBeNull() + // `toMatchObject`, not exact equality: `DEV_CONSENT_COUNTRY` adds an + // `overrides` key whenever a developer has NEXT_PUBLIC_CONSENT_COUNTRY set + // locally, and the assertion is about the shipped configuration. + expect(mockConsentManagerProvider.mock.calls[0]?.[0]).toMatchObject({ + mode: 'hosted', + backendURL: 'https://sim-sim.inth.app', + consentCategories: ['necessary', 'measurement', 'marketing'], + store: { 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 new file mode 100644 index 00000000000..09791fd56f4 --- /dev/null +++ b/apps/sim/app/_shell/consent/consent-store-provider.tsx @@ -0,0 +1,45 @@ +'use client' + +import type { ReactNode } from 'react' +import { type ConsentManagerOptions, ConsentManagerProvider } from '@c15t/nextjs/headless' +import { + CONSENT_BACKEND_URL, + CONSENT_CATEGORIES, + DEV_CONSENT_COUNTRY, +} from '@/lib/consent/constants' + +/** + * Imported from `@c15t/nextjs/headless`, not the package root: the headless + * entry leaves the runtime's own components and stylesheet out of the bundle, + * so `ConsentBanner` is the only consent UI that exists. The provider still + * injects a `