mirror of
https://github.com/simstudioai/sim.git
synced 2026-08-29 02:27:35 +08:00
v0.8.11: perf improvements, instant chat navigation, code hygiene
This commit is contained in:
@@ -33,6 +33,16 @@ The menu surface intentionally diverges from the pill: `dropdown-menu.tsx` items
|
||||
- **`ChipTimePicker`** — minute-granular time sibling of `ChipDatePicker`, a `ChipInput` that leniently parses typed input (`9:47`, `947`, `2:05pm`, `14:30`), commits on Enter/blur, and re-renders the canonical `9:47 AM` label.
|
||||
- **`DropdownMenu`** — the canonical context/action menu (Radix-backed). Not a chip, but the standard menu for command/action lists; reach for it instead of a hand-rolled popover. Its surface intentionally diverges from the chip pill (`text-small`, `gap-2`) — keep them distinct. For a pill that opens a value picker, use `ChipDropdown`/`ChipSelect` instead.
|
||||
|
||||
## Modal keyboard defaults
|
||||
|
||||
Declare keyboard intent on the action-owning primitive; never add document-level or per-callsite Enter listeners.
|
||||
|
||||
- `ChipModalFooter` defaults to `defaultAction='primary'`. A plain Enter in a canonical single-line field or a custom plain input invokes the enabled primary action. Use `'none'` when submission must require an explicit click, such as an irreversible destructive action or an editor whose nested control owns Enter. Use `'dismiss'` only when dismissal is genuinely the modal's default decision.
|
||||
- `ChipConfirmModal` fails safe with `defaultAction='dismiss'`. Opt into `'confirm'` only for an audited, low-impact reversible or non-destructive decision. Deleting an aggregate resource such as a workflow, table, knowledge base, or folder remains `'dismiss'` even when it can be restored, because the action takes a broad dependent graph offline. Use `'none'` for typed confirmations and severe account, ownership, or access changes. Button color never determines keyboard behavior.
|
||||
- Textareas, native forms, buttons, links, comboboxes, menus, listboxes, tag/email inputs, IME composition, modified Enter, and disabled or pending actions retain their native behavior. A native form remains the sole submission path so browser validation is not bypassed.
|
||||
- A custom field containing a search, token editor, or another input that owns Enter must set `submitOnEnter={false}` on `ChipModalField`. Do not attach a duplicate `onKeyDown` handler merely to call the footer action.
|
||||
- Initial focus goes to the first visible editable text control. With no text control, the declared real button receives focus; `'none'` focuses the dialog surface. A safe dismiss default never turns Enter in a text field into data loss—the field simply does not publish a submit action.
|
||||
|
||||
## Authoring principles
|
||||
|
||||
- **One source of truth for shared chrome.** Compose from `chip-chrome.ts` / `chipVariants`; never duplicate the chrome string.
|
||||
|
||||
@@ -90,6 +90,19 @@ const [{ id }, { kbName }] = await Promise.all([params, searchParams])
|
||||
|
||||
Only keep awaits sequential when a later call genuinely uses an earlier result, or when the ordering is deliberate (rate-limited batches, retry loops, write-then-read).
|
||||
|
||||
## Prefetch dynamic destination lists on intent
|
||||
|
||||
For long lists of dynamic destinations, do not viewport-prefetch every row and do not assume
|
||||
`router.prefetch()` warms the full route: in Next 16 it uses the automatic/PPR strategy. Gate
|
||||
`<Link prefetch={true}>` behind deliberate hover or keyboard focus, and prefetch destination
|
||||
server state with the consumer's shared React Query options. A short, cancelable hover dwell
|
||||
avoids drive-by downloads. Do not treat `touchstart` as intent because it also begins scrolling;
|
||||
let the actual unmodified click start the data request.
|
||||
|
||||
If a continuity-focused surface intentionally omits `loading.tsx` so the current view remains
|
||||
mounted until its peer is ready, the intent path must warm both the full route and its critical
|
||||
data. Otherwise keep the loading boundary so dynamic navigation remains responsive.
|
||||
|
||||
## Local feature barrels are the convention — do not "fix" them
|
||||
|
||||
Tooling (e.g. react-doctor's `no-barrel-import`) will flag imports from local `index.ts` barrels as a bundle cost. In this repo that is a **false positive**: barrel imports for 3+ export folders are mandated by `.claude/rules/sim-imports.md`. Leave them.
|
||||
|
||||
@@ -13,8 +13,9 @@ The Next.js `settings/[section]/layout.tsx` owns all settings page chrome via
|
||||
`SettingsHeaderShell` — a fixed header bar (a left back chip + right-aligned
|
||||
action chips), a scroll region, and a centered `max-w-[48rem]` content column led
|
||||
by a **title + description from navigation metadata**. The chrome stays mounted
|
||||
across section navigation (it never re-renders or re-lays-out). Each section
|
||||
renders through the **`SettingsPanel`** registrar
|
||||
across section navigation. Its routed title and description are available before
|
||||
the section body resolves. Each section renders through the **`SettingsPanel`**
|
||||
registrar
|
||||
(`@/app/workspace/[workspaceId]/settings/components/settings-panel`), which feeds
|
||||
the shell its header data and renders only the section body. Sections supply
|
||||
**data**, never chrome.
|
||||
@@ -82,6 +83,9 @@ return (
|
||||
`children` instead and omit the prop.
|
||||
- `title?` / `description?` — overrides for the nav-driven defaults. **Only** for a
|
||||
detail sub-view that needs a different heading; normal pages never pass these.
|
||||
A top-level page's header identity must remain stable while its data loads:
|
||||
never replace navigation metadata with client-fetched copy after first paint.
|
||||
Put data-dependent context in the page body instead.
|
||||
- `scrollContainerRef?: React.Ref<HTMLDivElement>` — forwards a ref to the scroll
|
||||
region (e.g. programmatic scroll-to-bottom).
|
||||
|
||||
|
||||
@@ -143,6 +143,11 @@ import KnowledgeBaseLoading from '@/app/workspace/[workspaceId]/knowledge/[id]/l
|
||||
|
||||
Reference: `apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx`.
|
||||
|
||||
The narrow exception is a continuity-focused peer switch that deliberately keeps the current
|
||||
view mounted and follows the full-route plus critical-data intent-prefetch rule in
|
||||
`sim-react-performance.md`. It still needs a real in-page Suspense fallback; it only omits the
|
||||
route-level `loading.tsx` that would replace the current peer before the destination is ready.
|
||||
|
||||
This applies to **page entries**. An inner `<Suspense>` wrapping a `lazy()` component is the exception: there `fallback={null}` is correct, precisely so the suspend resolves at the nearest boundary instead of flashing the whole route — see `sim-imports.md`, "Code-splitting through barrels".
|
||||
|
||||
## Debounced text inputs
|
||||
|
||||
@@ -14,8 +14,18 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
# The diff-based audits below need a base commit to read, and the default
|
||||
# depth of 1 clones a single commit with no parent. They normally fetch
|
||||
# their base by SHA (see "Resolve base ref"), so this depth only covers the
|
||||
# `HEAD~1` fallback — but without it that fallback resolves to nothing.
|
||||
#
|
||||
# Worth stating because the failure was invisible for so long: the migration
|
||||
# audit read the resulting `git diff` failure as "no migrations changed" and
|
||||
# exited 0, so it had never actually run on a push build.
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
@@ -104,15 +114,40 @@ jobs:
|
||||
|
||||
echo "✅ All env flags are properly configured"
|
||||
|
||||
- name: Check block registry invariants
|
||||
# One fetch for both base-ref audits, and no `|| true`: a swallowed fetch leaves
|
||||
# the base ref absent, which neither audit can tell apart from a branch that
|
||||
# changed nothing. The block-registry check at least degrades to a visible
|
||||
# `⚠ … skipping` line; the migration audit printed `✓ No new migrations to
|
||||
# check` and exited 0, clearing the only guard on production DDL.
|
||||
#
|
||||
# Depth stays at 1 — without a merge-base the migration audit diffs the two
|
||||
# tips, which under `--diff-filter=AM` is exactly the migrations new here.
|
||||
# Resolved once for both diff-based audits, and never with `|| true`: a
|
||||
# swallowed fetch leaves the base absent, which neither audit can tell apart
|
||||
# from a branch that changed nothing.
|
||||
#
|
||||
# On push the base is `github.event.before`, the tip the branch had before
|
||||
# this push — not `HEAD~1`, which names only the last commit and would let a
|
||||
# multi-commit push slip every earlier commit's migrations past the audit.
|
||||
# It is fetched by SHA at depth 1; the audits diff two tips and need no
|
||||
# common ancestry. An all-zero `before` means the branch is new and has no
|
||||
# predecessor to diff, so `HEAD~1` remains the fallback there.
|
||||
- name: Resolve base ref for diff-based audits
|
||||
id: audit_base
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
BASE_REF="origin/${{ github.base_ref }}"
|
||||
git fetch --depth=1 origin "${{ github.base_ref }}" 2>/dev/null || true
|
||||
git fetch --depth=1 origin "${{ github.base_ref }}"
|
||||
echo "ref=origin/${{ github.base_ref }}" >> "$GITHUB_OUTPUT"
|
||||
elif [ -n "${{ github.event.before }}" ] &&
|
||||
[ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]; then
|
||||
git fetch --depth=1 origin "${{ github.event.before }}"
|
||||
echo "ref=${{ github.event.before }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
BASE_REF="HEAD~1"
|
||||
echo "ref=HEAD~1" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
bun run apps/sim/scripts/check-block-registry.ts "$BASE_REF"
|
||||
|
||||
- name: Check block registry invariants
|
||||
run: bun run apps/sim/scripts/check-block-registry.ts "${{ steps.audit_base.outputs.ref }}"
|
||||
|
||||
- name: Lint code
|
||||
run: bun run lint:check
|
||||
@@ -127,14 +162,7 @@ jobs:
|
||||
run: bun run docs-manifest:check
|
||||
|
||||
- name: Migration safety (zero-downtime) audit
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
BASE_REF="origin/${{ github.base_ref }}"
|
||||
git fetch --depth=1 origin "${{ github.base_ref }}" 2>/dev/null || true
|
||||
else
|
||||
BASE_REF="HEAD~1"
|
||||
fi
|
||||
bun run check:migrations "$BASE_REF"
|
||||
run: bun run check:migrations "${{ steps.audit_base.outputs.ref }}"
|
||||
|
||||
# Every workspace, not just realtime. packages/emcn, packages/utils,
|
||||
# apps/desktop and apps/docs had no type check in CI at all; apps/sim's
|
||||
|
||||
@@ -88,6 +88,7 @@ npx sim-setup add sandbox
|
||||
npx sim-setup add jobs
|
||||
npx sim-setup add cache
|
||||
npx sim-setup add knowledge
|
||||
npx sim-setup add chat
|
||||
npx sim-setup add llm
|
||||
npx sim-setup add integration slack
|
||||
```
|
||||
|
||||
@@ -8,6 +8,7 @@ interface DocsContainerData {
|
||||
name: string
|
||||
blockType: string
|
||||
size?: { width: number; height: number }
|
||||
parentId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,6 +25,7 @@ export const DocsContainerNode = memo(function DocsContainerNode({
|
||||
name: data.name,
|
||||
width: data.size?.width,
|
||||
height: data.size?.height,
|
||||
parentId: data.parentId,
|
||||
isPreview: true,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { BLOCK_Z_BASE, CONTAINER_CHILD_Z_BASE, getEdgeZIndex } from '@sim/workflow-renderer'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { type PreviewBlock, type PreviewWorkflow, toReactFlowElements } from './workflow-data'
|
||||
|
||||
const block = (
|
||||
overrides: Partial<PreviewBlock> & Pick<PreviewBlock, 'id' | 'type'>
|
||||
): PreviewBlock => ({
|
||||
name: overrides.id,
|
||||
bgColor: '#000000',
|
||||
rows: [],
|
||||
position: { x: 0, y: 0 },
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const workflow: PreviewWorkflow = {
|
||||
id: 'nested-subflows',
|
||||
name: 'Nested subflows',
|
||||
blocks: [
|
||||
block({ id: 'start', type: 'starter' }),
|
||||
block({ id: 'loop', type: 'loop', size: { width: 500, height: 300 } }),
|
||||
block({
|
||||
id: 'parallel',
|
||||
type: 'parallel',
|
||||
parentId: 'loop',
|
||||
position: { x: 24, y: 64 },
|
||||
size: { width: 400, height: 200 },
|
||||
}),
|
||||
block({ id: 'agent', type: 'agent', parentId: 'loop', position: { x: 24, y: 140 } }),
|
||||
],
|
||||
edges: [
|
||||
{ id: 'start-loop', source: 'start', target: 'loop' },
|
||||
{ id: 'loop-parallel', source: 'loop', target: 'parallel' },
|
||||
{ id: 'loop-agent', source: 'loop', target: 'agent' },
|
||||
],
|
||||
}
|
||||
|
||||
describe('toReactFlowElements layering', () => {
|
||||
it('places incoming edges on their container target layer', () => {
|
||||
const { nodes, edges } = toReactFlowElements(workflow, false, {
|
||||
highlightEdge: 'loop-parallel',
|
||||
})
|
||||
const nodeById = new Map(nodes.map((node) => [node.id, node]))
|
||||
const edgeById = new Map(edges.map((edge) => [edge.id, edge]))
|
||||
|
||||
expect(nodeById.get('loop')?.zIndex).toBe(0)
|
||||
expect(nodeById.get('parallel')?.zIndex).toBe(1)
|
||||
expect(edgeById.get('start-loop')?.zIndex).toBe(0)
|
||||
expect(edgeById.get('loop-parallel')?.zIndex).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps ordinary cards above normally layered edges', () => {
|
||||
const { nodes, edges } = toReactFlowElements(workflow)
|
||||
const nodeById = new Map(nodes.map((node) => [node.id, node]))
|
||||
const edgeById = new Map(edges.map((edge) => [edge.id, edge]))
|
||||
|
||||
expect(nodeById.get('start')?.zIndex).toBe(BLOCK_Z_BASE)
|
||||
expect(nodeById.get('agent')?.zIndex).toBe(CONTAINER_CHILD_Z_BASE)
|
||||
expect(edgeById.get('loop-agent')?.zIndex).toBe(getEdgeZIndex(0))
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
BLOCK_Z_BASE,
|
||||
CONTAINER_CHILD_Z_BASE,
|
||||
getEdgeZIndex,
|
||||
getEdgeZIndexForTarget,
|
||||
} from '@sim/workflow-renderer'
|
||||
import { type Edge, type Node, Position } from 'reactflow'
|
||||
|
||||
/**
|
||||
@@ -61,6 +67,24 @@ export interface HighlightOptions {
|
||||
selectedBlock?: string
|
||||
}
|
||||
|
||||
/** Semantic container depth used for z-order while docs positions stay flattened. */
|
||||
function getNestingDepth(block: PreviewBlock, blocksById: Map<string, PreviewBlock>): number {
|
||||
let depth = 0
|
||||
let parentId = block.parentId
|
||||
const visited = new Set<string>()
|
||||
|
||||
while (parentId && !visited.has(parentId)) {
|
||||
const parent = blocksById.get(parentId)
|
||||
if (!parent) break
|
||||
|
||||
visited.add(parentId)
|
||||
depth += 1
|
||||
parentId = parent.parentId
|
||||
}
|
||||
|
||||
return depth
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a {@link PreviewWorkflow} to React Flow nodes and edges.
|
||||
*
|
||||
@@ -81,6 +105,7 @@ export function toReactFlowElements(
|
||||
|
||||
const nodes: Node[] = workflow.blocks.map((block, index) => {
|
||||
const isContainer = Boolean(block.size)
|
||||
const nestingDepth = getNestingDepth(block, blocksById)
|
||||
// Nested blocks are authored relative to their container; render them at
|
||||
// absolute coordinates (not React Flow parentNode children) so the edges
|
||||
// between a container and its nested blocks render reliably and on top.
|
||||
@@ -92,7 +117,7 @@ export function toReactFlowElements(
|
||||
id: block.id,
|
||||
type: isContainer ? 'previewContainer' : 'previewBlock',
|
||||
position,
|
||||
zIndex: isContainer ? 0 : 1,
|
||||
zIndex: isContainer ? nestingDepth : block.parentId ? CONTAINER_CHILD_Z_BASE : BLOCK_Z_BASE,
|
||||
...(block.size ? { style: { width: block.size.width, height: block.size.height } } : {}),
|
||||
data: {
|
||||
name: block.name,
|
||||
@@ -103,6 +128,7 @@ export function toReactFlowElements(
|
||||
tools: block.tools,
|
||||
hideTargetHandle: block.hideTargetHandle,
|
||||
size: block.size,
|
||||
parentId: block.parentId,
|
||||
index,
|
||||
animate,
|
||||
isHighlighted: highlightBlock === block.id || selectedBlock === block.id,
|
||||
@@ -127,6 +153,14 @@ export function toReactFlowElements(
|
||||
// so edges into and out of Loop/Parallel containers still connect.
|
||||
const sourceBlock = blocksById.get(e.source)
|
||||
const targetBlock = blocksById.get(e.target)
|
||||
const parentContainer = blocksById.get(sourceBlock?.parentId ?? targetBlock?.parentId ?? '')
|
||||
const baseZIndex = getEdgeZIndex(
|
||||
parentContainer ? getNestingDepth(parentContainer, blocksById) : undefined,
|
||||
{ isHighlighted: isEdgeHighlight }
|
||||
)
|
||||
const targetContainerZIndex = targetBlock?.size
|
||||
? getNestingDepth(targetBlock, blocksById)
|
||||
: undefined
|
||||
const sourceHandle =
|
||||
e.sourceHandle ?? (sourceBlock?.size ? `${sourceBlock.type}-end-source` : 'source')
|
||||
const targetHandle = targetBlock?.size ? undefined : 'target'
|
||||
@@ -142,6 +176,7 @@ export function toReactFlowElements(
|
||||
},
|
||||
sourceHandle,
|
||||
targetHandle,
|
||||
zIndex: getEdgeZIndexForTarget(baseZIndex, targetContainerZIndex),
|
||||
data: {
|
||||
animate,
|
||||
delay: animate ? sourceIndex * BLOCK_STAGGER + BLOCK_STAGGER : 0,
|
||||
|
||||
@@ -119,12 +119,27 @@ Click **Details** on any secret row to open its detail view.
|
||||
From here you can:
|
||||
|
||||
- View the **Key** and edit the **Value**
|
||||
- Toggle **Visibility** — show the value unmasked in run output; see [Visibility](#visibility)
|
||||
- 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.
|
||||
|
||||
### Visibility
|
||||
|
||||
By default, a secret's resolved value is masked everywhere Sim shows run output (see [Execution log protection](#execution-log-protection)). For values that aren't actually sensitive — a staging key, a shared base URL — that masking makes your own logs harder to read.
|
||||
|
||||
**Show value in logs and Chat** turns masking off for one workspace secret. With it on:
|
||||
|
||||
- Run logs, Chat, and code output show the real value instead of `{{KEY}}`
|
||||
- Files a run writes with the value in them stay readable and attachable
|
||||
- The Secrets API list includes the value for this secret, so external agents can read it directly instead of scraping logs
|
||||
|
||||
The value becomes visible to **anyone who can see this workspace's runs** — including publicly shared log links and log exports, and regardless of member restrictions on the secret itself. Only turn it on for values you'd be comfortable printing in a log.
|
||||
|
||||
The switch applies to future runs only. Logs written while the secret was masked stay masked, and anything written while it was visible keeps the value even if you turn masking back on. If another secret holds the same value, that value stays masked — masking always wins a conflict. Workspace secrets only; the same people who can edit the description can flip it.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -134,6 +134,26 @@ OLLAMA_URL=http://192.168.1.100:11434 docker compose -f docker-compose.prod.yml
|
||||
Inside Docker, `localhost` refers to the container, not your host. Use `host.docker.internal` or your host's IP.
|
||||
</Callout>
|
||||
|
||||
### LM Studio
|
||||
|
||||
[LM Studio exposes an OpenAI-compatible API](https://lmstudio.ai/docs/developer/openai-compat). Start its local server, load a model, and enable **Serve on Local Network** so the Docker container can reach it. [Enable API authentication](https://lmstudio.ai/docs/developer/core/authentication), then set the endpoint and token in the `.env` file next to your Compose file:
|
||||
|
||||
```bash
|
||||
# macOS/Windows
|
||||
VLLM_BASE_URL=http://host.docker.internal:1234
|
||||
|
||||
# Linux - use your host IP instead
|
||||
# VLLM_BASE_URL=http://192.168.1.100:1234
|
||||
|
||||
VLLM_API_KEY=your_lm_studio_api_token
|
||||
```
|
||||
|
||||
Both the server root shown above and a URL ending in `/v1` are accepted. After recreating the `simstudio` service, its models appear in the model picker with a `vllm/` prefix; Sim removes that prefix before sending the model identifier to LM Studio.
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.ollama.yml up -d --force-recreate simstudio
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
|
||||
@@ -81,8 +81,8 @@ import { Callout } from 'fumadocs-ui/components/callout'
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `VLLM_BASE_URL` | vLLM server URL, **without** a `/v1` suffix (e.g. `http://localhost:8000`) — Sim appends `/v1` itself |
|
||||
| `VLLM_API_KEY` | Optional bearer token for vLLM |
|
||||
| `VLLM_BASE_URL` | OpenAI-compatible vLLM or LM Studio URL. Both the server root (`http://localhost:8000`) and versioned API URL (`http://localhost:8000/v1`) are accepted |
|
||||
| `VLLM_API_KEY` | Optional bearer token for the vLLM or LM Studio endpoint |
|
||||
| `LITELLM_BASE_URL` | LiteLLM proxy base URL |
|
||||
| `LITELLM_API_KEY` | Optional bearer token for LiteLLM |
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ Sim is self-contained for the core editor and execution engine. A few features r
|
||||
| Feature | Requires | Notes |
|
||||
|---|---|---|
|
||||
| **Knowledge bases** | An OpenAI, Azure OpenAI, or Gemini API key | Embeddings are generated by a hosted provider, selected with `KB_EMBEDDING_MODEL` (`text-embedding-3-small` by default). There is no local embedding backend — knowledge bases are unavailable without one of these keys. |
|
||||
| **Agent blocks** | An API key for at least one model provider | Or a self-hosted OpenAI-compatible endpoint: Ollama, vLLM, or LiteLLM. |
|
||||
| **Agent blocks** | An API key for at least one model provider | Or a self-hosted OpenAI-compatible endpoint: Ollama, vLLM, LM Studio, or LiteLLM. |
|
||||
| **Chat module** | `COPILOT_API_KEY` from sim.ai | Set `NEXT_PUBLIC_CHAT_DISABLED=true` to hide the module instead. |
|
||||
| **Integrations** | Your own OAuth app per service | See [Integrations & OAuth](/platform/self-hosting/integrations-oauth). |
|
||||
| **Remote Function / Pi execution** | Optional E2B or Daytona key | Without one, JavaScript Function code that has no `import` or `require` still runs in the in-process isolated VM. Python, Shell, JavaScript with external imports, custom Function Sandboxes, and Pi require a configured remote provider. See [Security](/platform/self-hosting/security). |
|
||||
@@ -126,4 +126,3 @@ Sim is self-contained for the core editor and execution engine. A few features r
|
||||
{ question: "What are the required environment variables for production?", answer: "Three secrets are required: BETTER_AUTH_SECRET (authentication), ENCRYPTION_KEY (data encryption), and INTERNAL_API_SECRET (service-to-service auth). Generate each with openssl rand -hex 32. You also need to set NEXT_PUBLIC_APP_URL and BETTER_AUTH_URL to your domain."},
|
||||
{ question: "Can I use Sim with local AI models?", answer: "Yes. Sim supports Ollama for local model inference. Use docker-compose.ollama.yml instead of docker-compose.prod.yml. It offers both GPU (with NVIDIA support) and CPU-only profiles, and automatically pulls gemma3:4b as a starter model." },
|
||||
]} />
|
||||
|
||||
|
||||
@@ -25,6 +25,21 @@ OLLAMA_URL=http://host.docker.internal:11434 # macOS/Windows
|
||||
OLLAMA_URL=http://192.168.1.x:11434 # Linux (use actual IP)
|
||||
```
|
||||
|
||||
## LM Studio Requests Route to Ollama
|
||||
|
||||
Sim identifies dynamically discovered LM Studio and vLLM models by their `vllm/` prefix. If the endpoint is unavailable and you manually enter the raw LM Studio model identifier, Sim treats that unknown identifier as an Ollama model.
|
||||
|
||||
1. Confirm `VLLM_BASE_URL` is available inside the app container:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.ollama.yml exec simstudio printenv VLLM_BASE_URL
|
||||
```
|
||||
|
||||
2. In LM Studio, enable **Serve on Local Network** and API authentication so the container can connect safely.
|
||||
3. From Docker on macOS or Windows, use `http://host.docker.internal:1234` rather than `localhost`. On Linux, use the host IP.
|
||||
4. The server root and a URL ending in `/v1` are both accepted.
|
||||
5. Recreate `simstudio`, reload the workspace, and select the discovered `vllm/<model-id>` option from the model picker.
|
||||
|
||||
## WebSocket/Realtime Not Working
|
||||
|
||||
1. Verify reverse proxy routes `/socket.io` to the realtime service (default port 3002). `NEXT_PUBLIC_SOCKET_URL` is only needed if realtime is on a separate host.
|
||||
|
||||
@@ -2447,7 +2447,7 @@
|
||||
"get": {
|
||||
"operationId": "listSecrets",
|
||||
"summary": "List Secrets",
|
||||
"description": "List workspace and caller-owned personal secret metadata with opaque cursor pagination. Only names, scope, role, and timestamps are returned; secret values are never returned. A workspace API key is rejected with `403`; use a personal API key.",
|
||||
"description": "List workspace and caller-owned personal secret metadata with opaque cursor pagination. Rows for workspace secrets marked visible (unredacted) include the stored value; every other row is metadata-only and no other response ever carries a value. A workspace API key is rejected with `403`; use a personal API key.",
|
||||
"tags": ["Secrets"],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -5581,6 +5581,124 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"V2SecretWithValue": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 255,
|
||||
"pattern": "^[A-Za-z0-9_]+$",
|
||||
"description": "Secret name containing only letters, numbers, and underscores."
|
||||
},
|
||||
"scope": {
|
||||
"type": "string",
|
||||
"enum": ["workspace", "personal"],
|
||||
"description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace."
|
||||
},
|
||||
"description": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "What the secret is for, as set on the workspace secret. Always null for a personal secret, which has no shared audience."
|
||||
},
|
||||
"unredacted": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the workspace secret opts out of redaction, so its value appears in plaintext in run logs and model-visible content. Always false for a personal secret."
|
||||
},
|
||||
"role": {
|
||||
"type": "string",
|
||||
"enum": ["admin", "member"],
|
||||
"description": "Caller role for the secret."
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
|
||||
"description": "ISO 8601 timestamp when the secret was created."
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
|
||||
"description": "ISO 8601 timestamp when the secret was last updated."
|
||||
},
|
||||
"value": {
|
||||
"description": "The stored secret value. Present only when the workspace secret is marked visible (unredacted); omitted for every other secret.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"scope",
|
||||
"description",
|
||||
"unredacted",
|
||||
"role",
|
||||
"createdAt",
|
||||
"updatedAt"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"title": "Secret metadata with visible value",
|
||||
"description": "Secret metadata; the stored value is included only for a workspace secret marked visible (unredacted)."
|
||||
},
|
||||
"ListSecretsResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/V2SecretWithValue"
|
||||
},
|
||||
"description": "Items in the current page."
|
||||
},
|
||||
"nextCursor": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself."
|
||||
}
|
||||
},
|
||||
"required": ["data", "nextCursor"],
|
||||
"additionalProperties": false,
|
||||
"title": "List secrets response",
|
||||
"description": "Secret metadata visible to the caller; visible (unredacted) workspace secrets carry their value.",
|
||||
"examples": [
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"name": "STRIPE_API_KEY",
|
||||
"scope": "workspace",
|
||||
"description": "Production billing key — rotate quarterly.",
|
||||
"unredacted": false,
|
||||
"role": "admin",
|
||||
"createdAt": "2026-06-01T09:14:00.000Z",
|
||||
"updatedAt": "2026-06-20T14:02:11.000Z"
|
||||
},
|
||||
{
|
||||
"name": "STAGING_BASE_URL",
|
||||
"scope": "workspace",
|
||||
"description": "Staging environment base URL.",
|
||||
"unredacted": true,
|
||||
"role": "member",
|
||||
"createdAt": "2026-06-03T11:30:00.000Z",
|
||||
"updatedAt": "2026-06-21T08:45:09.000Z",
|
||||
"value": "https://staging.example.com"
|
||||
}
|
||||
],
|
||||
"nextCursor": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"V2Secret": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -5607,6 +5725,10 @@
|
||||
],
|
||||
"description": "What the secret is for, as set on the workspace secret. Always null for a personal secret, which has no shared audience."
|
||||
},
|
||||
"unredacted": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the workspace secret opts out of redaction, so its value appears in plaintext in run logs and model-visible content. Always false for a personal secret."
|
||||
},
|
||||
"role": {
|
||||
"type": "string",
|
||||
"enum": ["admin", "member"],
|
||||
@@ -5625,53 +5747,19 @@
|
||||
"description": "ISO 8601 timestamp when the secret was last updated."
|
||||
}
|
||||
},
|
||||
"required": ["name", "scope", "description", "role", "createdAt", "updatedAt"],
|
||||
"required": [
|
||||
"name",
|
||||
"scope",
|
||||
"description",
|
||||
"unredacted",
|
||||
"role",
|
||||
"createdAt",
|
||||
"updatedAt"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"title": "Secret metadata",
|
||||
"description": "Public secret metadata without the stored secret value."
|
||||
},
|
||||
"ListSecretsResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/V2Secret"
|
||||
},
|
||||
"description": "Items in the current page."
|
||||
},
|
||||
"nextCursor": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself."
|
||||
}
|
||||
},
|
||||
"required": ["data", "nextCursor"],
|
||||
"additionalProperties": false,
|
||||
"title": "List secrets response",
|
||||
"description": "Secret metadata visible to the caller without stored values.",
|
||||
"examples": [
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"name": "STRIPE_API_KEY",
|
||||
"scope": "workspace",
|
||||
"description": "Production billing key — rotate quarterly.",
|
||||
"role": "admin",
|
||||
"createdAt": "2026-06-01T09:14:00.000Z",
|
||||
"updatedAt": "2026-06-20T14:02:11.000Z"
|
||||
}
|
||||
],
|
||||
"nextCursor": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"SetSecretResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -5690,6 +5778,7 @@
|
||||
"name": "STRIPE_API_KEY",
|
||||
"scope": "workspace",
|
||||
"description": "Production billing key — rotate quarterly.",
|
||||
"unredacted": false,
|
||||
"role": "admin",
|
||||
"createdAt": "2026-06-01T09:14:00.000Z",
|
||||
"updatedAt": "2026-06-20T14:02:11.000Z"
|
||||
@@ -5729,6 +5818,10 @@
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"unredacted": {
|
||||
"description": "Opt the workspace secret out of redaction: its value then appears in plaintext in run logs, model-visible content, and files, including publicly shared log links. Workspace scope only — sending it for a personal secret is rejected. Omit it to leave the current setting untouched.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["workspaceId", "scope", "value"],
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"build": "fumadocs-mdx && NODE_OPTIONS='--max-old-space-size=8192' next build",
|
||||
"start": "next start",
|
||||
"postinstall": "fumadocs-mdx",
|
||||
"test": "vitest run",
|
||||
"type-check": "fumadocs-mdx && tsc --noEmit",
|
||||
"lint": "biome check --write --unsafe .",
|
||||
"lint:check": "biome check .",
|
||||
@@ -47,6 +48,7 @@
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^4.0.12",
|
||||
"typescript": "^7.0.2"
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,8 +90,8 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic
|
||||
|
||||
# Local AI Models (Optional)
|
||||
# OLLAMA_URL=http://localhost:11434 # URL for local Ollama server - uncomment if using local models
|
||||
# VLLM_BASE_URL=http://localhost:8000 # Base URL for your self-hosted vLLM (OpenAI-compatible)
|
||||
# VLLM_API_KEY= # Optional bearer token if your vLLM instance requires auth
|
||||
# VLLM_BASE_URL=http://localhost:8000 # vLLM or LM Studio OpenAI-compatible URL; a trailing /v1 is optional
|
||||
# VLLM_API_KEY= # Optional bearer token if the endpoint requires auth
|
||||
# LITELLM_BASE_URL=http://localhost:4000 # Base URL for your LiteLLM proxy (OpenAI-compatible)
|
||||
# LITELLM_API_KEY= # Optional bearer token if your LiteLLM proxy requires auth
|
||||
# OPENROUTER_API_KEY= # Optional self-hosted fallback for OpenAI knowledge-base embeddings
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
isGithubAuthDisabled,
|
||||
isGoogleAuthDisabled,
|
||||
isMicrosoftAuthDisabled,
|
||||
isProd,
|
||||
} from '@/lib/core/config/env-flags'
|
||||
|
||||
export async function getOAuthProviderStatus() {
|
||||
@@ -16,5 +15,5 @@ export async function getOAuthProviderStatus() {
|
||||
const microsoftAvailable =
|
||||
!!(env.MICROSOFT_CLIENT_ID && env.MICROSOFT_CLIENT_SECRET) && !isMicrosoftAuthDisabled
|
||||
|
||||
return { githubAvailable, googleAvailable, microsoftAvailable, isProduction: isProd }
|
||||
return { githubAvailable, googleAvailable, microsoftAvailable }
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ interface SocialLoginButtonsProps {
|
||||
googleAvailable: boolean
|
||||
microsoftAvailable: boolean
|
||||
callbackURL?: string
|
||||
isProduction: boolean
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
@@ -24,7 +23,6 @@ export function SocialLoginButtons({
|
||||
googleAvailable,
|
||||
microsoftAvailable,
|
||||
callbackURL = '/workspace',
|
||||
isProduction,
|
||||
children,
|
||||
}: SocialLoginButtonsProps) {
|
||||
const [isGithubLoading, setIsGithubLoading] = useState(false)
|
||||
|
||||
@@ -87,13 +87,11 @@ export default function LoginPage({
|
||||
githubAvailable,
|
||||
googleAvailable,
|
||||
microsoftAvailable,
|
||||
isProduction,
|
||||
registrationDisabled,
|
||||
}: {
|
||||
githubAvailable: boolean
|
||||
googleAvailable: boolean
|
||||
microsoftAvailable: boolean
|
||||
isProduction: boolean
|
||||
/** DISABLE_REGISTRATION. Hides the signup cross-link, which `/signup` blocks. */
|
||||
registrationDisabled: boolean
|
||||
}) {
|
||||
@@ -430,7 +428,6 @@ export default function LoginPage({
|
||||
googleAvailable={googleAvailable}
|
||||
githubAvailable={githubAvailable}
|
||||
microsoftAvailable={microsoftAvailable}
|
||||
isProduction={isProduction}
|
||||
callbackURL={callbackUrl}
|
||||
>
|
||||
{ssoEnabled && !hasOnlySSO && (
|
||||
@@ -464,9 +461,6 @@ export default function LoginPage({
|
||||
title='Email'
|
||||
value={forgotPasswordEmail}
|
||||
onChange={(value) => setForgotPasswordEmail(value)}
|
||||
onSubmit={() => {
|
||||
if (!isSubmittingReset) void handleForgotPassword()
|
||||
}}
|
||||
required
|
||||
placeholder='you@example.com'
|
||||
/>
|
||||
|
||||
@@ -12,8 +12,7 @@ export const metadata: Metadata = {
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function LoginPage() {
|
||||
const { githubAvailable, googleAvailable, microsoftAvailable, isProduction } =
|
||||
await getOAuthProviderStatus()
|
||||
const { githubAvailable, googleAvailable, microsoftAvailable } = await getOAuthProviderStatus()
|
||||
|
||||
return (
|
||||
<Suspense fallback={<LoginLoading />}>
|
||||
@@ -21,7 +20,6 @@ export default async function LoginPage() {
|
||||
githubAvailable={githubAvailable}
|
||||
googleAvailable={googleAvailable}
|
||||
microsoftAvailable={microsoftAvailable}
|
||||
isProduction={isProduction}
|
||||
registrationDisabled={isRegistrationDisabled}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
@@ -36,15 +36,13 @@ export default async function SignupPage({
|
||||
)
|
||||
}
|
||||
|
||||
const { githubAvailable, googleAvailable, microsoftAvailable, isProduction } =
|
||||
await getOAuthProviderStatus()
|
||||
const { githubAvailable, googleAvailable, microsoftAvailable } = await getOAuthProviderStatus()
|
||||
|
||||
return (
|
||||
<SignupForm
|
||||
githubAvailable={githubAvailable}
|
||||
googleAvailable={googleAvailable}
|
||||
microsoftAvailable={microsoftAvailable}
|
||||
isProduction={isProduction}
|
||||
emailSignupEnabled={!isEmailSignupDisabled}
|
||||
emailVerificationEnabled={isEmailVerificationEffectivelyEnabled()}
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,9 @@ import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
import { trackGoogleEvent } from '@/lib/analytics/google'
|
||||
import { client, useSession } from '@/lib/auth/auth-client'
|
||||
import { useTrackingConsent } from '@/lib/consent/tracking-consent'
|
||||
import { getEnv, isFalsy } from '@/lib/core/config/env'
|
||||
import { isSsoEnabled } from '@/lib/core/config/env-flags'
|
||||
import { validateCallbackUrl } from '@/lib/core/security/input-validation'
|
||||
@@ -91,7 +93,6 @@ interface SignupFormProps {
|
||||
githubAvailable: boolean
|
||||
googleAvailable: boolean
|
||||
microsoftAvailable: boolean
|
||||
isProduction: boolean
|
||||
emailSignupEnabled: boolean
|
||||
/** Server-derived: verification is enabled AND a mail provider is configured. */
|
||||
emailVerificationEnabled: boolean
|
||||
@@ -101,7 +102,6 @@ function SignupFormContent({
|
||||
githubAvailable,
|
||||
googleAvailable,
|
||||
microsoftAvailable,
|
||||
isProduction,
|
||||
emailSignupEnabled,
|
||||
emailVerificationEnabled,
|
||||
}: SignupFormProps) {
|
||||
@@ -109,6 +109,7 @@ function SignupFormContent({
|
||||
const searchParams = useSearchParams()
|
||||
const { refetch: refetchSession } = useSession()
|
||||
const posthog = usePostHog()
|
||||
const { measurement } = useTrackingConsent()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -346,6 +347,8 @@ function SignupFormContent({
|
||||
return
|
||||
}
|
||||
|
||||
if (measurement) trackGoogleEvent('sign_up', { method: 'email' })
|
||||
|
||||
try {
|
||||
await refetchSession()
|
||||
logger.info('Session refreshed after successful signup')
|
||||
@@ -484,7 +487,6 @@ function SignupFormContent({
|
||||
googleAvailable={googleAvailable}
|
||||
microsoftAvailable={microsoftAvailable}
|
||||
callbackURL={redirectUrl || '/workspace'}
|
||||
isProduction={isProduction}
|
||||
>
|
||||
{ssoEnabled && !hasOnlySSO && (
|
||||
<SSOLoginButton callbackURL={redirectUrl || '/workspace'} variant='outline' />
|
||||
@@ -507,7 +509,6 @@ export default function SignupPage({
|
||||
githubAvailable,
|
||||
googleAvailable,
|
||||
microsoftAvailable,
|
||||
isProduction,
|
||||
emailSignupEnabled,
|
||||
emailVerificationEnabled,
|
||||
}: SignupFormProps) {
|
||||
@@ -519,7 +520,6 @@ export default function SignupPage({
|
||||
githubAvailable={githubAvailable}
|
||||
googleAvailable={googleAvailable}
|
||||
microsoftAvailable={microsoftAvailable}
|
||||
isProduction={isProduction}
|
||||
emailSignupEnabled={emailSignupEnabled}
|
||||
emailVerificationEnabled={emailVerificationEnabled}
|
||||
/>
|
||||
|
||||
@@ -48,6 +48,13 @@ const FALLBACK_STATUS: ProviderStatus = {
|
||||
const SOCIAL_BTN =
|
||||
'relative flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[var(--border-1)] text-[13.5px] text-[var(--text-primary)] transition-colors hover:bg-[var(--surface-hover)] disabled:cursor-not-allowed disabled:opacity-50'
|
||||
|
||||
/** Auth providers are peer choices, so opening the dialog must not arm one or dismissal. */
|
||||
function focusAuthDialog(event: Event): void {
|
||||
event.preventDefault()
|
||||
const content = event.currentTarget as HTMLElement | null
|
||||
content?.focus()
|
||||
}
|
||||
|
||||
function fetchProviderStatus(): Promise<ProviderStatus> {
|
||||
if (fetchPromise) return fetchPromise
|
||||
fetchPromise = requestJson(getAuthProvidersContract, {})
|
||||
@@ -155,7 +162,11 @@ export function AuthModal({ children, defaultView = 'login', source }: AuthModal
|
||||
return (
|
||||
<Modal open={open} onOpenChange={handleOpenChange}>
|
||||
<ModalTrigger asChild>{children}</ModalTrigger>
|
||||
<ModalContent size='sm' className='dark bg-[var(--bg)] text-[var(--text-primary)]'>
|
||||
<ModalContent
|
||||
size='sm'
|
||||
className='dark bg-[var(--bg)] text-[var(--text-primary)]'
|
||||
onOpenAutoFocus={focusAuthDialog}
|
||||
>
|
||||
<ModalTitle className='sr-only'>
|
||||
{effectiveView === 'login' ? 'Log in' : 'Create account'}
|
||||
</ModalTitle>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from 'next/link'
|
||||
import { ConsentPreferencesTrigger } from '@/app/_shell/consent/consent-preferences-trigger'
|
||||
import { ALL_COMPETITORS } from '@/app/(landing)/comparisons/utils'
|
||||
import { SimWordmark } from '@/app/(landing)/components/navbar/components/sim-wordmark'
|
||||
import { MODEL_PROVIDERS_WITH_CATALOGS } from '@/app/(landing)/models/utils'
|
||||
@@ -19,14 +20,25 @@ import { MODEL_PROVIDERS_WITH_CATALOGS } from '@/app/(landing)/models/utils'
|
||||
*/
|
||||
|
||||
const LINK_CLASS =
|
||||
'text-sm text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)]'
|
||||
'text-left text-sm text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)]'
|
||||
|
||||
interface FooterItem {
|
||||
interface FooterLinkItem {
|
||||
label: string
|
||||
href: string
|
||||
external?: boolean
|
||||
}
|
||||
|
||||
interface FooterConsentItem {
|
||||
label: string
|
||||
consentPreferences: true
|
||||
}
|
||||
|
||||
type FooterItem = FooterLinkItem | FooterConsentItem
|
||||
|
||||
interface FooterProps {
|
||||
showConsentPreferences?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Platform modules link to their local landing pages (internal link equity
|
||||
* stays on the ranking pages); docs-only surfaces (MCP, API, Self Hosting)
|
||||
@@ -108,27 +120,37 @@ const SOCIAL_LINKS: FooterItem[] = [
|
||||
const LEGAL_LINKS: FooterItem[] = [
|
||||
{ label: 'Terms of Service', href: '/terms' },
|
||||
{ label: 'Privacy Policy', href: '/privacy' },
|
||||
{ label: 'Cookie Policy', href: '/cookie-policy' },
|
||||
]
|
||||
|
||||
const CONSENT_PREFERENCES_LINK: FooterConsentItem = {
|
||||
label: 'Cookie preferences',
|
||||
consentPreferences: true,
|
||||
}
|
||||
|
||||
function FooterColumn({ title, items }: { title: string; items: FooterItem[] }) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className='mb-4 text-[var(--text-primary)] text-sm'>{title}</h3>
|
||||
<div className='flex flex-col gap-2.5'>
|
||||
{items.map(({ label, href, external }) =>
|
||||
external ? (
|
||||
{items.map((item) =>
|
||||
'consentPreferences' in item ? (
|
||||
<ConsentPreferencesTrigger key={item.label} className={LINK_CLASS}>
|
||||
{item.label}
|
||||
</ConsentPreferencesTrigger>
|
||||
) : item.external ? (
|
||||
<a
|
||||
key={label}
|
||||
href={href}
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
{label}
|
||||
{item.label}
|
||||
</a>
|
||||
) : (
|
||||
<Link key={label} href={href} className={LINK_CLASS}>
|
||||
{label}
|
||||
<Link key={item.label} href={item.href} className={LINK_CLASS}>
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
@@ -137,7 +159,7 @@ function FooterColumn({ title, items }: { title: string; items: FooterItem[] })
|
||||
)
|
||||
}
|
||||
|
||||
export function Footer() {
|
||||
export function Footer({ showConsentPreferences = false }: FooterProps) {
|
||||
return (
|
||||
<footer className='mt-[120px] w-full border-[var(--border)] border-t max-sm:mt-16 max-lg:mt-[88px]'>
|
||||
<div className='mx-auto w-full max-w-[1460px] px-20 pt-16 pb-16 max-sm:px-5 max-lg:px-8 max-lg:pt-12 max-lg:pb-12'>
|
||||
@@ -161,7 +183,12 @@ export function Footer() {
|
||||
<FooterColumn title='Integrations' items={INTEGRATION_LINKS} />
|
||||
<FooterColumn title='Models' items={MODEL_LINKS} />
|
||||
<FooterColumn title='Socials' items={SOCIAL_LINKS} />
|
||||
<FooterColumn title='Legal' items={LEGAL_LINKS} />
|
||||
<FooterColumn
|
||||
title='Legal'
|
||||
items={
|
||||
showConsentPreferences ? [...LEGAL_LINKS, CONSENT_PREFERENCES_LINK] : LEGAL_LINKS
|
||||
}
|
||||
/>
|
||||
</nav>
|
||||
|
||||
<p className='mt-16 text-[var(--text-muted)] text-sm'>© 2026 Sim. All rights reserved.</p>
|
||||
|
||||
@@ -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) {
|
||||
</a>
|
||||
<Navbar stars={stars} />
|
||||
{children}
|
||||
<Footer />
|
||||
<Footer showConsentPreferences={isHosted} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<button
|
||||
type='button'
|
||||
className={PROSE_TYPE.link}
|
||||
onClick={() => window.dispatchEvent(new Event(OPEN_CONSENT_PREFERENCES_EVENT))}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
<ConsentPreferencesTrigger className={PROSE_TYPE.link}>{children}</ConsentPreferencesTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,10 +8,6 @@ import {
|
||||
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
|
||||
@@ -41,15 +37,14 @@ function cookieTable(caption: string, rows: ReactNode[][]): LegalBlock {
|
||||
*
|
||||
* 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.
|
||||
* categories and consent-managed scripts: 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',
|
||||
lastUpdated: 'August 24, 2026',
|
||||
intro: [
|
||||
{
|
||||
kind: 'paragraph',
|
||||
@@ -120,7 +115,9 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = {
|
||||
<>
|
||||
<strong>Analytics</strong> — 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.
|
||||
not use these to target advertising. Google Analytics loads with analytics storage
|
||||
denied and cannot set analytics cookies until this category is allowed; before then,
|
||||
it may send limited cookieless consent and measurement signals.
|
||||
</>,
|
||||
<>
|
||||
<strong>Marketing</strong> — measuring which campaigns bring builders to Sim and
|
||||
@@ -182,6 +179,24 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = {
|
||||
['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'],
|
||||
[
|
||||
'ph_*_posthog',
|
||||
'PostHog',
|
||||
'Stores analytics identity and durable session state after analytics consent is granted.',
|
||||
'1 year',
|
||||
],
|
||||
[
|
||||
'__ph_opt_in_out_*',
|
||||
'PostHog',
|
||||
'Records PostHog’s local capture state, synchronized from your Sim analytics choice.',
|
||||
'Until you change your choice',
|
||||
],
|
||||
[
|
||||
'ph_*_window_id / ph_*_primary_window_exists',
|
||||
'PostHog',
|
||||
'Coordinates analytics state for the current browser tab.',
|
||||
'Session',
|
||||
],
|
||||
]),
|
||||
cookieTable('Marketing', [
|
||||
[
|
||||
@@ -199,7 +214,6 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = {
|
||||
],
|
||||
['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'],
|
||||
]),
|
||||
],
|
||||
},
|
||||
@@ -224,7 +238,7 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = {
|
||||
},
|
||||
{
|
||||
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.`,
|
||||
content: `We honor Global Privacy Control (GPC). Where the applicable privacy policy provides an opt-out right, the consent service applies that signal to the covered optional categories without requiring you to use the banner.`,
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
@@ -234,9 +248,9 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = {
|
||||
<ProseLink href='https://tools.google.com/dlpage/gaoptout'>
|
||||
Google Analytics
|
||||
</ProseLink>
|
||||
, <ProseLink href='https://myadcenter.google.com'>Google Ads</ProseLink>,{' '}
|
||||
<ProseLink href='https://x.com/settings/privacy_and_safety'>X (Twitter)</ProseLink>,
|
||||
and <ProseLink href='https://legal.hubspot.com/privacy-policy'>HubSpot</ProseLink>.
|
||||
, <ProseLink href='https://x.com/settings/privacy_and_safety'>X (Twitter)</ProseLink>,{' '}
|
||||
<ProseLink href='https://legal.hubspot.com/privacy-policy'>HubSpot</ProseLink>, and{' '}
|
||||
<ProseLink href='https://posthog.com/privacy'>PostHog</ProseLink>.
|
||||
</>
|
||||
),
|
||||
},
|
||||
@@ -256,10 +270,11 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = {
|
||||
<>
|
||||
The providers currently in use are{' '}
|
||||
<ProseLink href='https://policies.google.com/technologies/cookies'>Google</ProseLink>{' '}
|
||||
(Analytics, Tag Manager, and Ads),{' '}
|
||||
(Analytics),{' '}
|
||||
<ProseLink href='https://legal.hubspot.com/privacy-policy'>HubSpot</ProseLink>,{' '}
|
||||
<ProseLink href='https://x.com/en/privacy'>X (Twitter)</ProseLink>,{' '}
|
||||
<ProseLink href='https://ahrefs.com/privacy'>Ahrefs</ProseLink>, and{' '}
|
||||
<ProseLink href='https://ahrefs.com/privacy'>Ahrefs</ProseLink>,{' '}
|
||||
<ProseLink href='https://posthog.com/privacy'>PostHog</ProseLink>, and{' '}
|
||||
<ProseLink href='https://www.cloudflare.com/privacypolicy/'>Cloudflare</ProseLink>.
|
||||
</>
|
||||
),
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import Cal, { getCalApi } from '@calcom/embed-react'
|
||||
import { isHosted } from '@/lib/core/config/env-flags'
|
||||
import { trackGoogleEvent } from '@/lib/analytics/google'
|
||||
import { X_DEMO_BOOKED_EVENT_ID } from '@/lib/consent/scripts'
|
||||
import { useTrackingConsent } from '@/lib/consent/tracking-consent'
|
||||
import type { DemoLead } from '@/app/(landing)/demo/components/demo-form'
|
||||
|
||||
/** The Cal.com event the demo books - set `NEXT_PUBLIC_CAL_LINK` to override. */
|
||||
@@ -15,12 +17,6 @@ const CAL_LINK = process.env.NEXT_PUBLIC_CAL_LINK ?? 'team/sim/demo'
|
||||
*/
|
||||
const CAL_BRAND_COLOR = '#6f3dfa'
|
||||
|
||||
/**
|
||||
* X (Twitter) conversion event fired when a demo is actually booked, so ad
|
||||
* delivery optimizes toward bookings rather than form submits.
|
||||
*/
|
||||
const X_DEMO_BOOKED_EVENT_ID = 'tw-q5xbl-q5xbn'
|
||||
|
||||
interface DemoSchedulerProps {
|
||||
/** The captured lead used to prefill the Cal.com booking. */
|
||||
lead: DemoLead
|
||||
@@ -28,22 +24,6 @@ interface DemoSchedulerProps {
|
||||
|
||||
let calEmbedPreloaded = false
|
||||
|
||||
/**
|
||||
* Fires the X conversion once the Cal.com booking is confirmed. There is no
|
||||
* standalone confirmation page to drop the pixel snippet into — Cal renders the
|
||||
* "you're booked" state inside its cross-origin iframe — so the embed's
|
||||
* `bookingSuccessfulV2` event is the confirmation.
|
||||
*
|
||||
* Module-scope so the same function identity can be handed to both `on` and
|
||||
* `off`. `window.twq` is only defined where {@link LandingLayout} renders the
|
||||
* pixel base code, so the optional call is a second guard for the window
|
||||
* between mount and `uwt.js` finishing — the stub `twq` queues calls made
|
||||
* before the script loads and replays them.
|
||||
*/
|
||||
function trackDemoBooked(): void {
|
||||
window.twq?.('event', X_DEMO_BOOKED_EVENT_ID, {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Warm the Cal.com embed before the scheduler mounts. Loads `embed.js` and
|
||||
* issues the embed's `preload` instruction, which fetches the booker in a
|
||||
@@ -77,8 +57,20 @@ export function preloadCalEmbed(): void {
|
||||
* card stays the same height across the form→calendar transition.
|
||||
*/
|
||||
export function DemoScheduler({ lead }: DemoSchedulerProps) {
|
||||
const { marketing, measurement } = useTrackingConsent()
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const trackDemoBooked = () => {
|
||||
if (measurement) {
|
||||
trackGoogleEvent('get_a_demo', {
|
||||
page_path: '/demo',
|
||||
form_name: 'sim_demo',
|
||||
booking_status: 'scheduled',
|
||||
})
|
||||
}
|
||||
if (marketing) window.twq?.('event', X_DEMO_BOOKED_EVENT_ID, {})
|
||||
}
|
||||
const api = getCalApi({ namespace: CAL_NAMESPACE })
|
||||
api
|
||||
.then((cal) => {
|
||||
@@ -87,19 +79,19 @@ export function DemoScheduler({ lead }: DemoSchedulerProps) {
|
||||
hideEventTypeDetails: true,
|
||||
styles: { branding: { brandColor: CAL_BRAND_COLOR } },
|
||||
})
|
||||
// Matches the layout's pixel gating - a self-hosted deployment loads no
|
||||
// base pixel, so it must not subscribe an ad-tracking callback either.
|
||||
if (isHosted) cal('on', { action: 'bookingSuccessfulV2', callback: trackDemoBooked })
|
||||
if (measurement || marketing) {
|
||||
cal('on', { action: 'bookingSuccessfulV2', callback: trackDemoBooked })
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (!isHosted) return
|
||||
if (!measurement && !marketing) return
|
||||
api
|
||||
.then((cal) => cal('off', { action: 'bookingSuccessfulV2', callback: trackDemoBooked }))
|
||||
.catch(() => {})
|
||||
}
|
||||
}, [])
|
||||
}, [marketing, measurement])
|
||||
|
||||
return (
|
||||
<div className='flex h-full min-w-0 flex-col p-6 max-sm:p-5'>
|
||||
|
||||
@@ -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(
|
||||
<StrictMode>
|
||||
<HubspotPageViewTracker />
|
||||
</StrictMode>
|
||||
)
|
||||
)
|
||||
expect(window._hsq).toEqual([])
|
||||
|
||||
navigation.pathname = '/demo'
|
||||
act(() =>
|
||||
root?.render(
|
||||
<StrictMode>
|
||||
<HubspotPageViewTracker />
|
||||
</StrictMode>
|
||||
)
|
||||
)
|
||||
|
||||
expect(window._hsq).toEqual([['setPath', '/demo'], ['trackPageView']])
|
||||
})
|
||||
})
|
||||
@@ -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<string | null>(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
|
||||
}
|
||||
|
||||
@@ -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' && <HubspotPageViewTracker />}
|
||||
{xPixel.status === 'ready' && <XPageViewTracker />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<LandingShell>
|
||||
{children}
|
||||
{/* HubSpot + X pixel tracking — hosted only */}
|
||||
{isHosted && (
|
||||
<>
|
||||
<Script id='hs-script-loader' src={HUBSPOT_SCRIPT_SRC} strategy='afterInteractive' />
|
||||
<Script id='x-pixel-base' strategy='afterInteractive'>
|
||||
{X_PIXEL_BASE_CODE}
|
||||
</Script>
|
||||
<Suspense fallback={null}>
|
||||
<HubspotPageViewTracker />
|
||||
<XPageViewTracker />
|
||||
</Suspense>
|
||||
</>
|
||||
)}
|
||||
{isHosted && <LandingConsentTracking />}
|
||||
</LandingShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<string, string[]> = {
|
||||
@@ -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}/`]
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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(<XPageViewTracker />))
|
||||
}
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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<string | null>(null)
|
||||
const lastTrackedPathRef = useRef<string | null>(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
|
||||
}
|
||||
|
||||
@@ -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 `<html>` 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
|
||||
|
||||
@@ -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<HTMLButtonElement>) => (
|
||||
<button {...props}>{children}</button>
|
||||
),
|
||||
cn: (...classes: Array<string | undefined>) => 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(<ConsentPreferencesTrigger>Cookie settings</ConsentPreferencesTrigger>))
|
||||
const button = container.querySelector('button')
|
||||
act(() => button?.click())
|
||||
|
||||
expect(button?.type).toBe('button')
|
||||
expect(button?.textContent).toBe('Cookie settings')
|
||||
expect(mockOpenDialog).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -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 (
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost-secondary'
|
||||
className={cn('h-auto justify-start p-0 text-[length:inherit]', className)}
|
||||
onClick={openDialog}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -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 }) => (
|
||||
<div data-testid='store'>{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
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<unknown>) => {
|
||||
return function LazyRuntime() {
|
||||
mockDynamicImport(loader)
|
||||
return <span data-testid='runtime' />
|
||||
}
|
||||
},
|
||||
vi.mock('@/lib/consent/tracking-consent', () => ({
|
||||
TrackingConsentProvider: ({ children }: { children: ReactNode }) => children,
|
||||
}))
|
||||
vi.mock('@/app/_shell/consent/consent-banner', () => ({
|
||||
ConsentBanner: () => <span data-testid='banner' />,
|
||||
}))
|
||||
vi.mock('@/app/_shell/consent/google-analytics-page-view-tracker', () => ({
|
||||
GoogleAnalyticsPageViewTracker: () => <span data-testid='analytics' />,
|
||||
}))
|
||||
|
||||
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(<ConsentProvider />))
|
||||
act(() =>
|
||||
root?.render(
|
||||
<ConsentProvider>
|
||||
<span data-testid='application' />
|
||||
</ConsentProvider>
|
||||
)
|
||||
)
|
||||
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)
|
||||
it('wraps the application and presents the policy-controlled consent surface', () => {
|
||||
const container = render()
|
||||
|
||||
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()
|
||||
}
|
||||
)
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
/**
|
||||
* 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 <ConsentRuntime />
|
||||
interface ConsentProviderProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* 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({ children }: ConsentProviderProps) {
|
||||
return (
|
||||
<ConsentStoreProvider>
|
||||
<TrackingConsentProvider>
|
||||
{children}
|
||||
<GoogleAnalyticsPageViewTracker />
|
||||
<ConsentBanner />
|
||||
</TrackingConsentProvider>
|
||||
</ConsentStoreProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<ConsentStoreProvider>
|
||||
<ConsentBanner />
|
||||
</ConsentStoreProvider>
|
||||
)
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 <ConsentManagerProvider options={CONSENT_OPTIONS}>{children}</ConsentManagerProvider>
|
||||
|
||||
@@ -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(<GoogleAnalyticsPageViewTracker />))
|
||||
}
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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<string | null>(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
|
||||
}
|
||||
@@ -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 }) => (
|
||||
<div data-testid='posthog-provider'>{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
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(
|
||||
<PostHogProvider consentRequired>
|
||||
<span data-testid='application' />
|
||||
</PostHogProvider>
|
||||
)
|
||||
)
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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<React.ComponentType<{
|
||||
client: PostHog
|
||||
children: React.ReactNode
|
||||
}> | null>(null)
|
||||
const clientRef = useRef<PostHog | null>(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 (!isTruthy(posthogEnabled) || !posthogKey) {
|
||||
settlePostHogClient(null)
|
||||
return
|
||||
if (!canInitialize) {
|
||||
setPostHogClient(null)
|
||||
if (posthog.__loaded) posthog.opt_out_capturing()
|
||||
if (consentRequired && isResolved && !measurement && posthogKey) {
|
||||
clearPostHogBrowserState(posthogKey)
|
||||
}
|
||||
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,
|
||||
const posthogEnabled = getEnv('NEXT_PUBLIC_POSTHOG_ENABLED')
|
||||
|
||||
if (!isTruthy(posthogEnabled) || !posthogKey) {
|
||||
setPostHogClient(null)
|
||||
if (posthog.__loaded) posthog.opt_out_capturing()
|
||||
return () => setPostHogClient(null)
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*
|
||||
* `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.
|
||||
* 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.
|
||||
*/
|
||||
capture_exceptions: {
|
||||
capture_unhandled_errors: true,
|
||||
capture_unhandled_rejections: true,
|
||||
capture_console_errors: false,
|
||||
slimDOMOptions: {
|
||||
script: true,
|
||||
comment: true,
|
||||
headFavicon: true,
|
||||
headWhitespace: true,
|
||||
headMetaDescKeywords: true,
|
||||
headMetaSocial: true,
|
||||
headMetaRobots: true,
|
||||
headMetaHttpEquiv: true,
|
||||
headMetaAuthorship: true,
|
||||
headMetaVerification: true,
|
||||
},
|
||||
/**
|
||||
* 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: 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,
|
||||
},
|
||||
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)
|
||||
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')
|
||||
}
|
||||
clientRef.current = posthog
|
||||
setProvider(() => PHProvider)
|
||||
})
|
||||
.catch((err) => {
|
||||
settlePostHogClient(null)
|
||||
logger.error('Failed to load PostHog', { error: err })
|
||||
})
|
||||
}, [])
|
||||
if (publicEnvMissingAtModuleInit) {
|
||||
posthog.capture('runtime_env_missing_at_module_init')
|
||||
}
|
||||
} catch (err) {
|
||||
setPostHogClient(null)
|
||||
logger.error('Failed to load PostHog', { error: err })
|
||||
}
|
||||
|
||||
if (Provider && clientRef.current) {
|
||||
return <Provider client={clientRef.current}>{children}</Provider>
|
||||
}
|
||||
return () => {
|
||||
setPostHogClient(null)
|
||||
}
|
||||
}, [canInitialize, consentRequired, isResolved, measurement])
|
||||
|
||||
return <>{children}</>
|
||||
return <PHProvider client={posthog}>{children}</PHProvider>
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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)`)
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -991,6 +991,13 @@ interface FunctionRouteExecutionContext {
|
||||
outputSecretMatcher?: ResolvedSecretMatcher
|
||||
outputSecretNamesByScanLiteral: Map<string, string[]>
|
||||
outputSecretPlaintextsByName: Map<string, string>
|
||||
/**
|
||||
* 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<string>
|
||||
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) ?? []
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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<Record<string, unknown>> {
|
||||
if (!condition || typeof condition !== 'object') return []
|
||||
const node = condition as Record<string, unknown>
|
||||
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<string, unknown> | null | undefined) => executionData ?? {}
|
||||
)
|
||||
mockMapWithConcurrency.mockImplementation(
|
||||
async (
|
||||
items: unknown[],
|
||||
_limit: number,
|
||||
mapper: (item: unknown, index: number) => Promise<unknown>
|
||||
) => 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()
|
||||
})
|
||||
})
|
||||
@@ -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<string>`${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<Uint8Array>({
|
||||
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<string, unknown> | 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)
|
||||
)
|
||||
)
|
||||
: 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)
|
||||
|
||||
for (let j = 0; j < rows.length; j++) {
|
||||
const r = rows[j] as any
|
||||
const ed = materialized[j] as Record<string, any>
|
||||
// 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),
|
||||
})
|
||||
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<string, unknown> | null,
|
||||
{
|
||||
workspaceId: params.workspaceId,
|
||||
workflowId: row.workflowId,
|
||||
executionId: row.executionId,
|
||||
userId: session.user.id,
|
||||
}
|
||||
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`))
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
offset += pageSize
|
||||
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) ?? '')
|
||||
}
|
||||
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),
|
||||
})
|
||||
}
|
||||
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<Uint8Array>(
|
||||
{
|
||||
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 })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 },
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }) }
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
+14
-61
@@ -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 = (
|
||||
<PostHogProvider consentRequired={isHosted}>
|
||||
<ThemeProvider>
|
||||
<QueryProvider>
|
||||
<SessionProvider>
|
||||
<TooltipProvider>
|
||||
<BrandedLayout>{children}</BrandedLayout>
|
||||
</TooltipProvider>
|
||||
</SessionProvider>
|
||||
</QueryProvider>
|
||||
</ThemeProvider>
|
||||
</PostHogProvider>
|
||||
)
|
||||
|
||||
return (
|
||||
<html lang='en' suppressHydrationWarning {...publicEnvHtmlAttributes()}>
|
||||
@@ -226,70 +236,13 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<meta name='format-detection' content='telephone=no' />
|
||||
<meta httpEquiv='x-ua-compatible' content='ie=edge' />
|
||||
|
||||
{/* Google Tag Manager — hosted only */}
|
||||
{isHosted && (
|
||||
<Script
|
||||
id='gtm'
|
||||
strategy='afterInteractive'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
|
||||
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
|
||||
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
||||
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
|
||||
})(window,document,'script','dataLayer','${GTM_ID}');`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Google Analytics (gtag.js) — hosted only */}
|
||||
{isHosted && (
|
||||
<>
|
||||
<Script
|
||||
id='gtag-src'
|
||||
src={`https://www.googletagmanager.com/gtag/js?id=${GA_ID}`}
|
||||
strategy='afterInteractive'
|
||||
/>
|
||||
<Script
|
||||
id='gtag-init'
|
||||
strategy='afterInteractive'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','${GA_ID}');`,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isHosted ? <PublicEnvScript /> : <RuntimePublicEnvScript disableNextScript />}
|
||||
</head>
|
||||
<body className={`${season.variable} font-season`} suppressHydrationWarning>
|
||||
{/* Google Tag Manager (noscript) — hosted only */}
|
||||
{isHosted && (
|
||||
<noscript>
|
||||
<iframe
|
||||
src={`https://www.googletagmanager.com/ns.html?id=${GTM_ID}`}
|
||||
title='Google Tag Manager'
|
||||
height='0'
|
||||
width='0'
|
||||
className='invisible hidden'
|
||||
/>
|
||||
</noscript>
|
||||
)}
|
||||
<HydrationErrorHandler />
|
||||
<DesktopUpdateGate />
|
||||
<NuqsAdapter>
|
||||
<PostHogProvider>
|
||||
<ThemeProvider>
|
||||
<QueryProvider>
|
||||
<SessionProvider>
|
||||
<TooltipProvider>
|
||||
<BrandedLayout>{children}</BrandedLayout>
|
||||
{/* Cookie consent — hosted only */}
|
||||
{isHosted && <ConsentProvider />}
|
||||
</TooltipProvider>
|
||||
</SessionProvider>
|
||||
</QueryProvider>
|
||||
</ThemeProvider>
|
||||
</PostHogProvider>
|
||||
{isHosted ? <ConsentProvider>{application}</ConsentProvider> : application}
|
||||
</NuqsAdapter>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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 `<Link>` 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 <HomeFallback />
|
||||
}
|
||||
+226
@@ -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 }) => <span>{children}</span>,
|
||||
ChipModal: ({ open, children }: { open: boolean; children?: ReactNode }) =>
|
||||
open ? <div>{children}</div> : null,
|
||||
ChipModalBody: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
ChipModalError: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
ChipModalField: ({ title, children }: { title: string; children?: ReactNode }) => (
|
||||
<section>
|
||||
<span>{title}</span>
|
||||
{children}
|
||||
</section>
|
||||
),
|
||||
ChipModalFooter: ({
|
||||
primaryAction,
|
||||
}: {
|
||||
primaryAction: { label: string; onClick: () => void; disabled: boolean }
|
||||
}) => (
|
||||
<button
|
||||
type='button'
|
||||
data-testid='connect'
|
||||
onClick={primaryAction.onClick}
|
||||
disabled={primaryAction.disabled}
|
||||
>
|
||||
{primaryAction.label}
|
||||
</button>
|
||||
),
|
||||
ChipModalHeader: ({ children }: { children?: ReactNode }) => <header>{children}</header>,
|
||||
InfoCard: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
InfoCardItem: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
InfoCardList: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
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> | void
|
||||
} = {}) {
|
||||
act(() => {
|
||||
root.render(
|
||||
<ConnectOAuthModal
|
||||
mode='reauthorize'
|
||||
open={true}
|
||||
onOpenChange={vi.fn()}
|
||||
providerId='slack'
|
||||
toolName='Slack'
|
||||
reconnectTarget={reconnectTarget}
|
||||
onConnect={onConnect}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function clickConnect() {
|
||||
const button = container.querySelector<HTMLButtonElement>('[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()
|
||||
})
|
||||
})
|
||||
+17
-1
@@ -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> | 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
|
||||
|
||||
+10
-2
@@ -56,15 +56,17 @@ export function useCredentialDetailForm({
|
||||
|
||||
const [displayNameDraft, setDisplayNameDraft] = useState('')
|
||||
const [descriptionDraft, setDescriptionDraft] = useState('')
|
||||
const [unredactedDraft, setUnredactedDraft] = useState(false)
|
||||
const [seededCredentialId, setSeededCredentialId] = useState<string | null>(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,
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
-14
@@ -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('<img src="/a.png"><p>text</p><img src="/b.png"><img src="/a.png">')
|
||||
).toEqual(['/a.png', '/b.png', '/a.png'])
|
||||
})
|
||||
|
||||
it('returns an empty array for html with no img', () => {
|
||||
expect(extractImgSrcs('<p>hello</p>')).toEqual([])
|
||||
expect(extractImgSrcs('')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldSkipFileUpload (shared by paste and drop)', () => {
|
||||
const isHosted = (src: string) => src.startsWith('/api/files/view/')
|
||||
const hostedHtml = '<img src="/api/files/view/wf_abc">'
|
||||
|
||||
+2
-19
@@ -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 `<img>` `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 = /<img\b[^>]*\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 `<img>` `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 `<img>` whose `src` is already one of our own hosted workspace file
|
||||
* references. Copying a rendered `<img>` that's already on the page (e.g. Cmd+C after clicking it to
|
||||
|
||||
+12
@@ -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')
|
||||
|
||||
+11
-10
@@ -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}`
|
||||
}
|
||||
|
||||
+46
@@ -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([])
|
||||
}
|
||||
})
|
||||
})
|
||||
+9
-3
@@ -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()
|
||||
|
||||
+2
-7
@@ -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,
|
||||
|
||||
+62
@@ -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,<script>',
|
||||
'vbscript://x',
|
||||
'blob://x',
|
||||
'file://x',
|
||||
]
|
||||
|
||||
const executable = inputs.filter((input) =>
|
||||
/^(?:javascript|data|vbscript|blob|file):/.test(
|
||||
normalizeLinkHref(input)
|
||||
.replace(/[\t\n\r]/g, '')
|
||||
.toLowerCase()
|
||||
)
|
||||
)
|
||||
expect(executable).toEqual([])
|
||||
})
|
||||
|
||||
/**
|
||||
* A linked image carries its target in a node attribute rather than a link mark, so the mark's own
|
||||
* URI validation never sees it and the raw target survives parsing — which is correct, since the
|
||||
* document must serialize back verbatim. `image.tsx` builds its anchor from
|
||||
* `normalizeLinkHref(attrs.href)` and omits the anchor entirely when that is empty, so this is the
|
||||
* step that decides whether the target ever reaches the DOM.
|
||||
*/
|
||||
it('drops a dangerous linked-image target before it can reach an anchor', () => {
|
||||
const doc = parseMarkdownToDoc('[](javascript://%0aalert(1))')
|
||||
const hrefs: string[] = []
|
||||
const walk = (node: JSONContent) => {
|
||||
if (node.type === 'image' && typeof node.attrs?.href === 'string') hrefs.push(node.attrs.href)
|
||||
node.content?.forEach(walk)
|
||||
}
|
||||
walk(doc)
|
||||
|
||||
// The parser preserves the authored target — serialization round-trips it verbatim.
|
||||
expect(hrefs).toHaveLength(1)
|
||||
expect(hrefs[0]).toContain('javascript://')
|
||||
// …and the renderer refuses to build an anchor out of it.
|
||||
expect(normalizeLinkHref(hrefs[0])).toBe('')
|
||||
})
|
||||
|
||||
it('collapses trailing blank lines and preserves leading whitespace', () => {
|
||||
|
||||
@@ -252,6 +252,7 @@ export function ShareModal({
|
||||
</ChipModalBody>
|
||||
<ChipModalFooter
|
||||
onCancel={handleClose}
|
||||
defaultAction={isUnshareAction ? 'none' : 'primary'}
|
||||
secondaryActions={
|
||||
saved?.isActive && saved.url
|
||||
? [
|
||||
|
||||
@@ -2297,6 +2297,7 @@ export function Files() {
|
||||
open={Boolean(extractTarget)}
|
||||
onOpenChange={(open) => !open && setExtractTargetId(null)}
|
||||
title='Unzip archive?'
|
||||
defaultAction='confirm'
|
||||
text={[
|
||||
'This will unzip ',
|
||||
{ text: extractTarget?.name ?? 'this archive', bold: true },
|
||||
|
||||
+41
-4
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import type { ReactNode, SVGProps } from 'react'
|
||||
import { act, type ReactNode, type SVGProps } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { getBlockByToolName } from '@/blocks/registry'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { notifyBlockOverlayChanged } from '@/blocks/custom/client-overlay'
|
||||
import { getBlock, getBlockByToolName } from '@/blocks/registry'
|
||||
import { ToolCallItem } from './tool-call-item'
|
||||
|
||||
vi.mock('@/components/ui', () => ({
|
||||
@@ -12,6 +14,11 @@ vi.mock('@/components/ui', () => ({
|
||||
}))
|
||||
|
||||
describe('ToolCallItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
})
|
||||
|
||||
it.each(['executing', 'success', 'error', 'cancelled'] as const)(
|
||||
'renders the %s tool row without an icon',
|
||||
(status) => {
|
||||
@@ -115,4 +122,34 @@ describe('ToolCallItem', () => {
|
||||
expect(markup).toContain('<svg')
|
||||
expect(markup).toContain('Read recent emails')
|
||||
})
|
||||
|
||||
it('refreshes the read icon when custom blocks hydrate after mount', () => {
|
||||
vi.mocked(getBlock).mockReturnValue(undefined)
|
||||
const container = document.createElement('div')
|
||||
const root: Root = createRoot(container)
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<ToolCallItem
|
||||
toolName='read'
|
||||
displayTitle='Read Custom block invoice parser'
|
||||
status='success'
|
||||
params={{
|
||||
path: 'organization/custom-blocks/custom_block_invoice_parser.json',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
expect(container.querySelector('[data-testid="custom-block-icon"]')).toBeNull()
|
||||
|
||||
vi.mocked(getBlock).mockReturnValue({
|
||||
type: 'custom_block_invoice_parser',
|
||||
name: 'Invoice Parser',
|
||||
icon: (props: SVGProps<SVGSVGElement>) => <svg {...props} data-testid='custom-block-icon' />,
|
||||
} as ReturnType<typeof getBlock>)
|
||||
act(() => notifyBlockOverlayChanged())
|
||||
|
||||
expect(container.querySelector('[data-testid="custom-block-icon"]')).not.toBeNull()
|
||||
act(() => root.unmount())
|
||||
})
|
||||
})
|
||||
|
||||
+7
-5
@@ -13,6 +13,7 @@ import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/copilot/tools/retired
|
||||
import { extractStreamingStringArgument } from '@/lib/copilot/tools/streaming-args'
|
||||
import { getToolStatusDisplayTitle, getWaitCountdownTitle } from '@/lib/copilot/tools/tool-display'
|
||||
import { BrandIcon } from '@/blocks/brand-icon'
|
||||
import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay'
|
||||
import { getBlockByToolName } from '@/blocks/registry'
|
||||
import type { ToolCallData, ToolCallStatus } from '../../../../types'
|
||||
import { resolveToolDisplayState } from '../../utils'
|
||||
@@ -122,11 +123,12 @@ export function ToolCallItem({
|
||||
toolCallId,
|
||||
startedAt,
|
||||
}: ToolCallItemProps) {
|
||||
const readBlock = useMemo(() => {
|
||||
if (toolName !== ReadTool.id) return undefined
|
||||
const path = params?.path
|
||||
return typeof path === 'string' ? getReadTargetBlock(path) : undefined
|
||||
}, [toolName, params])
|
||||
useCustomBlockOverlayVersion()
|
||||
const readPath = params?.path
|
||||
const readBlock =
|
||||
toolName === ReadTool.id && typeof readPath === 'string'
|
||||
? getReadTargetBlock(readPath)
|
||||
: undefined
|
||||
|
||||
// Like read's VFS-target resolution above, the gateway uses its exact
|
||||
// discovered toolId only as a deterministic registry lookup. This renders
|
||||
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockGetBlock } = vi.hoisted(() => ({
|
||||
mockGetBlock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/blocks/registry', () => ({
|
||||
getBlock: mockGetBlock,
|
||||
getBlockByToolName: vi.fn(),
|
||||
getLatestBlock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/auth-client', () => ({
|
||||
useSession: vi.fn(() => ({ data: null, isPending: false })),
|
||||
}))
|
||||
|
||||
interface MockAgentGroupItem {
|
||||
type: string
|
||||
data?: { id: string; displayTitle: string }
|
||||
}
|
||||
|
||||
vi.mock('./components', () => ({
|
||||
AgentGroup: ({ items }: { items: MockAgentGroupItem[] }) => (
|
||||
<div>
|
||||
{items.map((item) => item.data && <span key={item.data.id}>{item.data.displayTitle}</span>)}
|
||||
</div>
|
||||
),
|
||||
ChatContent: () => null,
|
||||
CircleStop: () => null,
|
||||
Options: () => null,
|
||||
PendingTagIndicator: () => null,
|
||||
}))
|
||||
|
||||
import type { ContentBlock } from '@/app/workspace/[workspaceId]/home/types'
|
||||
import { notifyBlockOverlayChanged } from '@/blocks/custom/client-overlay'
|
||||
import { MessageContent } from './message-content'
|
||||
|
||||
describe('MessageContent custom-block hydration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
})
|
||||
|
||||
it('refreshes a read title when the custom-block registry hydrates after mount', () => {
|
||||
mockGetBlock.mockReturnValue(undefined)
|
||||
const blocks: ContentBlock[] = [
|
||||
{
|
||||
type: 'tool_call',
|
||||
toolCall: {
|
||||
id: 'read-custom-block',
|
||||
name: 'read',
|
||||
status: 'success',
|
||||
params: {
|
||||
path: 'organization/custom-blocks/custom_block_invoice_parser.json',
|
||||
},
|
||||
},
|
||||
timestamp: 1,
|
||||
},
|
||||
]
|
||||
const container = document.createElement('div')
|
||||
const root: Root = createRoot(container)
|
||||
|
||||
act(() => {
|
||||
root.render(<MessageContent blocks={blocks} fallbackContent='' isStreaming={false} />)
|
||||
})
|
||||
expect(container.textContent).toContain('Read Custom block invoice parser')
|
||||
|
||||
mockGetBlock.mockReturnValue({
|
||||
type: 'custom_block_invoice_parser',
|
||||
name: 'Invoice Parser',
|
||||
icon: () => null,
|
||||
})
|
||||
act(() => notifyBlockOverlayChanged())
|
||||
|
||||
expect(container.textContent).toContain('Read Invoice Parser')
|
||||
expect(container.textContent).not.toContain('Read Custom block invoice parser')
|
||||
act(() => root.unmount())
|
||||
})
|
||||
})
|
||||
+6
-1
@@ -22,6 +22,7 @@ import {
|
||||
} from '@/lib/copilot/tools/tool-display'
|
||||
import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
|
||||
import type { CredentialSubmissionPayload } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
|
||||
import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay'
|
||||
import type { ContentBlock, OptionItem, ToolCallData } from '../../types'
|
||||
import { SUBAGENT_LABELS } from '../../types'
|
||||
import type { AgentGroupItem } from './components'
|
||||
@@ -851,7 +852,11 @@ function MessageContentInner({
|
||||
actions,
|
||||
}: MessageContentProps) {
|
||||
const { onWorkspaceResourceSelect } = useChatSurface()
|
||||
const parsed = useMemo(() => (blocks.length > 0 ? parseBlocks(blocks) : []), [blocks])
|
||||
const blockOverlayVersion = useCustomBlockOverlayVersion()
|
||||
const parsed = useMemo(
|
||||
() => (blocks.length > 0 ? parseBlocks(blocks) : []),
|
||||
[blocks, blockOverlayVersion]
|
||||
)
|
||||
|
||||
const [trailingRevealing, setTrailingRevealing] = useState(false)
|
||||
const handleTrailingRevealChange = useCallback((revealing: boolean) => {
|
||||
|
||||
+2
-8
@@ -289,14 +289,8 @@ export const PlusMenuDropdown = React.memo(
|
||||
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: anchorPos?.left ?? 0,
|
||||
top: anchorPos?.top ?? 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
className='pointer-events-none fixed size-0'
|
||||
style={{ left: anchorPos?.left ?? 0, top: anchorPos?.top ?? 0 }}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
|
||||
-1
@@ -218,7 +218,6 @@ export function usePromptEditor({
|
||||
const mentionMenu = useMentionMenu({
|
||||
message: value,
|
||||
selectedContexts: contextManagement.selectedContexts,
|
||||
onContextSelect: addContextNotified,
|
||||
onMessageChange: commitValue,
|
||||
})
|
||||
|
||||
|
||||
+2
-8
@@ -182,14 +182,8 @@ export const SkillsMenuDropdown = React.memo(
|
||||
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: anchorPos?.left ?? 0,
|
||||
top: anchorPos?.top ?? 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
className='pointer-events-none fixed size-0'
|
||||
style={{ left: anchorPos?.left ?? 0, top: anchorPos?.top ?? 0 }}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
|
||||
@@ -38,10 +38,7 @@ import { RESOURCE_HEADER_CLASSES } from '@/app/workspace/[workspaceId]/home/comp
|
||||
import { resolveWorkspaceResourceRef } from '@/app/workspace/[workspaceId]/home/resolve-resource-ref'
|
||||
import { resourceParam, resourceUrlKeys } from '@/app/workspace/[workspaceId]/home/search-params'
|
||||
import { useFolders } from '@/hooks/queries/folders'
|
||||
import {
|
||||
useMarkMothershipChatRead,
|
||||
useMothershipChatHistory,
|
||||
} from '@/hooks/queries/mothership-chats'
|
||||
import { useMarkMothershipChatRead } from '@/hooks/queries/mothership-chats'
|
||||
import { useWorkflows } from '@/hooks/queries/workflows'
|
||||
import { getWorkspaceFilesQueryOptions, useWorkspaceFiles } from '@/hooks/queries/workspace-files'
|
||||
import { useOAuthReturnRouter } from '@/hooks/use-oauth-return'
|
||||
@@ -205,7 +202,6 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps)
|
||||
|
||||
const wasSendingRef = useRef(false)
|
||||
|
||||
const { isPending: isChatHistoryPending } = useMothershipChatHistory(chatId)
|
||||
const { mutate: markRead } = useMarkMothershipChatRead(workspaceId)
|
||||
|
||||
const [isResourceCollapsed, setIsResourceCollapsed] = useState(true)
|
||||
@@ -242,6 +238,7 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps)
|
||||
|
||||
const {
|
||||
messages,
|
||||
isChatHistoryPending,
|
||||
isSending,
|
||||
isReconnecting,
|
||||
sendMessage,
|
||||
|
||||
@@ -193,6 +193,7 @@ interface WithdrawnSend {
|
||||
|
||||
export interface UseChatReturn {
|
||||
messages: ChatMessage[]
|
||||
isChatHistoryPending: boolean
|
||||
isSending: boolean
|
||||
isReconnecting: boolean
|
||||
error: string | null
|
||||
@@ -1790,7 +1791,8 @@ export function useChat(
|
||||
[flushPendingResources, queryClient, workspaceId]
|
||||
)
|
||||
|
||||
const { data: chatHistory } = useMothershipChatHistory(resolvedChatId)
|
||||
const { data: chatHistory, isPending: isChatHistoryPending } =
|
||||
useMothershipChatHistory(resolvedChatId)
|
||||
const messages = useMemo(() => {
|
||||
const source = chatHistory?.messages.map(toDisplayMessage) ?? pendingMessages
|
||||
return source.map((m) => restoreRevealedSimKeysForMessage(m, revealedSimKeysRef.current))
|
||||
@@ -5210,6 +5212,7 @@ export function useChat(
|
||||
|
||||
return {
|
||||
messages,
|
||||
isChatHistoryPending,
|
||||
isSending,
|
||||
isReconnecting,
|
||||
error,
|
||||
|
||||
+2
-1
@@ -381,7 +381,7 @@ export function DocumentTagsModal({
|
||||
<ChipModalHeader onClose={() => handleClose(false)}>Document Tags</ChipModalHeader>
|
||||
|
||||
<ChipModalBody>
|
||||
<ChipModalField type='custom' title='Tags'>
|
||||
<ChipModalField type='custom' title='Tags' submitOnEnter={false}>
|
||||
<div className='space-y-2'>
|
||||
{documentTags.map((tag, index) => (
|
||||
<div key={tag.displayName} className='space-y-2'>
|
||||
@@ -737,6 +737,7 @@ export function DocumentTagsModal({
|
||||
|
||||
<ChipModalFooter
|
||||
onCancel={() => handleClose(false)}
|
||||
defaultAction='none'
|
||||
primaryAction={{ label: 'Close', onClick: () => handleClose(false) }}
|
||||
/>
|
||||
</ChipModal>
|
||||
|
||||
@@ -1372,6 +1372,7 @@ export function KnowledgeBase({
|
||||
onOpenChange={setShowDeleteDialog}
|
||||
srTitle='Delete Knowledge Base'
|
||||
title='Delete Knowledge Base'
|
||||
defaultAction='dismiss'
|
||||
text={[
|
||||
'Are you sure you want to delete ',
|
||||
{ text: knowledgeBaseName, bold: true },
|
||||
|
||||
+2
@@ -248,6 +248,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
<ChipModalBody>
|
||||
<ChipModalField
|
||||
type='custom'
|
||||
submitOnEnter={false}
|
||||
title={
|
||||
<>
|
||||
Tags:{' '}
|
||||
@@ -389,6 +390,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
|
||||
<ChipModalFooter
|
||||
onCancel={() => handleClose(false)}
|
||||
defaultAction='none'
|
||||
primaryAction={{ label: 'Close', onClick: () => handleClose(false) }}
|
||||
/>
|
||||
</ChipModal>
|
||||
|
||||
+227
-11
@@ -1,17 +1,30 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import type { ReactNode, SVGProps } from 'react'
|
||||
import type { ButtonHTMLAttributes, ReactNode, SVGProps } from 'react'
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SyncLogData } from '@/lib/api/contracts/knowledge/connectors'
|
||||
import { CONNECTOR_SYNC_STALE_LOCK_TTL_MS } from '@/lib/knowledge/connectors/sync-limits'
|
||||
|
||||
const { icon } = vi.hoisted(() => ({
|
||||
const {
|
||||
consumeOAuthReturnContextMock,
|
||||
connectOAuthModalMock,
|
||||
credentialRefreshTriggersMock,
|
||||
icon,
|
||||
oauthCredentialsState,
|
||||
} = vi.hoisted(() => ({
|
||||
consumeOAuthReturnContextMock: vi.fn(),
|
||||
connectOAuthModalMock: vi.fn(),
|
||||
credentialRefreshTriggersMock: vi.fn(),
|
||||
icon: (name: string) => (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg data-testid={`icon-${name}`} className={props.className} />
|
||||
),
|
||||
oauthCredentialsState: {
|
||||
current: [] as Array<{ id: string; name: string; provider: string }>,
|
||||
isFetching: false,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@sim/emcn/icons', () => ({
|
||||
@@ -30,7 +43,16 @@ vi.mock('@sim/emcn/icons', () => ({
|
||||
|
||||
vi.mock('@sim/emcn', () => ({
|
||||
Badge: ({ children }: { children?: ReactNode }) => <span>{children}</span>,
|
||||
Button: ({ children }: { children?: ReactNode }) => <button type='button'>{children}</button>,
|
||||
Button: ({
|
||||
children,
|
||||
variant: _variant,
|
||||
size: _size,
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: string; size?: string }) => (
|
||||
<button type='button' {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Checkbox: () => <input type='checkbox' />,
|
||||
ChipConfirmModal: () => null,
|
||||
cn: (...classes: unknown[]) => classes.filter(Boolean).join(' '),
|
||||
@@ -38,20 +60,27 @@ vi.mock('@sim/emcn', () => ({
|
||||
DropdownMenuContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuItem: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuTrigger: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
Tooltip: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
Tooltip: {
|
||||
Root: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
Trigger: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
Content: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/credentials/client-state', () => ({
|
||||
consumeOAuthReturnContext: vi.fn(),
|
||||
consumeOAuthReturnContext: consumeOAuthReturnContextMock,
|
||||
writeOAuthReturnContext: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/oauth', () => ({
|
||||
getCanonicalScopesForProvider: vi.fn(() => []),
|
||||
getProviderIdFromServiceId: vi.fn(() => undefined),
|
||||
getProviderIdFromServiceId: vi.fn(() => 'slack'),
|
||||
}))
|
||||
vi.mock('@/lib/oauth/utils', () => ({ getMissingRequiredScopes: vi.fn(() => []) }))
|
||||
vi.mock('@/app/workspace/[workspaceId]/components/connect-oauth-modal', () => ({
|
||||
ConnectOAuthModal: () => null,
|
||||
ConnectOAuthModal: (props: unknown) => {
|
||||
connectOAuthModalMock(props)
|
||||
return null
|
||||
},
|
||||
}))
|
||||
vi.mock(
|
||||
'@/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal',
|
||||
@@ -59,21 +88,41 @@ vi.mock(
|
||||
)
|
||||
vi.mock('@/blocks', () => ({ getBlock: vi.fn(() => undefined) }))
|
||||
vi.mock('@/blocks/icon-color', () => ({ getTileIconColorClass: vi.fn(() => '') }))
|
||||
vi.mock('@/connectors/registry', () => ({ CONNECTOR_META_REGISTRY: {} }))
|
||||
vi.mock('@/connectors/registry', () => ({
|
||||
CONNECTOR_META_REGISTRY: {
|
||||
slack: {
|
||||
id: 'slack',
|
||||
name: 'Slack',
|
||||
auth: { mode: 'oauth', provider: 'slack', requiredScopes: ['channels:read'] },
|
||||
},
|
||||
},
|
||||
}))
|
||||
vi.mock('@/hooks/queries/kb/connectors', () => ({
|
||||
isConnectorSyncingOrPending: vi.fn(
|
||||
(connector: { status: string }) =>
|
||||
connector.status === 'pending' || connector.status === 'syncing'
|
||||
),
|
||||
useConnectorDetail: vi.fn(() => ({ data: undefined, isLoading: false })),
|
||||
useDeleteConnector: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
|
||||
useTriggerSync: vi.fn(() => ({ mutate: vi.fn() })),
|
||||
useUpdateConnector: vi.fn(() => ({ mutate: vi.fn() })),
|
||||
}))
|
||||
vi.mock('@/hooks/queries/oauth/oauth-credentials', () => ({
|
||||
useOAuthCredentials: vi.fn(() => ({ data: [] })),
|
||||
useOAuthCredentials: vi.fn(() => ({
|
||||
data: oauthCredentialsState.current,
|
||||
isFetching: oauthCredentialsState.isFetching,
|
||||
refetch: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
vi.mock('@/hooks/use-credential-refresh-triggers', () => ({
|
||||
useCredentialRefreshTriggers: vi.fn(),
|
||||
useCredentialRefreshTriggers: credentialRefreshTriggersMock,
|
||||
}))
|
||||
|
||||
import { SyncHistory } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section'
|
||||
import {
|
||||
ConnectorsSection,
|
||||
SyncHistory,
|
||||
} from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section'
|
||||
import type { ConnectorData } from '@/hooks/queries/kb/connectors'
|
||||
|
||||
let root: Root | null = null
|
||||
|
||||
@@ -102,6 +151,46 @@ function render(log: SyncLogData) {
|
||||
return container
|
||||
}
|
||||
|
||||
function makeConnector(overrides: Partial<ConnectorData> = {}): ConnectorData {
|
||||
return {
|
||||
id: 'connector-1',
|
||||
knowledgeBaseId: 'knowledge-1',
|
||||
connectorType: 'slack',
|
||||
credentialId: 'credential-1',
|
||||
sourceConfig: {},
|
||||
syncMode: null,
|
||||
syncIntervalMinutes: 60,
|
||||
status: 'disabled',
|
||||
lastSyncAt: null,
|
||||
lastSyncError: 'invalid_auth',
|
||||
lastSyncDocCount: null,
|
||||
nextSyncAt: null,
|
||||
consecutiveFailures: 3,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function renderSection(connector: ConnectorData) {
|
||||
;(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(
|
||||
<ConnectorsSection
|
||||
workspaceId='workspace-1'
|
||||
knowledgeBaseId='knowledge-1'
|
||||
connectors={[connector]}
|
||||
isLoading={false}
|
||||
canEdit
|
||||
/>
|
||||
)
|
||||
)
|
||||
return container
|
||||
}
|
||||
|
||||
function icons(container: HTMLElement) {
|
||||
return Array.from(container.querySelectorAll('[data-testid^="icon-"]')).map((node) =>
|
||||
node.getAttribute('data-testid')
|
||||
@@ -112,9 +201,136 @@ afterEach(() => {
|
||||
act(() => root?.unmount())
|
||||
root = null
|
||||
document.body.innerHTML = ''
|
||||
oauthCredentialsState.current = []
|
||||
oauthCredentialsState.isFetching = false
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Connector credential reauthorization', () => {
|
||||
it('fails closed when the connector credential cannot be resolved', () => {
|
||||
const container = renderSection(makeConnector())
|
||||
const reconnectButton = Array.from(container.querySelectorAll('button')).find(
|
||||
(button) => button.textContent === 'Reconnect'
|
||||
)
|
||||
|
||||
expect(reconnectButton?.disabled).toBe(true)
|
||||
|
||||
act(() => reconnectButton?.click())
|
||||
|
||||
expect(connectOAuthModalMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reauthorizes with the resolved credential provider and identity', () => {
|
||||
oauthCredentialsState.current = [
|
||||
{ id: 'credential-1', name: 'Workspace Slack', provider: 'slack-custom' },
|
||||
]
|
||||
const container = renderSection(makeConnector())
|
||||
const reconnectButton = Array.from(container.querySelectorAll('button')).find(
|
||||
(button) => button.textContent === 'Reconnect'
|
||||
)
|
||||
|
||||
expect(reconnectButton?.disabled).toBe(false)
|
||||
|
||||
act(() => reconnectButton?.click())
|
||||
|
||||
expect(connectOAuthModalMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
providerId: 'slack-custom',
|
||||
reconnectTarget: {
|
||||
workspaceId: 'workspace-1',
|
||||
credentialId: 'credential-1',
|
||||
displayName: 'Workspace Slack',
|
||||
},
|
||||
})
|
||||
)
|
||||
expect(credentialRefreshTriggersMock).toHaveBeenLastCalledWith(
|
||||
expect.any(Function),
|
||||
'slack-custom',
|
||||
'workspace-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps reauthorization open while the credential query is loading', () => {
|
||||
oauthCredentialsState.current = [
|
||||
{ id: 'credential-1', name: 'Workspace Slack', provider: 'slack-custom' },
|
||||
]
|
||||
const connector = makeConnector()
|
||||
const container = renderSection(connector)
|
||||
const reconnectButton = Array.from(container.querySelectorAll('button')).find(
|
||||
(button) => button.textContent === 'Reconnect'
|
||||
)
|
||||
|
||||
act(() => reconnectButton?.click())
|
||||
expect(connectOAuthModalMock).toHaveBeenCalledOnce()
|
||||
|
||||
connectOAuthModalMock.mockClear()
|
||||
oauthCredentialsState.current = []
|
||||
oauthCredentialsState.isFetching = true
|
||||
act(() =>
|
||||
root?.render(
|
||||
<ConnectorsSection
|
||||
workspaceId='workspace-1'
|
||||
knowledgeBaseId='knowledge-1'
|
||||
connectors={[connector]}
|
||||
isLoading={false}
|
||||
canEdit
|
||||
/>
|
||||
)
|
||||
)
|
||||
|
||||
expect(consumeOAuthReturnContextMock).not.toHaveBeenCalled()
|
||||
|
||||
oauthCredentialsState.current = [
|
||||
{ id: 'credential-1', name: 'Workspace Slack', provider: 'slack-custom' },
|
||||
]
|
||||
oauthCredentialsState.isFetching = false
|
||||
act(() =>
|
||||
root?.render(
|
||||
<ConnectorsSection
|
||||
workspaceId='workspace-1'
|
||||
knowledgeBaseId='knowledge-1'
|
||||
connectors={[connector]}
|
||||
isLoading={false}
|
||||
canEdit
|
||||
/>
|
||||
)
|
||||
)
|
||||
|
||||
expect(connectOAuthModalMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('clears the OAuth return context if the credential disappears while open', () => {
|
||||
oauthCredentialsState.current = [
|
||||
{ id: 'credential-1', name: 'Workspace Slack', provider: 'slack-custom' },
|
||||
]
|
||||
const connector = makeConnector()
|
||||
const container = renderSection(connector)
|
||||
const reconnectButton = Array.from(container.querySelectorAll('button')).find(
|
||||
(button) => button.textContent === 'Reconnect'
|
||||
)
|
||||
|
||||
act(() => reconnectButton?.click())
|
||||
expect(connectOAuthModalMock).toHaveBeenCalledOnce()
|
||||
|
||||
connectOAuthModalMock.mockClear()
|
||||
oauthCredentialsState.current = []
|
||||
act(() =>
|
||||
root?.render(
|
||||
<ConnectorsSection
|
||||
workspaceId='workspace-1'
|
||||
knowledgeBaseId='knowledge-1'
|
||||
connectors={[connector]}
|
||||
isLoading={false}
|
||||
canEdit
|
||||
/>
|
||||
)
|
||||
)
|
||||
|
||||
expect(consumeOAuthReturnContextMock).toHaveBeenCalledOnce()
|
||||
expect(connectOAuthModalMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SyncHistory', () => {
|
||||
it('renders a fresh "started" row as in progress, not as a success', () => {
|
||||
const container = render(makeLog({ status: 'started' }))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user