diff --git a/.agents/skills/react-query-best-practices/SKILL.md b/.agents/skills/react-query-best-practices/SKILL.md index 4074fd5dc54..2bce17ad86f 100644 --- a/.agents/skills/react-query-best-practices/SKILL.md +++ b/.agents/skills/react-query-best-practices/SKILL.md @@ -35,6 +35,12 @@ Read these before analyzing: - Every query must have an explicit `staleTime` (default 0 is almost never correct), assigned from a named exported constant — never an inline numeric literal. A server-side prefetch hydrating the same query key must import and reuse that constant instead of restating the number - `keepPreviousData` / `placeholderData` only on variable-key queries (where params change), never on static keys - Use `enabled` to prevent queries from running without required params +- Warm data for hover/focus intent with `queryClient.prefetchQuery` and shared `queryOptions`; never temporarily enable a mounted hidden observer, which can remain active after focus restoration and refetch data for closed UI +- When gating a query by view or modal state, move every consumer to the active query too: imperative refresh/pagination, loading and error feedback, and data-derived controls must never read a disabled query or placeholder data from a previous key +- Compose caller-controlled `enabled` options with required-param guards (`Boolean(id) && (options?.enabled ?? true)`). Never spread options after an internal guard, because `{ enabled: true }` can silently re-enable an invalid request. +- A disabled query can still report `isPending: true`. Aggregate loading state only for queries that are applicable/enabled, or an optional query can hold the whole surface in a permanent loading state. +- Deferred authorization or policy queries must fail closed. Do not give pending/error data the same fallback as a successfully loaded unrestricted policy; disable guarded actions until the policy query succeeds. +- Server prefetches must call the authorized use case, apply the route presenter/response schema, and reuse the client's exact key, mapper, and stale time. Keep all fallible auth/read/parse work inside `queryFn` so an optional warm cannot fail the page, and never bypass a route that redacts fields. ### Mutations - Use `onSettled` (not `onSuccess`) for cache reconciliation — it fires on both success and error @@ -46,7 +52,7 @@ Read these before analyzing: - Never copy query data into useState. Use query data directly in components. - Never copy query data into Zustand stores (exception: mutation callbacks that coordinate cross-store state like temp ID replacement) - The query cache is not a local state manager — `setQueryData` is for optimistic updates only -- Forms are the one deliberate exception: copy server data into local form state with `staleTime: Infinity` +- Forms are the one deliberate exception: once query data exists, initialize a keyed form subtree from it with lazy state initializers. Do not synchronize query data into draft state with an Effect; key the form by resource identity so switching resources resets every draft/modal/upload field together. Keep independent queries in the outer wrapper so they still start in parallel. ## Steps diff --git a/.agents/skills/ship/SKILL.md b/.agents/skills/ship/SKILL.md index 76c5bbf3643..f9e8aef93bf 100644 --- a/.agents/skills/ship/SKILL.md +++ b/.agents/skills/ship/SKILL.md @@ -150,7 +150,7 @@ gh pr create --base staging --title "COMMIT_MESSAGE" --body "PR_BODY" ## Important Notes -- Always confirm the commit message and PR description with the user before executing +- Do not ask the user to confirm the commit message or PR description before executing - The PR should be created against `staging` branch - Keep descriptions concise and in active voice - Match the user's previous PR style: direct, no fluff, bullet points diff --git a/.agents/skills/you-might-not-need-an-effect/SKILL.md b/.agents/skills/you-might-not-need-an-effect/SKILL.md index 287bdf4dda1..d2bf26b9cfb 100644 --- a/.agents/skills/you-might-not-need-an-effect/SKILL.md +++ b/.agents/skills/you-might-not-need-an-effect/SKILL.md @@ -16,3 +16,7 @@ Steps: 1. Read https://react.dev/learn/you-might-not-need-an-effect to understand the guidelines 2. Analyze the specified scope for useEffect anti-patterns 3. If fix=true, apply the fixes. If fix=false, propose the fixes without applying. + +## Query-backed forms + +When query data supplies the initial values for an editable form, do not copy it into draft state in an Effect. Render loading chrome in an outer component, then mount a keyed form child once data exists and initialize its state lazily from props. Key by the resource identity so every related draft, dialog, and upload state resets together when the resource changes. Keep independent queries in the outer component to preserve parallel fetching. diff --git a/.claude/rules/sim-list-ordering.md b/.claude/rules/sim-list-ordering.md index 2966eb4a1a6..cad6c2104b3 100644 --- a/.claude/rules/sim-list-ordering.md +++ b/.claude/rules/sim-list-ordering.md @@ -26,6 +26,81 @@ Left-to-right becomes top-to-bottom. A toolbar reading `Filter · Sort · Export Platform-only entries (desktop **Browser** and **Terminal**) trail the shared set rather than interleaving, so the common prefix is identical on every platform. +## Grouping: a rule marks a change in what the action acts on + +Order is governed above. **Separators are governed here.** + +A `DropdownMenuSeparator` earns its place when the next group stops acting on the thing the user +clicked. That is the whole test — one question, asked the same way in every menu: + +| The group | Gets a rule before it | +| --- | --- | +| Acts on the clicked item (open, rename, duplicate, export, copy, edit, pin, run) | no — this is the body of the menu | +| Acts on **something else** — the page's filters or view, or a newly created sibling | yes | +| **Destroys or detaches** it (delete, leave, close, hide, remove) | yes | + +Most row menus only ever have the one transition, so they carry one rule, immediately before +`Delete`. A menu that also filters the page or inserts siblings carries two. Nothing carries +more, because there is no third thing a menu acts on. + +Do **not** band by verb. "Navigation", "status", "edit", "copy" are categories of *what the verb +is*, not of *what it touches*, and the user meets no such taxonomy anywhere else — every toolbar +in the app is a flat `gap-1` chip row with no dividers. Menus banded that way put the same action +in different groups depending on which siblings happened to be visible. + +The consequential group trails in almost every menu. It leads in exactly one: the **logs row +menu**, where `Retry` and `Cancel Run` act on the run itself and are the primary actions on a +failure, so they sit on top with the rule beneath them. Ordering follows the surface (see "The +rule" above); the separator fences whichever end that group occupies. + +A group whose items are merely *disabled* still gets no extra rule — `disabled` is not a group. + +```tsx +// ✗ Bad — four semantic bands the user meets nowhere else +Open in new tab │─── Rename, Lock │─── Duplicate, Export │─── Delete + +// ✓ Good — one rule, where the menu stops acting on the workflow +Open in new tab, Rename, Lock, Duplicate, Export │─── Delete +``` + +**Worked examples.** The logs row menu carries two: `Retry, Cancel Run │ Copy Run ID, Copy Link, +Open Workflow, Open Snapshot │ Filter by Workflow, Clear Filters` — the run, then this log, then +the page. The table row and column menus carry two: the rule before `Insert row above` / +`Insert column left` is where the menu stops acting on the clicked cell and starts creating +siblings. Every other row menu in the app has only the destructive transition, so it carries one. + +**The one standing exception: menus that emulate a native menu.** The text-editor menu +(`editor-context-menu.tsx`), the terminal menu (`terminal-context-menu.tsx`), and the browser +page menu (`browser-session.tsx`) each mirror the OS menu the user already knows — clipboard +banding (`Cut · Copy · Paste │ Select all`) is a convention every text field on their machine +teaches them. These keep their native banding, and that is the *same* principle as the ordering +rule above: mirror the surface the user already reads. The test is whether a real menu outside +Sim taught them the grouping. Our own resource, row, and action menus have no such precedent — +the toolbars they mirror are flat — so they take the single rule. + +**Both sides of every rule must be guaranteed non-empty.** Write the separator's guard out of +the *exact* render conditions of the items around it, never a looser approximation: + +```tsx +// ✗ Bad — `showLeave` alone, while the Leave item needs `showLeave && onLeave`. +// A caller passing showLeave from a permission check with a conditional +// onLeave renders a trailing rule under the last item. +{hasActionsAbove && (showLeave || showDelete) && } + +// ✓ Good — each term is the item's own condition, verbatim +const hasDestructiveSection = (showLeave && onLeave) || showDelete +{hasActionsAboveDestructive && hasDestructiveSection && } +``` + +This is the failure that put a dangling rule at the bottom of the logs row menu, where two +unconditional separators sat above conditional items. + +**Do not add a prop to move a rule.** The shared workflow context menu grew +`groupNonDestructiveActions` and `separateNavigationAction` for this; between them they moved one +separator for one caller, four of six branches were unreachable, and `separateNavigationAction` +had no observable effect anywhere in the repo. Both are gone. A menu that wants different +grouping wants the standard grouping. + ## Encode the order once An order duplicated across surfaces is an order that will drift. Export **one** constant and sort by it — do not hand-maintain a matching literal per menu. diff --git a/CLAUDE.md b/CLAUDE.md index 60c0990eecc..773e3bccf51 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -386,7 +386,9 @@ Co-locate a `search-params.ts` per feature exporting the parser map (single sour A list orders itself the way the user already reads the same things somewhere else. Resource menus (`+` attach, `@` mention, resource-tab `+`) mirror the **sidebar** top-down; a row or root **context menu** mirrors that surface's **toolbar**, left-to-right becoming top-to-bottom; tab strips mirror their nav. Platform-only entries (desktop Browser, Terminal) trail the shared set. -Encode the order in ONE exported constant and sort by it — never a hand-maintained literal per menu (`RESOURCE_MENU_ORDER` / `byResourceMenuOrder` in `home/components/mothership-view/components/resource-registry`). Render mixed item kinds in a single ordered pass; emitting all submenu-backed families and then all flat ones silently pins every submenu to the top no matter what the constant says. Divergence is allowed only for search ranking, user-controlled ordering, and recency. Full rule in `.claude/rules/sim-list-ordering.md`. +Encode the order in ONE exported constant and sort by it — never a hand-maintained literal per menu (`RESOURCE_MENU_ORDER` / `byResourceMenuOrder` in `home/components/mothership-view/components/resource-registry`). Render mixed item kinds in a single ordered pass; emitting all submenu-backed families and then all flat ones silently pins every submenu to the top no matter what the constant says. Divergence is allowed only for search ranking, user-controlled ordering, and recency. + +**Grouping**: a `DropdownMenuSeparator` marks a change in WHAT the action acts on — the clicked item (no rule), something else like the page's filters or a new sibling (rule), or destroying it (rule). Most row menus have only the destructive transition and carry one rule before Delete/Leave/Close/Hide; menus that also filter the page or insert siblings carry two. Never band by verb (navigation/status/edit/copy) — the toolbars are flat, so that taxonomy exists nowhere else. No toolbar in the app renders a divider, so multi-band menus teach a taxonomy that exists on no other surface. Build each separator's guard from the EXACT render conditions of the items on both sides — a looser guard is what leaves a dangling rule when its group is conditional. Never add a prop to move a rule. Full rule in `.claude/rules/sim-list-ordering.md`. ## Styling diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c8431b946ae..5fa7d00c9e1 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -9,7 +9,7 @@ "type": "module", "main": "dist/main.cjs", "engines": { - "bun": ">=1.2.13", + "bun": ">=1.3.14", "node": ">=20.0.0" }, "scripts": { diff --git a/apps/docs/content/docs/en/cli/commands.mdx b/apps/docs/content/docs/en/cli/commands.mdx index 67c44e3f197..8a1e209c3a1 100644 --- a/apps/docs/content/docs/en/cli/commands.mdx +++ b/apps/docs/content/docs/en/cli/commands.mdx @@ -113,3 +113,29 @@ sim configure [options] | `--unset ` | No | Remove settings (endpoint, workspace, output). | + +## Ask Sim and print the reply + +```bash +sim chat [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `message` | Yes | What to ask Sim | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-c, --conversation ` | No | Continue the conversation with this ID. | + + diff --git a/apps/docs/content/docs/en/cli/knowledge.mdx b/apps/docs/content/docs/en/cli/knowledge.mdx index a2fb532b6d8..d09f27e1dad 100644 --- a/apps/docs/content/docs/en/cli/knowledge.mdx +++ b/apps/docs/content/docs/en/cli/knowledge.mdx @@ -216,7 +216,7 @@ sim knowledge create [options] ## Create knowledge connector ```bash -sim knowledge connectors create [options] +sim knowledge connectors create [options] ``` **Arguments** @@ -225,7 +225,7 @@ sim knowledge connectors create [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique knowledge base identifier. | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | @@ -246,7 +246,7 @@ sim knowledge connectors create [options] ## Delete knowledge connector ```bash -sim knowledge connectors delete [options] +sim knowledge connectors delete [options] ``` **Arguments** @@ -255,7 +255,7 @@ sim knowledge connectors delete [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Knowledge base that owns the connector. | +| `knowledgeBaseId` | Yes | Knowledge base that owns the connector. | | `connectorId` | Yes | Connector selected for the operation. | @@ -275,7 +275,7 @@ sim knowledge connectors delete [options] ## Get knowledge connector ```bash -sim knowledge connectors get +sim knowledge connectors get ``` **Arguments** @@ -284,7 +284,7 @@ sim knowledge connectors get | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Knowledge base that owns the connector. | +| `knowledgeBaseId` | Yes | Knowledge base that owns the connector. | | `connectorId` | Yes | Connector selected for the operation. | @@ -292,7 +292,7 @@ sim knowledge connectors get ## List knowledge connector documents ```bash -sim knowledge connectors documents list [options] +sim knowledge connectors documents list [options] ``` **Arguments** @@ -301,7 +301,7 @@ sim knowledge connectors documents list [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Knowledge base that owns the connector. | +| `knowledgeBaseId` | Yes | Knowledge base that owns the connector. | | `connectorId` | Yes | Connector selected for the operation. | @@ -318,10 +318,38 @@ sim knowledge connectors documents list [options] +## Update knowledge connector documents + +```bash +sim knowledge connectors documents update [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `knowledgeBaseId` | Yes | Knowledge base that owns the connector. | +| `connectorId` | Yes | Connector selected for the operation. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--operation ` | Yes | Whether to restore or exclude the selected documents. Accepted values: `restore`, `exclude`. | +| `--document ` | Yes | Connector document identifiers to update. (space-separated, or @path / @- with one value per line). | + + + ## List knowledge connectors ```bash -sim knowledge connectors list [options] +sim knowledge connectors list [options] ``` **Arguments** @@ -330,7 +358,7 @@ sim knowledge connectors list [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique knowledge base identifier. | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | @@ -346,10 +374,10 @@ sim knowledge connectors list [options] -## Update knowledge connector +## Queue a knowledge connector synchronization ```bash -sim knowledge connectors update [options] +sim knowledge connectors sync [options] ``` **Arguments** @@ -358,7 +386,7 @@ sim knowledge connectors update [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Knowledge base that owns the connector. | +| `knowledgeBaseId` | Yes | Knowledge base that owns the connector. | | `connectorId` | Yes | Connector selected for the operation. | @@ -369,16 +397,15 @@ sim knowledge connectors update [options] | Option | Required | Description | | --- | --- | --- | -| `--source-config ` | No | Replacement source selection and filtering configuration. (JSON, or @path / @- to read a file or stdin). | -| `--sync-interval-minutes ` | No | New scheduled synchronization interval in minutes. | -| `--status ` | No | New connector state. Accepted values: `active`, `paused`. | +| `--rehydrate` | No | Re-fetch and re-index every existing connector document. | +| `--no-rehydrate` | No | Send --rehydrate as false. | -## Update knowledge connector documents +## Update knowledge connector ```bash -sim knowledge connectors documents update [options] +sim knowledge connectors update [options] ``` **Arguments** @@ -387,7 +414,7 @@ sim knowledge connectors documents update [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Knowledge base that owns the connector. | +| `knowledgeBaseId` | Yes | Knowledge base that owns the connector. | | `connectorId` | Yes | Connector selected for the operation. | @@ -398,8 +425,9 @@ sim knowledge connectors documents update [options] | Option | Required | Description | | --- | --- | --- | -| `--operation ` | Yes | Whether to restore or exclude the selected documents. Accepted values: `restore`, `exclude`. | -| `--document ` | Yes | Connector document identifiers to update. (space-separated, or @path / @- with one value per line). | +| `--source-config ` | No | Replacement source selection and filtering configuration. Updating a runnable connector queues synchronization; paused connectors remain paused. (JSON, or @path / @- to read a file or stdin). | +| `--sync-interval-minutes ` | No | New scheduled synchronization interval in minutes. | +| `--status ` | No | New connector state. Accepted values: `active`, `paused`. | @@ -588,34 +616,6 @@ sim knowledge search [options] -## Sync knowledge connector - -```bash -sim knowledge sync create [options] -``` - -**Arguments** - - - -| Argument | Required | Description | -| --- | --- | --- | -| `id` | Yes | Knowledge base that owns the connector. | -| `connectorId` | Yes | Connector selected for the operation. | - - - -**Options** - - - -| Option | Required | Description | -| --- | --- | --- | -| `--rehydrate` | No | Re-fetch and re-index every existing connector document. | -| `--no-rehydrate` | No | Send --rehydrate as false. | - - - ## Update knowledge base ```bash diff --git a/apps/docs/content/docs/en/cli/reference.mdx b/apps/docs/content/docs/en/cli/reference.mdx index 4346ef28715..de5a6bd8f34 100644 --- a/apps/docs/content/docs/en/cli/reference.mdx +++ b/apps/docs/content/docs/en/cli/reference.mdx @@ -101,6 +101,34 @@ sim configure [options] +## sim chat + +Ask Sim and print the reply + +```bash +sim chat [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `message` | Yes | What to ask Sim | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-c, --conversation ` | No | Continue the conversation with this ID. | + + + ## sim profiles Also spelled `sim profile`. @@ -1208,7 +1236,7 @@ sim knowledge create [options] Create Knowledge Connector ```bash -sim knowledge connectors create [options] +sim knowledge connectors create [options] ``` **Arguments** @@ -1217,7 +1245,7 @@ sim knowledge connectors create [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique knowledge base identifier. | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | @@ -1240,7 +1268,7 @@ sim knowledge connectors create [options] Delete Knowledge Connector ```bash -sim knowledge connectors delete [options] +sim knowledge connectors delete [options] ``` **Arguments** @@ -1249,7 +1277,7 @@ sim knowledge connectors delete [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Knowledge base that owns the connector. | +| `knowledgeBaseId` | Yes | Knowledge base that owns the connector. | | `connectorId` | Yes | Connector selected for the operation. | @@ -1271,7 +1299,7 @@ sim knowledge connectors delete [options] Get Knowledge Connector ```bash -sim knowledge connectors get +sim knowledge connectors get ``` **Arguments** @@ -1280,7 +1308,7 @@ sim knowledge connectors get | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Knowledge base that owns the connector. | +| `knowledgeBaseId` | Yes | Knowledge base that owns the connector. | | `connectorId` | Yes | Connector selected for the operation. | @@ -1290,7 +1318,7 @@ sim knowledge connectors get List Knowledge Connector Documents ```bash -sim knowledge connectors documents list [options] +sim knowledge connectors documents list [options] ``` **Arguments** @@ -1299,7 +1327,7 @@ sim knowledge connectors documents list [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Knowledge base that owns the connector. | +| `knowledgeBaseId` | Yes | Knowledge base that owns the connector. | | `connectorId` | Yes | Connector selected for the operation. | @@ -1316,12 +1344,42 @@ sim knowledge connectors documents list [options] +### sim knowledge connectors documents update + +Update Knowledge Connector Documents + +```bash +sim knowledge connectors documents update [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `knowledgeBaseId` | Yes | Knowledge base that owns the connector. | +| `connectorId` | Yes | Connector selected for the operation. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--operation ` | Yes | Whether to restore or exclude the selected documents. Accepted values: `restore`, `exclude`. | +| `--document ` | Yes | Connector document identifiers to update. (space-separated, or @path / @- with one value per line). | + + + ### sim knowledge connectors list List Knowledge Connectors ```bash -sim knowledge connectors list [options] +sim knowledge connectors list [options] ``` **Arguments** @@ -1330,7 +1388,7 @@ sim knowledge connectors list [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Unique knowledge base identifier. | +| `knowledgeBaseId` | Yes | Unique knowledge base identifier. | @@ -1346,12 +1404,12 @@ sim knowledge connectors list [options] -### sim knowledge connectors update +### sim knowledge connectors sync -Update Knowledge Connector +Queue a knowledge connector synchronization ```bash -sim knowledge connectors update [options] +sim knowledge connectors sync [options] ``` **Arguments** @@ -1360,7 +1418,7 @@ sim knowledge connectors update [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Knowledge base that owns the connector. | +| `knowledgeBaseId` | Yes | Knowledge base that owns the connector. | | `connectorId` | Yes | Connector selected for the operation. | @@ -1371,18 +1429,17 @@ sim knowledge connectors update [options] | Option | Required | Description | | --- | --- | --- | -| `--source-config ` | No | Replacement source selection and filtering configuration. (JSON, or @path / @- to read a file or stdin). | -| `--sync-interval-minutes ` | No | New scheduled synchronization interval in minutes. | -| `--status ` | No | New connector state. Accepted values: `active`, `paused`. | +| `--rehydrate` | No | Re-fetch and re-index every existing connector document. | +| `--no-rehydrate` | No | Send --rehydrate as false. | -### sim knowledge connectors documents update +### sim knowledge connectors update -Update Knowledge Connector Documents +Update Knowledge Connector ```bash -sim knowledge connectors documents update [options] +sim knowledge connectors update [options] ``` **Arguments** @@ -1391,7 +1448,7 @@ sim knowledge connectors documents update [options] | Argument | Required | Description | | --- | --- | --- | -| `id` | Yes | Knowledge base that owns the connector. | +| `knowledgeBaseId` | Yes | Knowledge base that owns the connector. | | `connectorId` | Yes | Connector selected for the operation. | @@ -1402,8 +1459,9 @@ sim knowledge connectors documents update [options] | Option | Required | Description | | --- | --- | --- | -| `--operation ` | Yes | Whether to restore or exclude the selected documents. Accepted values: `restore`, `exclude`. | -| `--document ` | Yes | Connector document identifiers to update. (space-separated, or @path / @- with one value per line). | +| `--source-config ` | No | Replacement source selection and filtering configuration. Updating a runnable connector queues synchronization; paused connectors remain paused. (JSON, or @path / @- to read a file or stdin). | +| `--sync-interval-minutes ` | No | New scheduled synchronization interval in minutes. | +| `--status ` | No | New connector state. Accepted values: `active`, `paused`. | @@ -1610,36 +1668,6 @@ sim knowledge search [options] -### sim knowledge sync create - -Sync Knowledge Connector - -```bash -sim knowledge sync create [options] -``` - -**Arguments** - - - -| Argument | Required | Description | -| --- | --- | --- | -| `id` | Yes | Knowledge base that owns the connector. | -| `connectorId` | Yes | Connector selected for the operation. | - - - -**Options** - - - -| Option | Required | Description | -| --- | --- | --- | -| `--rehydrate` | No | Re-fetch and re-index every existing connector document. | -| `--no-rehydrate` | No | Send --rehydrate as false. | - - - ### sim knowledge update Update Knowledge Base diff --git a/apps/docs/content/docs/en/integrations/bitbucket.mdx b/apps/docs/content/docs/en/integrations/bitbucket.mdx index 8d7529ce0c8..eba02b0e083 100644 --- a/apps/docs/content/docs/en/integrations/bitbucket.mdx +++ b/apps/docs/content/docs/en/integrations/bitbucket.mdx @@ -12,7 +12,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" ## Usage Instructions -Connect Bitbucket Cloud to inspect repositories and source, collaborate on pull requests, and diagnose or control pipelines. This action integration uses OAuth and does not create webhooks or triggers. +Connect Bitbucket Cloud to inspect repositories and source, collaborate on pull requests, diagnose or control pipelines, and start workflows from repository and pull request events. OAuth is used for actions and automatic webhook management. @@ -1546,3 +1546,672 @@ Read a bounded UTF-8 tail of a pipeline step log | `totalBytes` | number | Full log byte size when reported | + +## Triggers + +A **Trigger** is a block that starts a workflow when an event happens in this service. + +### Bitbucket Build Status Created + +Trigger workflow when a build status is created for a Bitbucket commit + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | The Bitbucket account used to create and remove the repository webhook. Repository administrator access is required. | +| `workspacePicker` | project-selector | Yes | The workspace containing the repository to monitor. | +| `workspaceSlugInput` | string | Yes | Enter the workspace slug directly instead of selecting a workspace. | +| `repositoryPicker` | project-selector | Yes | The repository where Sim creates the webhook. | +| `repositorySlugInput` | string | Yes | Enter the repository slug directly instead of selecting a repository. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Bitbucket event key from X-Event-Key | +| `hookUuid` | string | UUID of the Bitbucket repository webhook | +| `requestUuid` | string | Bitbucket delivery request UUID | +| `attemptNumber` | number | Bitbucket delivery attempt number | +| `actor` | json | User or app that caused the event | +| `repository` | json | Repository where the event occurred | +| `payload` | json | Full parsed Bitbucket webhook payload | +| `commitStatus` | json | Bitbucket commit status object | +| `commitHash` | string | Hash of the commit with this status | +| `statusKey` | string | Key identifying the build status | +| `statusState` | string | Current build status state | +| `statusName` | string | Display name of the build status | +| `statusUrl` | string | URL associated with the build status | + + +--- + +### Bitbucket Build Status Updated + +Trigger workflow when a build status is updated for a Bitbucket commit + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | The Bitbucket account used to create and remove the repository webhook. Repository administrator access is required. | +| `workspacePicker` | project-selector | Yes | The workspace containing the repository to monitor. | +| `workspaceSlugInput` | string | Yes | Enter the workspace slug directly instead of selecting a workspace. | +| `repositoryPicker` | project-selector | Yes | The repository where Sim creates the webhook. | +| `repositorySlugInput` | string | Yes | Enter the repository slug directly instead of selecting a repository. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Bitbucket event key from X-Event-Key | +| `hookUuid` | string | UUID of the Bitbucket repository webhook | +| `requestUuid` | string | Bitbucket delivery request UUID | +| `attemptNumber` | number | Bitbucket delivery attempt number | +| `actor` | json | User or app that caused the event | +| `repository` | json | Repository where the event occurred | +| `payload` | json | Full parsed Bitbucket webhook payload | +| `commitStatus` | json | Bitbucket commit status object | +| `commitHash` | string | Hash of the commit with this status | +| `statusKey` | string | Key identifying the build status | +| `statusState` | string | Current build status state | +| `statusName` | string | Display name of the build status | +| `statusUrl` | string | URL associated with the build status | + + +--- + +### Bitbucket Commit Comment Created + +Trigger workflow when a comment is created on a Bitbucket commit + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | The Bitbucket account used to create and remove the repository webhook. Repository administrator access is required. | +| `workspacePicker` | project-selector | Yes | The workspace containing the repository to monitor. | +| `workspaceSlugInput` | string | Yes | Enter the workspace slug directly instead of selecting a workspace. | +| `repositoryPicker` | project-selector | Yes | The repository where Sim creates the webhook. | +| `repositorySlugInput` | string | Yes | Enter the repository slug directly instead of selecting a repository. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Bitbucket event key from X-Event-Key | +| `hookUuid` | string | UUID of the Bitbucket repository webhook | +| `requestUuid` | string | Bitbucket delivery request UUID | +| `attemptNumber` | number | Bitbucket delivery attempt number | +| `actor` | json | User or app that caused the event | +| `repository` | json | Repository where the event occurred | +| `payload` | json | Full parsed Bitbucket webhook payload | +| `comment` | json | Bitbucket comment object | +| `commentId` | number | Repository-scoped comment ID | +| `commentContent` | string | Raw text content of the comment | +| `commit` | json | Commit the comment was created on | + + +--- + +### Bitbucket Pull Request Approval Removed + +Trigger workflow when approval is removed from a Bitbucket pull request + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | The Bitbucket account used to create and remove the repository webhook. Repository administrator access is required. | +| `workspacePicker` | project-selector | Yes | The workspace containing the repository to monitor. | +| `workspaceSlugInput` | string | Yes | Enter the workspace slug directly instead of selecting a workspace. | +| `repositoryPicker` | project-selector | Yes | The repository where Sim creates the webhook. | +| `repositorySlugInput` | string | Yes | Enter the repository slug directly instead of selecting a repository. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Bitbucket event key from X-Event-Key | +| `hookUuid` | string | UUID of the Bitbucket repository webhook | +| `requestUuid` | string | Bitbucket delivery request UUID | +| `attemptNumber` | number | Bitbucket delivery attempt number | +| `actor` | json | User or app that caused the event | +| `repository` | json | Repository where the event occurred | +| `payload` | json | Full parsed Bitbucket webhook payload | +| `pullRequest` | json | Bitbucket pull request object | +| `pullRequestId` | number | Repository-scoped pull request ID | +| `pullRequestTitle` | string | Pull request title | +| `pullRequestState` | string | Pull request state | +| `sourceBranch` | string | Pull request source branch | +| `destinationBranch` | string | Pull request destination branch | +| `approval` | json | Pull request approval details | + + +--- + +### Bitbucket Pull Request Approved + +Trigger workflow when a Bitbucket pull request is approved + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | The Bitbucket account used to create and remove the repository webhook. Repository administrator access is required. | +| `workspacePicker` | project-selector | Yes | The workspace containing the repository to monitor. | +| `workspaceSlugInput` | string | Yes | Enter the workspace slug directly instead of selecting a workspace. | +| `repositoryPicker` | project-selector | Yes | The repository where Sim creates the webhook. | +| `repositorySlugInput` | string | Yes | Enter the repository slug directly instead of selecting a repository. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Bitbucket event key from X-Event-Key | +| `hookUuid` | string | UUID of the Bitbucket repository webhook | +| `requestUuid` | string | Bitbucket delivery request UUID | +| `attemptNumber` | number | Bitbucket delivery attempt number | +| `actor` | json | User or app that caused the event | +| `repository` | json | Repository where the event occurred | +| `payload` | json | Full parsed Bitbucket webhook payload | +| `pullRequest` | json | Bitbucket pull request object | +| `pullRequestId` | number | Repository-scoped pull request ID | +| `pullRequestTitle` | string | Pull request title | +| `pullRequestState` | string | Pull request state | +| `sourceBranch` | string | Pull request source branch | +| `destinationBranch` | string | Pull request destination branch | +| `approval` | json | Pull request approval details | + + +--- + +### Bitbucket Pull Request Changes Request Removed + +Trigger workflow when a changes request is removed from a Bitbucket pull request + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | The Bitbucket account used to create and remove the repository webhook. Repository administrator access is required. | +| `workspacePicker` | project-selector | Yes | The workspace containing the repository to monitor. | +| `workspaceSlugInput` | string | Yes | Enter the workspace slug directly instead of selecting a workspace. | +| `repositoryPicker` | project-selector | Yes | The repository where Sim creates the webhook. | +| `repositorySlugInput` | string | Yes | Enter the repository slug directly instead of selecting a repository. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Bitbucket event key from X-Event-Key | +| `hookUuid` | string | UUID of the Bitbucket repository webhook | +| `requestUuid` | string | Bitbucket delivery request UUID | +| `attemptNumber` | number | Bitbucket delivery attempt number | +| `actor` | json | User or app that caused the event | +| `repository` | json | Repository where the event occurred | +| `payload` | json | Full parsed Bitbucket webhook payload | +| `pullRequest` | json | Bitbucket pull request object | +| `pullRequestId` | number | Repository-scoped pull request ID | +| `pullRequestTitle` | string | Pull request title | +| `pullRequestState` | string | Pull request state | +| `sourceBranch` | string | Pull request source branch | +| `destinationBranch` | string | Pull request destination branch | +| `changesRequest` | json | Pull request changes-request details | + + +--- + +### Bitbucket Pull Request Changes Requested + +Trigger workflow when changes are requested on a Bitbucket pull request + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | The Bitbucket account used to create and remove the repository webhook. Repository administrator access is required. | +| `workspacePicker` | project-selector | Yes | The workspace containing the repository to monitor. | +| `workspaceSlugInput` | string | Yes | Enter the workspace slug directly instead of selecting a workspace. | +| `repositoryPicker` | project-selector | Yes | The repository where Sim creates the webhook. | +| `repositorySlugInput` | string | Yes | Enter the repository slug directly instead of selecting a repository. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Bitbucket event key from X-Event-Key | +| `hookUuid` | string | UUID of the Bitbucket repository webhook | +| `requestUuid` | string | Bitbucket delivery request UUID | +| `attemptNumber` | number | Bitbucket delivery attempt number | +| `actor` | json | User or app that caused the event | +| `repository` | json | Repository where the event occurred | +| `payload` | json | Full parsed Bitbucket webhook payload | +| `pullRequest` | json | Bitbucket pull request object | +| `pullRequestId` | number | Repository-scoped pull request ID | +| `pullRequestTitle` | string | Pull request title | +| `pullRequestState` | string | Pull request state | +| `sourceBranch` | string | Pull request source branch | +| `destinationBranch` | string | Pull request destination branch | +| `changesRequest` | json | Pull request changes-request details | + + +--- + +### Bitbucket Pull Request Comment Created + +Trigger workflow when a comment is created on a Bitbucket pull request + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | The Bitbucket account used to create and remove the repository webhook. Repository administrator access is required. | +| `workspacePicker` | project-selector | Yes | The workspace containing the repository to monitor. | +| `workspaceSlugInput` | string | Yes | Enter the workspace slug directly instead of selecting a workspace. | +| `repositoryPicker` | project-selector | Yes | The repository where Sim creates the webhook. | +| `repositorySlugInput` | string | Yes | Enter the repository slug directly instead of selecting a repository. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Bitbucket event key from X-Event-Key | +| `hookUuid` | string | UUID of the Bitbucket repository webhook | +| `requestUuid` | string | Bitbucket delivery request UUID | +| `attemptNumber` | number | Bitbucket delivery attempt number | +| `actor` | json | User or app that caused the event | +| `repository` | json | Repository where the event occurred | +| `payload` | json | Full parsed Bitbucket webhook payload | +| `pullRequest` | json | Bitbucket pull request object | +| `pullRequestId` | number | Repository-scoped pull request ID | +| `pullRequestTitle` | string | Pull request title | +| `pullRequestState` | string | Pull request state | +| `sourceBranch` | string | Pull request source branch | +| `destinationBranch` | string | Pull request destination branch | +| `comment` | json | Bitbucket comment object | +| `commentId` | number | Repository-scoped comment ID | +| `commentContent` | string | Raw text content of the comment | + + +--- + +### Bitbucket Pull Request Comment Deleted + +Trigger workflow when a comment is deleted from a Bitbucket pull request + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | The Bitbucket account used to create and remove the repository webhook. Repository administrator access is required. | +| `workspacePicker` | project-selector | Yes | The workspace containing the repository to monitor. | +| `workspaceSlugInput` | string | Yes | Enter the workspace slug directly instead of selecting a workspace. | +| `repositoryPicker` | project-selector | Yes | The repository where Sim creates the webhook. | +| `repositorySlugInput` | string | Yes | Enter the repository slug directly instead of selecting a repository. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Bitbucket event key from X-Event-Key | +| `hookUuid` | string | UUID of the Bitbucket repository webhook | +| `requestUuid` | string | Bitbucket delivery request UUID | +| `attemptNumber` | number | Bitbucket delivery attempt number | +| `actor` | json | User or app that caused the event | +| `repository` | json | Repository where the event occurred | +| `payload` | json | Full parsed Bitbucket webhook payload | +| `pullRequest` | json | Bitbucket pull request object | +| `pullRequestId` | number | Repository-scoped pull request ID | +| `pullRequestTitle` | string | Pull request title | +| `pullRequestState` | string | Pull request state | +| `sourceBranch` | string | Pull request source branch | +| `destinationBranch` | string | Pull request destination branch | +| `comment` | json | Bitbucket comment object | +| `commentId` | number | Repository-scoped comment ID | +| `commentContent` | string | Raw text content of the comment | + + +--- + +### Bitbucket Pull Request Comment Reopened + +Trigger workflow when a resolved comment is reopened on a Bitbucket pull request + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | The Bitbucket account used to create and remove the repository webhook. Repository administrator access is required. | +| `workspacePicker` | project-selector | Yes | The workspace containing the repository to monitor. | +| `workspaceSlugInput` | string | Yes | Enter the workspace slug directly instead of selecting a workspace. | +| `repositoryPicker` | project-selector | Yes | The repository where Sim creates the webhook. | +| `repositorySlugInput` | string | Yes | Enter the repository slug directly instead of selecting a repository. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Bitbucket event key from X-Event-Key | +| `hookUuid` | string | UUID of the Bitbucket repository webhook | +| `requestUuid` | string | Bitbucket delivery request UUID | +| `attemptNumber` | number | Bitbucket delivery attempt number | +| `actor` | json | User or app that caused the event | +| `repository` | json | Repository where the event occurred | +| `payload` | json | Full parsed Bitbucket webhook payload | +| `pullRequest` | json | Bitbucket pull request object | +| `pullRequestId` | number | Repository-scoped pull request ID | +| `pullRequestTitle` | string | Pull request title | +| `pullRequestState` | string | Pull request state | +| `sourceBranch` | string | Pull request source branch | +| `destinationBranch` | string | Pull request destination branch | +| `comment` | json | Bitbucket comment object | +| `commentId` | number | Repository-scoped comment ID | +| `commentContent` | string | Raw text content of the comment | + + +--- + +### Bitbucket Pull Request Comment Resolved + +Trigger workflow when a comment is resolved on a Bitbucket pull request + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | The Bitbucket account used to create and remove the repository webhook. Repository administrator access is required. | +| `workspacePicker` | project-selector | Yes | The workspace containing the repository to monitor. | +| `workspaceSlugInput` | string | Yes | Enter the workspace slug directly instead of selecting a workspace. | +| `repositoryPicker` | project-selector | Yes | The repository where Sim creates the webhook. | +| `repositorySlugInput` | string | Yes | Enter the repository slug directly instead of selecting a repository. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Bitbucket event key from X-Event-Key | +| `hookUuid` | string | UUID of the Bitbucket repository webhook | +| `requestUuid` | string | Bitbucket delivery request UUID | +| `attemptNumber` | number | Bitbucket delivery attempt number | +| `actor` | json | User or app that caused the event | +| `repository` | json | Repository where the event occurred | +| `payload` | json | Full parsed Bitbucket webhook payload | +| `pullRequest` | json | Bitbucket pull request object | +| `pullRequestId` | number | Repository-scoped pull request ID | +| `pullRequestTitle` | string | Pull request title | +| `pullRequestState` | string | Pull request state | +| `sourceBranch` | string | Pull request source branch | +| `destinationBranch` | string | Pull request destination branch | +| `comment` | json | Bitbucket comment object | +| `commentId` | number | Repository-scoped comment ID | +| `commentContent` | string | Raw text content of the comment | + + +--- + +### Bitbucket Pull Request Comment Updated + +Trigger workflow when a comment is updated on a Bitbucket pull request + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | The Bitbucket account used to create and remove the repository webhook. Repository administrator access is required. | +| `workspacePicker` | project-selector | Yes | The workspace containing the repository to monitor. | +| `workspaceSlugInput` | string | Yes | Enter the workspace slug directly instead of selecting a workspace. | +| `repositoryPicker` | project-selector | Yes | The repository where Sim creates the webhook. | +| `repositorySlugInput` | string | Yes | Enter the repository slug directly instead of selecting a repository. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Bitbucket event key from X-Event-Key | +| `hookUuid` | string | UUID of the Bitbucket repository webhook | +| `requestUuid` | string | Bitbucket delivery request UUID | +| `attemptNumber` | number | Bitbucket delivery attempt number | +| `actor` | json | User or app that caused the event | +| `repository` | json | Repository where the event occurred | +| `payload` | json | Full parsed Bitbucket webhook payload | +| `pullRequest` | json | Bitbucket pull request object | +| `pullRequestId` | number | Repository-scoped pull request ID | +| `pullRequestTitle` | string | Pull request title | +| `pullRequestState` | string | Pull request state | +| `sourceBranch` | string | Pull request source branch | +| `destinationBranch` | string | Pull request destination branch | +| `comment` | json | Bitbucket comment object | +| `commentId` | number | Repository-scoped comment ID | +| `commentContent` | string | Raw text content of the comment | + + +--- + +### Bitbucket Pull Request Created + +Trigger workflow when a Bitbucket pull request is created + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | The Bitbucket account used to create and remove the repository webhook. Repository administrator access is required. | +| `workspacePicker` | project-selector | Yes | The workspace containing the repository to monitor. | +| `workspaceSlugInput` | string | Yes | Enter the workspace slug directly instead of selecting a workspace. | +| `repositoryPicker` | project-selector | Yes | The repository where Sim creates the webhook. | +| `repositorySlugInput` | string | Yes | Enter the repository slug directly instead of selecting a repository. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Bitbucket event key from X-Event-Key | +| `hookUuid` | string | UUID of the Bitbucket repository webhook | +| `requestUuid` | string | Bitbucket delivery request UUID | +| `attemptNumber` | number | Bitbucket delivery attempt number | +| `actor` | json | User or app that caused the event | +| `repository` | json | Repository where the event occurred | +| `payload` | json | Full parsed Bitbucket webhook payload | +| `pullRequest` | json | Bitbucket pull request object | +| `pullRequestId` | number | Repository-scoped pull request ID | +| `pullRequestTitle` | string | Pull request title | +| `pullRequestState` | string | Pull request state | +| `sourceBranch` | string | Pull request source branch | +| `destinationBranch` | string | Pull request destination branch | + + +--- + +### Bitbucket Pull Request Declined + +Trigger workflow when a Bitbucket pull request is declined + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | The Bitbucket account used to create and remove the repository webhook. Repository administrator access is required. | +| `workspacePicker` | project-selector | Yes | The workspace containing the repository to monitor. | +| `workspaceSlugInput` | string | Yes | Enter the workspace slug directly instead of selecting a workspace. | +| `repositoryPicker` | project-selector | Yes | The repository where Sim creates the webhook. | +| `repositorySlugInput` | string | Yes | Enter the repository slug directly instead of selecting a repository. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Bitbucket event key from X-Event-Key | +| `hookUuid` | string | UUID of the Bitbucket repository webhook | +| `requestUuid` | string | Bitbucket delivery request UUID | +| `attemptNumber` | number | Bitbucket delivery attempt number | +| `actor` | json | User or app that caused the event | +| `repository` | json | Repository where the event occurred | +| `payload` | json | Full parsed Bitbucket webhook payload | +| `pullRequest` | json | Bitbucket pull request object | +| `pullRequestId` | number | Repository-scoped pull request ID | +| `pullRequestTitle` | string | Pull request title | +| `pullRequestState` | string | Pull request state | +| `sourceBranch` | string | Pull request source branch | +| `destinationBranch` | string | Pull request destination branch | + + +--- + +### Bitbucket Pull Request Merged + +Trigger workflow when a Bitbucket pull request is merged + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | The Bitbucket account used to create and remove the repository webhook. Repository administrator access is required. | +| `workspacePicker` | project-selector | Yes | The workspace containing the repository to monitor. | +| `workspaceSlugInput` | string | Yes | Enter the workspace slug directly instead of selecting a workspace. | +| `repositoryPicker` | project-selector | Yes | The repository where Sim creates the webhook. | +| `repositorySlugInput` | string | Yes | Enter the repository slug directly instead of selecting a repository. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Bitbucket event key from X-Event-Key | +| `hookUuid` | string | UUID of the Bitbucket repository webhook | +| `requestUuid` | string | Bitbucket delivery request UUID | +| `attemptNumber` | number | Bitbucket delivery attempt number | +| `actor` | json | User or app that caused the event | +| `repository` | json | Repository where the event occurred | +| `payload` | json | Full parsed Bitbucket webhook payload | +| `pullRequest` | json | Bitbucket pull request object | +| `pullRequestId` | number | Repository-scoped pull request ID | +| `pullRequestTitle` | string | Pull request title | +| `pullRequestState` | string | Pull request state | +| `sourceBranch` | string | Pull request source branch | +| `destinationBranch` | string | Pull request destination branch | + + +--- + +### Bitbucket Pull Request Updated + +Trigger workflow when a Bitbucket pull request is updated + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | The Bitbucket account used to create and remove the repository webhook. Repository administrator access is required. | +| `workspacePicker` | project-selector | Yes | The workspace containing the repository to monitor. | +| `workspaceSlugInput` | string | Yes | Enter the workspace slug directly instead of selecting a workspace. | +| `repositoryPicker` | project-selector | Yes | The repository where Sim creates the webhook. | +| `repositorySlugInput` | string | Yes | Enter the repository slug directly instead of selecting a repository. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Bitbucket event key from X-Event-Key | +| `hookUuid` | string | UUID of the Bitbucket repository webhook | +| `requestUuid` | string | Bitbucket delivery request UUID | +| `attemptNumber` | number | Bitbucket delivery attempt number | +| `actor` | json | User or app that caused the event | +| `repository` | json | Repository where the event occurred | +| `payload` | json | Full parsed Bitbucket webhook payload | +| `pullRequest` | json | Bitbucket pull request object | +| `pullRequestId` | number | Repository-scoped pull request ID | +| `pullRequestTitle` | string | Pull request title | +| `pullRequestState` | string | Pull request state | +| `sourceBranch` | string | Pull request source branch | +| `destinationBranch` | string | Pull request destination branch | + + +--- + +### Bitbucket Push + +Trigger workflow when commits are pushed to a Bitbucket repository + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | The Bitbucket account used to create and remove the repository webhook. Repository administrator access is required. | +| `workspacePicker` | project-selector | Yes | The workspace containing the repository to monitor. | +| `workspaceSlugInput` | string | Yes | Enter the workspace slug directly instead of selecting a workspace. | +| `repositoryPicker` | project-selector | Yes | The repository where Sim creates the webhook. | +| `repositorySlugInput` | string | Yes | Enter the repository slug directly instead of selecting a repository. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Bitbucket event key from X-Event-Key | +| `hookUuid` | string | UUID of the Bitbucket repository webhook | +| `requestUuid` | string | Bitbucket delivery request UUID | +| `attemptNumber` | number | Bitbucket delivery attempt number | +| `actor` | json | User or app that caused the event | +| `repository` | json | Repository where the event occurred | +| `payload` | json | Full parsed Bitbucket webhook payload | +| `push` | json | Push details, including changes and commits | + + +--- + +### Bitbucket Repository Forked + +Trigger workflow when a Bitbucket repository is forked + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | The Bitbucket account used to create and remove the repository webhook. Repository administrator access is required. | +| `workspacePicker` | project-selector | Yes | The workspace containing the repository to monitor. | +| `workspaceSlugInput` | string | Yes | Enter the workspace slug directly instead of selecting a workspace. | +| `repositoryPicker` | project-selector | Yes | The repository where Sim creates the webhook. | +| `repositorySlugInput` | string | Yes | Enter the repository slug directly instead of selecting a repository. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Bitbucket event key from X-Event-Key | +| `hookUuid` | string | UUID of the Bitbucket repository webhook | +| `requestUuid` | string | Bitbucket delivery request UUID | +| `attemptNumber` | number | Bitbucket delivery attempt number | +| `actor` | json | User or app that caused the event | +| `repository` | json | Repository where the event occurred | +| `payload` | json | Full parsed Bitbucket webhook payload | +| `fork` | json | Newly created repository fork | + + +--- + +### Bitbucket Repository Updated + +Trigger workflow when a Bitbucket repository is updated + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | The Bitbucket account used to create and remove the repository webhook. Repository administrator access is required. | +| `workspacePicker` | project-selector | Yes | The workspace containing the repository to monitor. | +| `workspaceSlugInput` | string | Yes | Enter the workspace slug directly instead of selecting a workspace. | +| `repositoryPicker` | project-selector | Yes | The repository where Sim creates the webhook. | +| `repositorySlugInput` | string | Yes | Enter the repository slug directly instead of selecting a repository. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Bitbucket event key from X-Event-Key | +| `hookUuid` | string | UUID of the Bitbucket repository webhook | +| `requestUuid` | string | Bitbucket delivery request UUID | +| `attemptNumber` | number | Bitbucket delivery attempt number | +| `actor` | json | User or app that caused the event | +| `repository` | json | Repository where the event occurred | +| `payload` | json | Full parsed Bitbucket webhook payload | +| `changes` | json | Repository fields changed by the update | + diff --git a/apps/docs/content/docs/en/platform/credentials.mdx b/apps/docs/content/docs/en/platform/credentials.mdx index fbc34584af2..2584a353ac1 100644 --- a/apps/docs/content/docs/en/platform/credentials.mdx +++ b/apps/docs/content/docs/en/platform/credentials.mdx @@ -91,7 +91,9 @@ Both masking and model-bound projection match only exact values in either case. ### Copilot code execution -Copilot's Function and code-execution tools receive a saved secret only when their code explicitly contains a valid `{{KEY}}` reference. Direct `environmentVariables.KEY` access, shell `$KEY`, dynamic names, literals, and configured-but-unused secrets do not mount a value. Code execution requires workspace write access, and the caller must also be allowed to view the raw value: your own Personal secrets, any secret for which you are a Credential Admin, and Workspace secrets when you are a workspace admin. Credential Members can continue using shared secrets through normal workflow and tool resolution, but cannot mount their plaintext into arbitrary Copilot code. +Copilot's Function and code-execution tools receive a saved secret only when their code explicitly contains a valid `{{KEY}}` reference. Direct `environmentVariables.KEY` access, shell `$KEY`, dynamic names, literals, and configured-but-unused secrets do not mount a value. Code execution requires workspace write access, and the caller must be allowed to **use** the secret — the same set a workflow resolves for them: your own Personal secrets, and Workspace secrets you hold an active grant on as a Credential Member or Credential Admin, which a workspace admin holds on every key. A secret you hold no grant on does not mount, and neither does one whose grant is revoked or still pending. + +This matches what a workflow Function block already resolves for the same person, deliberately. Being able to run a secret is not the same as being able to read it: the value stays masked under **Settings → Secrets**, and **See usage** stays visible only to that secret's admins, so a Credential Member using a secret in code is recorded for whoever can rotate it. Headless surfaces use their saved **Secret access** setting: @@ -99,7 +101,7 @@ Headless surfaces use their saved **Secret access** setting: - **Scheduled Tasks** — in the task modal - **Inbox** — under **Settings → Inbox → Secrets** -Choose **All secrets** or **Selected secrets**. Existing configurations default to **All secrets** for compatibility. **All secrets** still means only secrets explicitly referenced with `{{KEY}}` that the execution actor may view; it never injects the full environment. Inbox messages from allowed external senders do not receive raw-secret access. +Choose **All secrets** or **Selected secrets**. Existing configurations default to **All secrets** for compatibility. **All secrets** still means only secrets explicitly referenced with `{{KEY}}` that the execution actor may use; it never injects the full environment. Inbox messages from allowed external senders do not receive raw-secret access at all — an inbound message that Sim cannot match to a workspace member runs with no secret actor, so no `{{KEY}}` resolves for it. Code receives the real authorized value at runtime. Before any Copilot-visible tool result is returned, exact occurrences of activated secret values are replaced with `{{KEY}}`; local side effects and runtime results are not rewritten. Encoded, hashed, URL-encoded, otherwise transformed, or network-exfiltrated values cannot be inferred and masked reliably, so code should not deliberately return, transform, print, or transmit secrets to unintended destinations. @@ -139,10 +141,11 @@ Usage is recorded independently of execution logs, so it outlives them: logs exp | | Workspace | Personal | |---|---|---| -| **Visibility** | All workspace members, including external workspace members | Only you | -| **Use in workflows** | Any member can use | Only you can use | +| **Who sees the name** | All workspace members, including external workspace members | Only you | +| **Who sees the value** | Workspace admins and that secret's Credential Admins | Only you | +| **Use in workflows and code** | Any member can use | Only you can use | | **Best for** | Production workflows, shared services | Testing, personal API keys | -| **Who can edit** | Workspace admins | Only you | +| **Who can edit** | Workspace admins and that secret's Credential Admins | Only you | When a workspace secret and a personal secret share the same key name, the **workspace secret takes precedence**. diff --git a/apps/docs/content/docs/en/platform/enterprise/custom-blocks.mdx b/apps/docs/content/docs/en/platform/enterprise/custom-blocks.mdx index 57fe1bc6ce4..a528416612d 100644 --- a/apps/docs/content/docs/en/platform/enterprise/custom-blocks.mdx +++ b/apps/docs/content/docs/en/platform/enterprise/custom-blocks.mdx @@ -74,7 +74,15 @@ Pick which of the workflow's outputs consumers can use, and give each one a name Create block form filled in: Workspace and Workflow selectors, an uploaded icon, Name and Description fields, an expanded input with a placeholder, and two selected outputs each given a name -### 6. Save +### 6. Choose whether runs are traced + +**Trace runs in consumer logs** is off by default. Leave it off and your block stays a single step in every workflow that uses it: nothing about the run is recorded anywhere a consumer can reach. + +Turn it on and the block's steps appear inside the trace of every workflow that runs it, org-wide. That means anyone who can read those workflows' logs sees your workflow's block names, inputs, outputs, and prompts — including people with no access to this workspace. It is the same information curated outputs and redacted errors otherwise keep on your side of the block, so turn it on when you want consumers to be able to debug your block themselves, and leave it off otherwise. + +You can change this at any time; it applies to runs from that point on. Failures always return a reference id either way, so you can find a run in your own logs even with tracing off. + +### 7. Save Click **Save changes**. The block is published immediately and becomes available to everyone in your organization in the workflow editor's block toolbar. @@ -86,7 +94,7 @@ In the workflow editor, open the block toolbar. Published custom blocks appear u Workflow editor block toolbar with a Custom Blocks section listing two published blocks below Core Blocks -Consumers don't need any access to the source workflow. The block runs on its own, using only the inputs provided, and returns only the outputs you exposed. Internal steps, models, and intermediate values of the source workflow are never visible. +Consumers don't need any access to the source workflow. The block runs on its own, using only the inputs provided, and returns only the outputs you exposed. Its internal steps, models, and intermediate values stay hidden unless the block's publisher turned on **Trace runs in consumer logs**, in which case they appear under the block in the run's trace. A custom block connected to a Start block on the workflow canvas, with its query input filled in and the run output showing the returned fields @@ -96,7 +104,7 @@ Consumers don't need any access to the source workflow. The block runs on its ow Open a block from **Settings → Enterprise → Custom blocks** to edit or delete it. -- **Editing** changes only the block's presentation and interface — name, description, icon, input placeholders, and exposed outputs. The source workflow can't be re-pointed. +- **Editing** changes only the block's presentation, interface, and trace policy — name, description, icon, input placeholders, exposed outputs, and whether runs are traced in consumer logs. The source workflow can't be re-pointed. - **Changing what the block does** is done by editing and **redeploying the source workflow**. The block picks up the new deployment automatically; there's nothing to republish. - **Deleting** a block is permanent. Workflows already using it will have that block removed, so replace it before deleting if it's in active use. diff --git a/apps/docs/content/docs/en/platform/self-hosting/integrations-oauth.mdx b/apps/docs/content/docs/en/platform/self-hosting/integrations-oauth.mdx index 3bf2b8a769b..761e59b2e7a 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/integrations-oauth.mdx +++ b/apps/docs/content/docs/en/platform/self-hosting/integrations-oauth.mdx @@ -150,9 +150,10 @@ The same variables also power "Sign in with Microsoft". For Bitbucket, create an OAuth consumer and register `https:///api/auth/oauth2/callback/bitbucket` as its callback URL. Bitbucket fixes permissions on the consumer instead of narrowing them per authorization request. Enable exactly -Account read, Repositories read/write, Pull requests read/write, and Pipelines read/write -(`account`, `repository`, `repository:write`, `pullrequest`, `pullrequest:write`, `pipeline`, and -`pipeline:write`). Webhook permission is not required for the integration-only release. +Account read, Repositories read/write, Pull requests read/write, Pipelines read/write, and Webhooks +read/write (`account`, `repository`, `repository:write`, `pullrequest`, `pullrequest:write`, +`pipeline`, `pipeline:write`, and `webhook`). The webhook permission is required for automatic +trigger subscription management. ### Services with a different flow diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index b9a51bceab7..6c31e2db830 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -3518,7 +3518,7 @@ "password": { "description": "Password for a password-gated share. Kept when omitted; enabling `password` with neither a supplied nor a stored password is a 400.", "type": "string", - "minLength": 1, + "minLength": 15, "maxLength": 1024 }, "allowedEmails": { diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index 79634b38de6..2a3ee6dd098 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -766,7 +766,7 @@ "patch": { "operationId": "updateKnowledgeConnector", "summary": "Update Knowledge Connector", - "description": "Update connector source configuration, schedule, or active state. Authentication material cannot be changed through this operation. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Update connector source configuration, schedule, or active state. Replacing source configuration on a runnable connector queues an immediate synchronization; paused connectors retain the change without synchronizing until resumed. Source configuration cannot be replaced while synchronization is already in progress. Authentication material cannot be changed through this operation. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -3450,8 +3450,8 @@ }, "status": { "type": "string", - "enum": ["active", "paused", "syncing", "error", "disabled"], - "description": "Current connector state." + "enum": ["active", "paused", "pending", "syncing", "error", "disabled"], + "description": "Current connector state. `pending` means a sync is queued but not yet running." }, "lastSyncAt": { "anyOf": [ @@ -3840,8 +3840,8 @@ }, "status": { "type": "string", - "enum": ["active", "paused", "syncing", "error", "disabled"], - "description": "Current connector state." + "enum": ["active", "paused", "pending", "syncing", "error", "disabled"], + "description": "Current connector state. `pending` means a sync is queued but not yet running." }, "lastSyncAt": { "anyOf": [ @@ -3988,7 +3988,7 @@ "description": "Workspace that owns the knowledge base." }, "sourceConfig": { - "description": "Replacement source selection and filtering configuration.", + "description": "Replacement source selection and filtering configuration. Updating a runnable connector queues synchronization; paused connectors remain paused.", "type": "object", "propertyNames": { "type": "string" diff --git a/apps/realtime/package.json b/apps/realtime/package.json index 35e45fc29e6..78b86958d99 100644 --- a/apps/realtime/package.json +++ b/apps/realtime/package.json @@ -5,7 +5,7 @@ "license": "Apache-2.0", "type": "module", "engines": { - "bun": ">=1.2.13", + "bun": ">=1.3.14", "node": ">=20.0.0" }, "scripts": { diff --git a/apps/sim/.env.example b/apps/sim/.env.example index 54451772873..c7313cbaa07 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -17,6 +17,7 @@ BETTER_AUTH_URL=http://localhost:3000 # NextJS (Required) NEXT_PUBLIC_APP_URL=http://localhost:3000 +# NEXT_PUBLIC_STATUS_NOTICE_PREVIEW=true # Force the sidebar service-status notice into its critical preview state for testing # INTERNAL_API_BASE_URL=http://sim-app.default.svc.cluster.local:3000 # Optional: internal URL for server-side /api self-calls; defaults to NEXT_PUBLIC_APP_URL # TRUSTED_ORIGINS=https://www.example.com,https://app.example.com # Optional: comma-separated additional public origins to trust for auth (apex+www, alias domains). Merged into Better Auth trustedOrigins. # AUTH_TRUSTED_PROXIES=10.0.0.0/24,192.0.2.10 # Optional: reverse-proxy IPs/CIDRs in front of the app. Better Auth walks x-forwarded-for right to left, skips these hops, and uses the first untrusted address as the client IP (prevents forwarded-header spoofing). Use your proxies' actual addresses, not broad private ranges that also cover clients. diff --git a/apps/sim/app/_shell/providers/posthog-provider.tsx b/apps/sim/app/_shell/providers/posthog-provider.tsx index d7c0152476f..095646f29de 100644 --- a/apps/sim/app/_shell/providers/posthog-provider.tsx +++ b/apps/sim/app/_shell/providers/posthog-provider.tsx @@ -4,6 +4,8 @@ import { useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import type { PostHog } from 'posthog-js' import { getEnv, isTruthy, publicEnvMissingAtModuleInit } from '@/lib/core/config/env' +import { settlePostHogClient } from '@/lib/posthog/client' +import { dropUnactionableExceptions } from '@/lib/posthog/exception-filter' const logger = createLogger('PostHogProvider') @@ -18,7 +20,10 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) { const posthogEnabled = getEnv('NEXT_PUBLIC_POSTHOG_ENABLED') const posthogKey = getEnv('NEXT_PUBLIC_POSTHOG_KEY') - if (!isTruthy(posthogEnabled) || !posthogKey) return + if (!isTruthy(posthogEnabled) || !posthogKey) { + settlePostHogClient(null) + return + } Promise.all([import('posthog-js'), import('posthog-js/react')]) .then(([posthogModule, { PostHogProvider: PHProvider }]) => { @@ -52,6 +57,14 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) { 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: dropUnactionableExceptions, disable_session_recording: true, session_recording: { maskAllInputs: false, @@ -88,6 +101,12 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) { persistence: 'localStorage+cookie', }) } + /** + * Releases anything captured while the imports above were in flight. + * Must run after `init`, since `capture` is a silent no-op until then. + */ + settlePostHogClient(posthog) + if (publicEnvMissingAtModuleInit) { posthog.capture('runtime_env_missing_at_module_init') } @@ -95,6 +114,7 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) { setProvider(() => PHProvider) }) .catch((err) => { + settlePostHogClient(null) logger.error('Failed to load PostHog', { error: err }) }) }, []) diff --git a/apps/sim/app/api/audit-logs/export/route.ts b/apps/sim/app/api/audit-logs/export/route.ts index e6d4a562809..c4860a85092 100644 --- a/apps/sim/app/api/audit-logs/export/route.ts +++ b/apps/sim/app/api/audit-logs/export/route.ts @@ -10,8 +10,8 @@ import { queryAuditLogs, } from '@/lib/audit-logs/query' import { getSession } from '@/lib/auth' +import { formatCsvValue, toCsvRow } from '@/lib/core/utils/csv' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { formatCsvValue, toCsvRow } from '@/lib/table/export-format' import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format' diff --git a/apps/sim/app/api/auth/forget-password/route.test.ts b/apps/sim/app/api/auth/forget-password/route.test.ts index 44e40cda51c..ed7d96fa86d 100644 --- a/apps/sim/app/api/auth/forget-password/route.test.ts +++ b/apps/sim/app/api/auth/forget-password/route.test.ts @@ -3,7 +3,7 @@ * * @vitest-environment node */ -import { createMockRequest, resetEnvMock, setEnv } from '@sim/testing' +import { createMockRequest, requestUtilsMockFns, resetEnvMock, setEnv } from '@sim/testing' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { mockCheckRateLimitDirect } = vi.hoisted(() => ({ @@ -138,6 +138,16 @@ describe('Forget Password API Route', () => { expect(mockRequestPasswordReset).not.toHaveBeenCalled() }) + it('uses the recipient backstop when the client IP cannot be resolved', async () => { + requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce(null) + + const response = await POST(createMockRequest('POST', { email: 'test@example.com' })) + + expect(response.status).toBe(200) + expect(recipientKeys()).toHaveLength(1) + expect(mockRequestPasswordReset).toHaveBeenCalledOnce() + }) + it('should reject external redirectTo URL', async () => { const req = createMockRequest('POST', { email: 'test@example.com', diff --git a/apps/sim/app/api/auth/forget-password/route.ts b/apps/sim/app/api/auth/forget-password/route.ts index 43e2e2e0c60..4eaf161b37f 100644 --- a/apps/sim/app/api/auth/forget-password/route.ts +++ b/apps/sim/app/api/auth/forget-password/route.ts @@ -8,7 +8,7 @@ import { forgetPasswordContract } from '@/lib/api/contracts' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { auth } from '@/lib/auth' import { - enforceIpRateLimit, + enforceIpRateLimitWithIndependentBackstop, enforceRecipientRateLimit, type TokenBucketConfig, } from '@/lib/core/rate-limiter' @@ -27,7 +27,10 @@ const RESET_EMAIL_RATE_LIMIT: TokenBucketConfig = { export const POST = withRouteHandler(async (request: NextRequest) => { try { - const ipRateLimited = await enforceIpRateLimit('forget-password', request) + const ipRateLimited = await enforceIpRateLimitWithIndependentBackstop( + 'forget-password', + request + ) if (ipRateLimited) return ipRateLimited const parsed = await parseRequest( diff --git a/apps/sim/app/api/chat/[identifier]/otp/route.test.ts b/apps/sim/app/api/chat/[identifier]/otp/route.test.ts index e49b485cca3..c7b4f5d0490 100644 --- a/apps/sim/app/api/chat/[identifier]/otp/route.test.ts +++ b/apps/sim/app/api/chat/[identifier]/otp/route.test.ts @@ -31,6 +31,7 @@ const { mockSetChatAuthCookie, mockGetStorageMethod, mockZodParse, + mockAfterResponse, } = vi.hoisted(() => { const mockRedisSet = vi.fn() const mockRedisGet = vi.fn() @@ -49,6 +50,7 @@ const { const mockSetChatAuthCookie = vi.fn() const mockGetStorageMethod = vi.fn() const mockZodParse = vi.fn() + const mockAfterResponse = vi.fn() return { mockRedisSet, @@ -62,6 +64,7 @@ const { mockSetChatAuthCookie, mockGetStorageMethod, mockZodParse, + mockAfterResponse, } }) @@ -84,6 +87,10 @@ vi.mock('@/lib/core/rate-limiter', () => ({ }, })) +vi.mock('@/lib/core/utils/after-response', () => ({ + afterResponse: mockAfterResponse, +})) + vi.mock('@/lib/messaging/email/mailer', () => ({ sendEmail: mockSendEmail, })) @@ -149,7 +156,14 @@ vi.mock('zod', () => { } }) -import { POST, PUT } from './route' +import { PUT, POST as routePost } from './route' + +const POST: typeof routePost = async (...args) => { + const response = await routePost(...args) + const task = mockAfterResponse.mock.calls.at(-1)?.[0] as (() => Promise) | undefined + if (task) await task() + return response +} describe('Chat OTP API Route', () => { const mockEmail = 'test@example.com' @@ -209,7 +223,6 @@ describe('Chat OTP API Route', () => { remaining: 10, resetAt: new Date(Date.now() + 60_000), }) - mockZodParse.mockImplementation((data: unknown) => data) setEnv({ NEXT_PUBLIC_APP_URL: 'http://localhost:3000', NODE_ENV: 'test' }) @@ -252,6 +265,27 @@ describe('Chat OTP API Route', () => { }) describe('POST - Rate limiting', () => { + it('returns the generic acceptance response for a rejected email without a client IP', async () => { + requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce(null) + queueDeployment(emailDeployment) + + const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { + method: 'POST', + body: JSON.stringify({ email: 'not-allowed@example.com' }), + }) + + const response = await POST(request, { + params: Promise.resolve({ identifier: mockIdentifier }), + }) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ message: 'Verification code sent' }) + expect(mockAfterResponse).toHaveBeenCalledTimes(1) + expect(mockCheckRateLimitDirect).not.toHaveBeenCalled() + expect(mockRedisSet).not.toHaveBeenCalled() + expect(mockSendEmail).not.toHaveBeenCalled() + }) + it('returns 429 with Retry-After when IP rate limit is exceeded', async () => { mockCheckRateLimitDirect.mockResolvedValueOnce({ allowed: false, @@ -282,13 +316,18 @@ describe('Chat OTP API Route', () => { expect(dbChainMockFns.select).not.toHaveBeenCalled() }) - it('returns 429 with Retry-After when email rate limit is exceeded', async () => { + it('returns the generic acceptance response when the email rate limit is exceeded', async () => { mockCheckRateLimitDirect .mockResolvedValueOnce({ allowed: true, remaining: 9, resetAt: new Date(Date.now() + 60_000), }) + .mockResolvedValueOnce({ + allowed: true, + remaining: 99, + resetAt: new Date(Date.now() + 60_000), + }) .mockResolvedValueOnce({ allowed: false, remaining: 0, @@ -296,12 +335,35 @@ describe('Chat OTP API Route', () => { retryAfterMs: 900_000, }) - const headerSet = vi.fn() - mockCreateErrorResponse.mockImplementationOnce((message: string, status: number) => ({ - json: () => Promise.resolve({ error: message }), - status, - headers: { set: headerSet }, - })) + queueDeployment(emailDeployment) + + const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { + method: 'POST', + body: JSON.stringify({ email: mockEmail }), + }) + + const response = await POST(request, { + params: Promise.resolve({ identifier: mockIdentifier }), + }) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ message: 'Verification code sent' }) + expect(mockSendEmail).not.toHaveBeenCalled() + }) + + it('returns the generic acceptance response when the chat resource limit is exceeded', async () => { + mockCheckRateLimitDirect + .mockResolvedValueOnce({ + allowed: true, + remaining: 9, + resetAt: new Date(Date.now() + 60_000), + }) + .mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: new Date(Date.now() + 900_000), + retryAfterMs: 900_000, + }) queueDeployment(emailDeployment) @@ -314,8 +376,8 @@ describe('Chat OTP API Route', () => { params: Promise.resolve({ identifier: mockIdentifier }), }) - expect(response.status).toBe(429) - expect(headerSet).toHaveBeenCalledWith('Retry-After', '900') + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ message: 'Verification code sent' }) expect(mockSendEmail).not.toHaveBeenCalled() }) @@ -343,8 +405,8 @@ describe('Chat OTP API Route', () => { expect(headerSet).toHaveBeenCalledWith('Retry-After', '900') }) - it('folds spoofed `unknown` client IPs into a single shared bucket', async () => { - requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce('unknown') + it('retains resource and email backstops when the client IP cannot be resolved', async () => { + requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce(null) queueDeployment(emailDeployment) const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { @@ -354,14 +416,19 @@ describe('Chat OTP API Route', () => { await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) + expect(mockAfterResponse).toHaveBeenCalledTimes(1) expect(mockCheckRateLimitDirect).toHaveBeenCalledTimes(2) - expect(mockCheckRateLimitDirect).toHaveBeenCalledWith( - expect.stringMatching(/^chat-otp:ip:.*:unknown$/), - expect.any(Object) + expect(mockCheckRateLimitDirect).toHaveBeenNthCalledWith( + 1, + 'chat-otp:resource:chat-123', + expect.any(Object), + { failClosed: true } ) - expect(mockCheckRateLimitDirect).toHaveBeenCalledWith( + expect(mockCheckRateLimitDirect).toHaveBeenNthCalledWith( + 2, expect.stringContaining('chat-otp:email:'), - expect.any(Object) + expect.any(Object), + { failClosed: true } ) }) }) diff --git a/apps/sim/app/api/chat/[identifier]/otp/route.ts b/apps/sim/app/api/chat/[identifier]/otp/route.ts index aa936877cc5..8a3747c5ae8 100644 --- a/apps/sim/app/api/chat/[identifier]/otp/route.ts +++ b/apps/sim/app/api/chat/[identifier]/otp/route.ts @@ -17,8 +17,10 @@ import { MAX_OTP_ATTEMPTS, OTP_EMAIL_RATE_LIMIT, OTP_IP_RATE_LIMIT, + OTP_RESOURCE_RATE_LIMIT, storeOTP, } from '@/lib/core/security/otp' +import { afterResponse } from '@/lib/core/utils/after-response' import { generateRequestId, getClientIp } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { sendEmail } from '@/lib/messaging/email/mailer' @@ -29,6 +31,49 @@ const logger = createLogger('ChatOtpAPI') const rateLimiter = new RateLimiter() +function otpRequestAccepted() { + return createSuccessResponse({ message: 'Verification code sent' }) +} + +async function deliverOtp(requestId: string, deploymentId: string, title: string, email: string) { + const resourceRateLimit = await rateLimiter.checkRateLimitDirect( + `chat-otp:resource:${deploymentId}`, + OTP_RESOURCE_RATE_LIMIT, + { failClosed: true } + ) + if (!resourceRateLimit.allowed) { + logger.warn(`[${requestId}] OTP resource rate limit exceeded for chat ${deploymentId}`) + return + } + + const emailRateLimit = await rateLimiter.checkRateLimitDirect( + `chat-otp:email:${deploymentId}:${email.toLowerCase()}`, + OTP_EMAIL_RATE_LIMIT, + { failClosed: true } + ) + if (!emailRateLimit.allowed) { + logger.warn(`[${requestId}] OTP email rate limit exceeded for ${email} on chat ${deploymentId}`) + return + } + + const otp = generateOTP() + await storeOTP('chat', deploymentId, email, otp) + + const emailHtml = await renderOTPEmail(otp, email, 'email-verification', title) + const emailResult = await sendEmail({ + to: email, + subject: getOtpSubject(title), + html: emailHtml, + }) + + if (!emailResult.success) { + logger.error(`[${requestId}] Failed to send OTP email:`, emailResult.message) + return + } + + logger.info(`[${requestId}] OTP sent to ${email} for chat ${deploymentId}`) +} + export const POST = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ identifier: string }> }) => { const { identifier } = await context.params @@ -36,18 +81,21 @@ export const POST = withRouteHandler( try { const ip = getClientIp(request) - const ipRateLimit = await rateLimiter.checkRateLimitDirect( - `chat-otp:ip:${identifier}:${ip}`, - OTP_IP_RATE_LIMIT - ) - if (!ipRateLimit.allowed) { - logger.warn(`[${requestId}] OTP IP rate limit exceeded for ${identifier} from ${ip}`) - const retryAfter = Math.ceil( - (ipRateLimit.retryAfterMs ?? OTP_IP_RATE_LIMIT.refillIntervalMs) / 1000 + if (ip) { + const ipRateLimit = await rateLimiter.checkRateLimitDirect( + `chat-otp:ip:${identifier}:${ip}`, + OTP_IP_RATE_LIMIT, + { failClosed: true } ) - const response = createErrorResponse('Too many requests. Please try again later.', 429) - response.headers.set('Retry-After', String(retryAfter)) - return response + if (!ipRateLimit.allowed) { + logger.warn(`[${requestId}] OTP IP rate limit exceeded for ${identifier} from ${ip}`) + const retryAfter = Math.ceil( + (ipRateLimit.retryAfterMs ?? OTP_IP_RATE_LIMIT.refillIntervalMs) / 1000 + ) + const response = createErrorResponse('Too many requests. Please try again later.', 429) + response.headers.set('Retry-After', String(retryAfter)) + return response + } } const parsed = await parseRequest(requestChatEmailOtpContract, request, context, { @@ -84,53 +132,13 @@ export const POST = withRouteHandler( const allowedEmails: string[] = Array.isArray(deployment.allowedEmails) ? deployment.allowedEmails : [] + const emailAllowed = isEmailAllowed(email, allowedEmails) - if (!isEmailAllowed(email, allowedEmails)) { - return createErrorResponse('Email not authorized for this chat', 403) - } - - const emailRateLimit = await rateLimiter.checkRateLimitDirect( - `chat-otp:email:${deployment.id}:${email.toLowerCase()}`, - OTP_EMAIL_RATE_LIMIT - ) - if (!emailRateLimit.allowed) { - logger.warn( - `[${requestId}] OTP email rate limit exceeded for ${email} on chat ${deployment.id}` - ) - const retryAfter = Math.ceil( - (emailRateLimit.retryAfterMs ?? OTP_EMAIL_RATE_LIMIT.refillIntervalMs) / 1000 - ) - const response = createErrorResponse( - 'Too many verification code requests. Please try again later.', - 429 - ) - response.headers.set('Retry-After', String(retryAfter)) - return response - } - - const otp = generateOTP() - await storeOTP('chat', deployment.id, email, otp) - - const emailHtml = await renderOTPEmail( - otp, - email, - 'email-verification', - deployment.title || 'Chat' - ) - - const emailResult = await sendEmail({ - to: email, - subject: getOtpSubject(deployment.title || 'Chat'), - html: emailHtml, + afterResponse(async () => { + if (!emailAllowed) return + await deliverOtp(requestId, deployment.id, deployment.title || 'Chat', email) }) - - if (!emailResult.success) { - logger.error(`[${requestId}] Failed to send OTP email:`, emailResult.message) - return createErrorResponse('Failed to send verification email', 500) - } - - logger.info(`[${requestId}] OTP sent to ${email} for chat ${deployment.id}`) - return createSuccessResponse({ message: 'Verification code sent' }) + return otpRequestAccepted() } catch (error) { logger.error(`[${requestId}] Error processing OTP request:`, error) return createErrorResponse('Failed to process request', 500) diff --git a/apps/sim/app/api/chat/[identifier]/sso/route.test.ts b/apps/sim/app/api/chat/[identifier]/sso/route.test.ts new file mode 100644 index 00000000000..57156d93981 --- /dev/null +++ b/apps/sim/app/api/chat/[identifier]/sso/route.test.ts @@ -0,0 +1,92 @@ +/** + * @vitest-environment node + */ +import { queueTableRows, requestUtilsMockFns, resetDbChainMock, schemaMock } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockIsEmailAllowed, mockCheckRateLimitDirect } = vi.hoisted(() => ({ + mockIsEmailAllowed: vi.fn(), + mockCheckRateLimitDirect: vi.fn(), +})) + +vi.mock('@/lib/core/security/deployment', () => ({ isEmailAllowed: mockIsEmailAllowed })) +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mockCheckRateLimitDirect + }, +})) + +import { POST } from '@/app/api/chat/[identifier]/sso/route' + +const deployment = { + id: 'chat-1', + authType: 'sso', + allowedEmails: ['@acme.com'], + isActive: true, +} + +function post(email: string): NextRequest { + return new NextRequest('http://localhost/api/chat/support/sso', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email }), + }) +} + +const context = { params: Promise.resolve({ identifier: 'support' }) } + +describe('POST /api/chat/[identifier]/sso', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + queueTableRows(schemaMock.chat, [deployment]) + requestUtilsMockFns.mockGetClientIp.mockReturnValue('127.0.0.1') + mockCheckRateLimitDirect.mockResolvedValue({ allowed: true }) + mockIsEmailAllowed.mockReturnValue(true) + }) + + it('applies both client-IP and chat-resource limits', async () => { + const response = await POST(post('user@acme.com'), context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ eligible: true }) + expect(mockCheckRateLimitDirect).toHaveBeenNthCalledWith( + 1, + 'chat-sso:ip:127.0.0.1', + expect.objectContaining({ maxTokens: 20 }), + { failClosed: true } + ) + expect(mockCheckRateLimitDirect).toHaveBeenNthCalledWith( + 2, + 'chat-sso:resource:chat-1', + expect.objectContaining({ maxTokens: 100 }), + { failClosed: true } + ) + }) + + it('returns 429 when the chat-resource limit is exceeded', async () => { + mockCheckRateLimitDirect + .mockResolvedValueOnce({ allowed: true }) + .mockResolvedValueOnce({ allowed: false, retryAfterMs: 3000 }) + + const response = await POST(post('user@acme.com'), context) + + expect(response.status).toBe(429) + expect(response.headers.get('Retry-After')).toBe('3') + }) + + it('retains the chat-resource limit when the client IP cannot be resolved', async () => { + requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce(null) + + const response = await POST(post('user@acme.com'), context) + + expect(response.status).toBe(200) + expect(mockCheckRateLimitDirect).toHaveBeenCalledTimes(1) + expect(mockCheckRateLimitDirect).toHaveBeenCalledWith( + 'chat-sso:resource:chat-1', + expect.objectContaining({ maxTokens: 100 }), + { failClosed: true } + ) + }) +}) diff --git a/apps/sim/app/api/chat/[identifier]/sso/route.ts b/apps/sim/app/api/chat/[identifier]/sso/route.ts index c6ab98cfe94..d29f66789f6 100644 --- a/apps/sim/app/api/chat/[identifier]/sso/route.ts +++ b/apps/sim/app/api/chat/[identifier]/sso/route.ts @@ -25,23 +25,33 @@ const SSO_IP_RATE_LIMIT: TokenBucketConfig = { refillIntervalMs: 15 * 60_000, } +const SSO_RESOURCE_RATE_LIMIT: TokenBucketConfig = { + maxTokens: 100, + refillRate: 100, + refillIntervalMs: 15 * 60_000, +} + +function rateLimited(retryAfterMs: number | undefined, fallbackMs: number) { + const response = createErrorResponse('Too many requests. Please try again later.', 429) + response.headers.set('Retry-After', String(Math.ceil((retryAfterMs ?? fallbackMs) / 1000))) + return response +} + export const POST = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ identifier: string }> }) => { const requestId = generateRequestId() const ip = getClientIp(request) - const ipRateLimit = await rateLimiter.checkRateLimitDirect( - `chat-sso:ip:${ip}`, - SSO_IP_RATE_LIMIT - ) - if (!ipRateLimit.allowed) { - logger.warn(`[${requestId}] SSO eligibility rate limit exceeded from ${ip}`) - const retryAfter = Math.ceil( - (ipRateLimit.retryAfterMs ?? SSO_IP_RATE_LIMIT.refillIntervalMs) / 1000 + if (ip) { + const ipRateLimit = await rateLimiter.checkRateLimitDirect( + `chat-sso:ip:${ip}`, + SSO_IP_RATE_LIMIT, + { failClosed: true } ) - const response = createErrorResponse('Too many requests. Please try again later.', 429) - response.headers.set('Retry-After', String(retryAfter)) - return response + if (!ipRateLimit.allowed) { + logger.warn(`[${requestId}] SSO eligibility rate limit exceeded from ${ip}`) + return rateLimited(ipRateLimit.retryAfterMs, SSO_IP_RATE_LIMIT.refillIntervalMs) + } } const parsed = await parseRequest(chatSSOContract, request, context) @@ -52,6 +62,7 @@ export const POST = withRouteHandler( const [deployment] = await db .select({ + id: chat.id, authType: chat.authType, allowedEmails: chat.allowedEmails, isActive: chat.isActive, @@ -69,6 +80,18 @@ export const POST = withRouteHandler( return createErrorResponse('Chat is not configured for SSO authentication', 400) } + const resourceRateLimit = await rateLimiter.checkRateLimitDirect( + `chat-sso:resource:${deployment.id}`, + SSO_RESOURCE_RATE_LIMIT, + { failClosed: true } + ) + if (!resourceRateLimit.allowed) { + logger.warn(`[${requestId}] SSO eligibility resource rate limit exceeded`, { + deploymentId: deployment.id, + }) + return rateLimited(resourceRateLimit.retryAfterMs, SSO_RESOURCE_RATE_LIMIT.refillIntervalMs) + } + const eligible = isEmailAllowed(email, (deployment.allowedEmails as string[]) || []) return createSuccessResponse({ eligible }) diff --git a/apps/sim/app/api/chat/utils.test.ts b/apps/sim/app/api/chat/utils.test.ts index 6c41eeb21cc..5b5675763a1 100644 --- a/apps/sim/app/api/chat/utils.test.ts +++ b/apps/sim/app/api/chat/utils.test.ts @@ -5,9 +5,11 @@ */ import { authMockFns, + createMockRequest, encryptionMock, encryptionMockFns, loggingSessionMock, + requestUtilsMockFns, workflowsUtilsMock, } from '@sim/testing' import type { NextResponse } from 'next/server' @@ -208,6 +210,18 @@ describe('Chat API Utils', () => { const result = await validateChatAuth('request-id', deployment, mockRequest, parsedBody) + expect(mockCheckRateLimitDirect).toHaveBeenNthCalledWith( + 1, + 'chat-password:ip:chat-id:127.0.0.1', + expect.objectContaining({ maxTokens: 10 }), + { failClosed: true } + ) + expect(mockCheckRateLimitDirect).toHaveBeenNthCalledWith( + 2, + 'chat-password:resource:chat-id', + expect.objectContaining({ maxTokens: 100 }), + { failClosed: true } + ) expect(decryptSecret).toHaveBeenCalledWith('encrypted-password') expect(result.authorized).toBe(true) }) @@ -236,7 +250,7 @@ describe('Chat API Utils', () => { expect(result.error).toBe('Invalid password') }) - it('should return 429 when the password attempt rate limit is exceeded', async () => { + it('should return 429 when the password IP rate limit is exceeded', async () => { mockCheckRateLimitDirect.mockResolvedValueOnce({ allowed: false, retryAfterMs: 60_000 }) const deployment = { @@ -260,6 +274,63 @@ describe('Chat API Utils', () => { expect(result.status).toBe(429) expect(result.retryAfterMs).toBe(60_000) expect(decryptSecret).not.toHaveBeenCalled() + expect(mockCheckRateLimitDirect).toHaveBeenCalledWith( + 'chat-password:ip:chat-id:127.0.0.1', + expect.objectContaining({ maxTokens: 10 }), + { failClosed: true } + ) + }) + + it('should return 429 when the password resource rate limit is exceeded', async () => { + mockCheckRateLimitDirect + .mockResolvedValueOnce({ allowed: true }) + .mockResolvedValueOnce({ allowed: false, retryAfterMs: 30_000 }) + + const deployment = { + id: 'chat-id', + authType: 'password', + password: 'encrypted-password', + } + const mockRequest = createMockRequest('POST') + const candidate = 'password-attempt-fixture' + + const result = await validateChatAuth('request-id', deployment, mockRequest, { + password: candidate, + }) + + expect(result).toEqual( + expect.objectContaining({ authorized: false, status: 429, retryAfterMs: 30_000 }) + ) + expect(mockCheckRateLimitDirect).toHaveBeenNthCalledWith( + 2, + 'chat-password:resource:chat-id', + expect.objectContaining({ maxTokens: 100 }), + { failClosed: true } + ) + expect(decryptSecret).not.toHaveBeenCalled() + }) + + it('should retain the password resource limit when the client IP cannot be resolved', async () => { + requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce(null) + const deployment = { + id: 'chat-id', + authType: 'password', + password: 'encrypted-password', + } + const mockRequest = createMockRequest('POST') + const candidate = 'correct-password' + + const result = await validateChatAuth('request-id', deployment, mockRequest, { + password: candidate, + }) + + expect(result.authorized).toBe(true) + expect(mockCheckRateLimitDirect).toHaveBeenCalledTimes(1) + expect(mockCheckRateLimitDirect).toHaveBeenCalledWith( + 'chat-password:resource:chat-id', + expect.objectContaining({ maxTokens: 100 }), + { failClosed: true } + ) }) it('should request email auth for email-protected chats', async () => { diff --git a/apps/sim/app/api/contact/route.ts b/apps/sim/app/api/contact/route.ts index 5d1c0404eba..69df8e97457 100644 --- a/apps/sim/app/api/contact/route.ts +++ b/apps/sim/app/api/contact/route.ts @@ -54,8 +54,11 @@ export const POST = withRouteHandler(async (req: NextRequest) => { try { const ip = getClientIp(req) + if (!ip) { + logger.warn(`[${requestId}] Unable to resolve client IP for public rate limit`) + return NextResponse.json(TOO_MANY_REQUESTS_RESPONSE, { status: 429 }) + } const storageKey = `public:contact:${ip}` - const { allowed, remaining, resetAt } = await rateLimiter.checkRateLimitDirect( storageKey, PUBLIC_ENDPOINT_RATE_LIMIT diff --git a/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.test.ts b/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.test.ts index acb2344a2b6..ff16fe62b7c 100644 --- a/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.test.ts +++ b/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.test.ts @@ -20,7 +20,7 @@ vi.mock('@/lib/credential-groups/application/public-enrollment', () => ({ })) vi.mock('@/lib/credential-groups/rate-limit', () => ({ - enforcePublicCredentialGroupIpRateLimit: mocks.ipRateLimit, + enforcePublicCredentialGroupOAuthStartIpRateLimit: mocks.ipRateLimit, enforceCredentialGroupEnrollmentOAuthRateLimit: mocks.enrollmentRateLimit, })) diff --git a/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.ts b/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.ts index 22921bc1f62..bc66315f68e 100644 --- a/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.ts +++ b/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.ts @@ -10,7 +10,7 @@ import { startPublicCredentialGroupOAuth } from '@/lib/credential-groups/applica import { CredentialGroupOAuthError } from '@/lib/credential-groups/provider-adapter' import { enforceCredentialGroupEnrollmentOAuthRateLimit, - enforcePublicCredentialGroupIpRateLimit, + enforcePublicCredentialGroupOAuthStartIpRateLimit, } from '@/lib/credential-groups/rate-limit' import { createCredentialGroupEnrollmentRedirect } from '@/app/api/credential-groups/enrollment-redirect' @@ -24,7 +24,7 @@ export const GET = withRouteHandler( request: NextRequest, context: { params: Promise<{ token: string; optionId: string }> } ) => { - const limited = await enforcePublicCredentialGroupIpRateLimit(request, 'oauth-start') + const limited = await enforcePublicCredentialGroupOAuthStartIpRateLimit(request) const parsed = await parseRequest(startCredentialGroupOAuthContract, request, context) if (!parsed.success) return limited ?? parsed.response diff --git a/apps/sim/app/api/cron/cleanup-stale-executions/route.test.ts b/apps/sim/app/api/cron/cleanup-stale-executions/route.test.ts index 47df7139d7e..473201533b1 100644 --- a/apps/sim/app/api/cron/cleanup-stale-executions/route.test.ts +++ b/apps/sim/app/api/cron/cleanup-stale-executions/route.test.ts @@ -396,8 +396,9 @@ describe('stale execution cleanup deadline grace', () => { const response = await GET(createRequest()) expect(response.status).toBe(200) - expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(8) - expect(dbChainMockFns.for).toHaveBeenCalledTimes(8) + // Nine batched arms: the connector sync-log retention pass is the newest. + expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(9) + expect(dbChainMockFns.for).toHaveBeenCalledTimes(9) for (const [strength, options] of dbChainMockFns.for.mock.calls) { expect(strength).toBe('update') expect(options).toEqual({ skipLocked: true }) @@ -469,7 +470,7 @@ describe('stale execution cleanup deadline grace', () => { const limits = dbChainMockFns.limit.mock.calls.map(([limit]) => limit) expect(limits.filter((limit) => limit === 100)).toHaveLength(20) expect(limits.filter((limit) => limit === 1000)).toHaveLength(30) - expect(limits.filter((limit) => limit === 2000)).toHaveLength(11) + expect(limits.filter((limit) => limit === 2000)).toHaveLength(12) const workflowUpdates = dbChainMockFns.update.mock.calls.filter( ([table]) => table === workflowExecutionLogs diff --git a/apps/sim/app/api/cron/cleanup-stale-executions/route.ts b/apps/sim/app/api/cron/cleanup-stale-executions/route.ts index ab5b323de02..3578a3f958f 100644 --- a/apps/sim/app/api/cron/cleanup-stale-executions/route.ts +++ b/apps/sim/app/api/cron/cleanup-stale-executions/route.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' import { asyncJobs, + knowledgeConnectorSyncLog, tableJobs, workflowDeploymentOperation, workflowExecutionLogs, @@ -31,6 +32,7 @@ import { STALE_SWEEPABLE_EXECUTION_STATUSES, type StaleSweepableExecutionStatus, } from '@/lib/logs/types' +import { cancelStaleDispatches } from '@/lib/table/dispatcher' import { deleteFile } from '@/lib/uploads/core/storage-service' import { carrierNotIrrecoverableSql, @@ -52,12 +54,33 @@ const EXECUTION_DEADLINE_ERROR = getTimeoutErrorMessage(undefined) const TABLE_JOB_STALE_THRESHOLD_MINUTES = 95 /** Terminal table-jobs older than this are pruned; only the latest job per table is ever read. */ const TABLE_JOB_RETENTION_HOURS = 24 +/** + * A table run dispatch whose holder has not made progress for this long is + * treated as dead. Same shape and window as the table-job threshold above: the + * 90-minute Trigger.dev task ceiling (`maxDuration` in `trigger.config.ts`) plus + * five minutes of cleanup grace, measured from the dispatcher's own per-window + * heartbeat rather than from when the run was requested. + */ +const TABLE_DISPATCH_STALE_THRESHOLD_MINUTES = 95 +/** Per-run ceiling on reaped dispatches, so one tick cannot fan out unbounded SSE. */ +const TABLE_DISPATCH_MAX_PER_RUN = 200 /** * Terminal deployment operations older than this are pruned. Every reader of * this table is latest-generation-only, and idempotency keys only need to * survive a client retry window, so 30 days is generous. */ const DEPLOYMENT_OPERATION_RETENTION_DAYS = 30 +/** + * Terminal connector sync logs older than this are pruned. Nothing pruned them + * before, so the table grew by one row per sync run forever — a connector on a + * fifteen-minute interval writes about 35,000 rows a year on its own. That cost + * lands on `loadPreviousListingObservation`, which reads the newest `completed` + * row per connector through an index covering `connector_id` alone, so every + * retained row makes the sort behind the deletion-safety corroboration slower. + */ +const CONNECTOR_SYNC_LOG_RETENTION_DAYS = 30 +const CONNECTOR_SYNC_LOG_PRUNE_BATCH_SIZE = 2000 +const CONNECTOR_SYNC_LOG_MAX_ROWS_PER_RUN = 20_000 const DEPLOYMENT_OPERATION_PRUNE_BATCH_SIZE = 2000 const DEPLOYMENT_OPERATION_PRUNE_MAX_BATCHES = 10 const WORKFLOW_EXECUTION_MUTATION_BATCH_SIZE = 100 @@ -144,6 +167,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const staleTableJobThreshold = new Date( now.getTime() - TABLE_JOB_STALE_THRESHOLD_MINUTES * 60 * 1000 ) + const staleDispatchThreshold = new Date( + now.getTime() - TABLE_DISPATCH_STALE_THRESHOLD_MINUTES * 60 * 1000 + ) let staleExecutionsFound = 0 let cleaned = 0 @@ -538,6 +564,90 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }) } + /** + * Prune terminal connector sync logs past retention. + * + * HARD INVARIANT: the newest row per connector must survive, and so must the + * newest `completed` row. `loadPreviousListingObservation` reconstructs the + * previous listing from the latest `completed` log, and that reconstruction + * decides whether a suspect listing is corroborated — i.e. whether + * reconciliation may delete documents. Pruning the last `completed` row + * would silently change deletion behaviour, so both `exists` guards below + * are load-bearing rather than defensive. + * + * `started` rows are never eligible: they are either in flight or waiting on + * the scheduler's own sweep to close them. + */ + let connectorSyncLogsPruned = 0 + try { + const syncLogRetention = new Date( + Date.now() - CONNECTOR_SYNC_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000 + ) + const newerSyncLog = alias(knowledgeConnectorSyncLog, 'newer_sync_log') + const newerCompletedSyncLog = alias(knowledgeConnectorSyncLog, 'newer_completed_sync_log') + const syncLogPredicate = and( + inArray(knowledgeConnectorSyncLog.status, ['completed', 'failed']), + lt(knowledgeConnectorSyncLog.startedAt, syncLogRetention), + exists( + db + .select({ id: newerSyncLog.id }) + .from(newerSyncLog) + .where( + and( + eq(newerSyncLog.connectorId, knowledgeConnectorSyncLog.connectorId), + gt(newerSyncLog.startedAt, knowledgeConnectorSyncLog.startedAt) + ) + ) + ), + or( + ne(knowledgeConnectorSyncLog.status, 'completed'), + exists( + db + .select({ id: newerCompletedSyncLog.id }) + .from(newerCompletedSyncLog) + .where( + and( + eq(newerCompletedSyncLog.connectorId, knowledgeConnectorSyncLog.connectorId), + eq(newerCompletedSyncLog.status, 'completed'), + gt(newerCompletedSyncLog.startedAt, knowledgeConnectorSyncLog.startedAt) + ) + ) + ) + ) + ) + const syncLogResult = await runBatchedMutation({ + batchSize: CONNECTOR_SYNC_LOG_PRUNE_BATCH_SIZE, + maxRowsPerRun: CONNECTOR_SYNC_LOG_MAX_ROWS_PER_RUN, + claim: (tx, limit) => + tx + .select({ id: knowledgeConnectorSyncLog.id }) + .from(knowledgeConnectorSyncLog) + .where(syncLogPredicate) + .limit(limit) + .for('update', { skipLocked: true }), + mutation: (tx, candidateIds) => + tx + .delete(knowledgeConnectorSyncLog) + .where(inArray(knowledgeConnectorSyncLog.id, candidateIds)) + .returning({ id: knowledgeConnectorSyncLog.id }), + }) + connectorSyncLogsPruned = syncLogResult.affected + if (connectorSyncLogsPruned > 0) { + logger.info( + `Pruned ${connectorSyncLogsPruned} old connector sync logs (retention: ${CONNECTOR_SYNC_LOG_RETENTION_DAYS}d)` + ) + } + if (syncLogResult.reachedLimit) { + logger.info('Deferred remaining connector sync logs after reaching the per-run cap', { + maxRowsPerRun: CONNECTOR_SYNC_LOG_MAX_ROWS_PER_RUN, + }) + } + } catch (error) { + logger.error('Failed to prune old connector sync logs:', { + error: toError(error).message, + }) + } + /** * Prune terminal deployment operations past retention. HARD INVARIANT: * the newest-generation row per workflow must always survive — the next @@ -604,6 +714,29 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }) } + /** + * Cancel table run dispatches abandoned by a dead dispatcher. Nothing else + * reclaims them — every other terminal transition is user- or flow-initiated + * — so a dispatcher killed mid-loop left the row `dispatching` forever and + * the client's "X running" overlay with it. Ages from the dispatcher's + * per-window heartbeat, so a slow-but-live dispatch is spared. + */ + let staleDispatchesCancelled = 0 + try { + staleDispatchesCancelled = ( + await cancelStaleDispatches(staleDispatchThreshold, TABLE_DISPATCH_MAX_PER_RUN) + ).length + if (staleDispatchesCancelled > 0) { + logger.warn(`Cancelled ${staleDispatchesCancelled} abandoned table run dispatches`, { + thresholdMinutes: TABLE_DISPATCH_STALE_THRESHOLD_MINUTES, + }) + } + } catch (error) { + logger.error('Failed to cancel abandoned table run dispatches:', { + error: toError(error).message, + }) + } + return NextResponse.json({ success: true, executions: { @@ -622,6 +755,14 @@ export const GET = withRouteHandler(async (request: NextRequest) => { tableJobs: { staleMarkedFailed: staleTableJobsMarkedFailed, }, + connectorSyncLogs: { + pruned: connectorSyncLogsPruned, + retentionDays: CONNECTOR_SYNC_LOG_RETENTION_DAYS, + }, + tableRunDispatches: { + staleCancelled: staleDispatchesCancelled, + thresholdMinutes: TABLE_DISPATCH_STALE_THRESHOLD_MINUTES, + }, deploymentOperations: { pruned: deploymentOperationsPruned, retentionDays: DEPLOYMENT_OPERATION_RETENTION_DAYS, diff --git a/apps/sim/app/api/custom-blocks/[id]/route.ts b/apps/sim/app/api/custom-blocks/[id]/route.ts index 474d8ea273f..a344424f2ed 100644 --- a/apps/sim/app/api/custom-blocks/[id]/route.ts +++ b/apps/sim/app/api/custom-blocks/[id]/route.ts @@ -36,7 +36,8 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout if (authz.error) return authz.error const { ctx } = authz - const { name, description, enabled, iconUrl, inputs, exposedOutputs } = parsed.data.body + const { name, description, enabled, iconUrl, inputs, exposedOutputs, traceChildRuns } = + parsed.data.body try { await updateCustomBlock(id, { name, @@ -45,6 +46,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout inputs, iconUrl, exposedOutputs, + traceChildRuns, }) recordAudit({ workspaceId: ctx.sourceWorkspaceId, diff --git a/apps/sim/app/api/custom-blocks/route.ts b/apps/sim/app/api/custom-blocks/route.ts index 7c3c964b1dd..8b02b33ff59 100644 --- a/apps/sim/app/api/custom-blocks/route.ts +++ b/apps/sim/app/api/custom-blocks/route.ts @@ -36,6 +36,7 @@ function toWire(block: CustomBlockWithInputs) { description: block.description, iconUrl: block.iconUrl, enabled: block.enabled, + traceChildRuns: block.traceChildRuns, inputFields: block.inputFields, exposedOutputs: block.exposedOutputs, } @@ -82,8 +83,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (!parsed.success) return parsed.response const userId = session.user.id - const { workspaceId, workflowId, name, description, iconUrl, inputs, exposedOutputs } = - parsed.data.body + const { + workspaceId, + workflowId, + name, + description, + iconUrl, + inputs, + exposedOutputs, + traceChildRuns, + } = parsed.data.body const access = await checkWorkspaceAccess(workspaceId, userId) if (!access.canAdmin) { @@ -120,6 +129,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { iconUrl, inputs, exposedOutputs, + traceChildRuns, }) recordAudit({ workspaceId, diff --git a/apps/sim/app/api/demo-requests/route.ts b/apps/sim/app/api/demo-requests/route.ts index 7553239e7b2..56c1bbededb 100644 --- a/apps/sim/app/api/demo-requests/route.ts +++ b/apps/sim/app/api/demo-requests/route.ts @@ -28,8 +28,14 @@ export const POST = withRouteHandler(async (req: NextRequest) => { try { const ip = getClientIp(req) + if (!ip) { + logger.warn(`[${requestId}] Unable to resolve client IP for public rate limit`) + return NextResponse.json( + { error: 'Too many requests. Please try again later.' }, + { status: 429 } + ) + } const storageKey = `public:demo-request:${ip}` - const { allowed, remaining, resetAt } = await rateLimiter.checkRateLimitDirect( storageKey, PUBLIC_ENDPOINT_RATE_LIMIT diff --git a/apps/sim/app/api/files/public/[token]/otp/route.test.ts b/apps/sim/app/api/files/public/[token]/otp/route.test.ts index bce515c9237..94429804c40 100644 --- a/apps/sim/app/api/files/public/[token]/otp/route.test.ts +++ b/apps/sim/app/api/files/public/[token]/otp/route.test.ts @@ -1,6 +1,7 @@ /** * @vitest-environment node */ +import { requestUtilsMockFns } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -17,6 +18,7 @@ const { mockRenderOTPEmail, mockSendEmail, mockCheckRateLimitDirect, + mockAfterResponse, } = vi.hoisted(() => ({ mockResolveActiveShareByToken: vi.fn(), mockIsEmailAllowed: vi.fn(), @@ -30,6 +32,7 @@ const { mockRenderOTPEmail: vi.fn(), mockSendEmail: vi.fn(), mockCheckRateLimitDirect: vi.fn(), + mockAfterResponse: vi.fn(), })) vi.mock('@/lib/public-shares/share-manager', () => ({ @@ -49,6 +52,7 @@ vi.mock('@/lib/core/security/otp', () => ({ MAX_OTP_ATTEMPTS: 5, OTP_IP_RATE_LIMIT: { maxTokens: 10, refillRate: 10, refillIntervalMs: 1000 }, OTP_EMAIL_RATE_LIMIT: { maxTokens: 3, refillRate: 3, refillIntervalMs: 1000 }, + OTP_RESOURCE_RATE_LIMIT: { maxTokens: 100, refillRate: 100, refillIntervalMs: 1000 }, })) vi.mock('@/components/emails', () => ({ getOtpSubject: (label: string) => `Verification code for ${label}`, @@ -60,8 +64,18 @@ vi.mock('@/lib/core/rate-limiter', () => ({ checkRateLimitDirect = mockCheckRateLimitDirect }, })) +vi.mock('@/lib/core/utils/after-response', () => ({ + afterResponse: mockAfterResponse, +})) + +import { PUT, POST as routePost } from '@/app/api/files/public/[token]/otp/route' -import { POST, PUT } from '@/app/api/files/public/[token]/otp/route' +const POST: typeof routePost = async (...args) => { + const response = await routePost(...args) + const task = mockAfterResponse.mock.calls.at(-1)?.[0] as (() => Promise) | undefined + if (task) await task() + return response +} const params = (token = 'tok_1') => ({ params: Promise.resolve({ token }) }) const post = (email: string, token = 'tok_1') => @@ -96,15 +110,33 @@ describe('POST /api/files/public/[token]/otp', () => { it('sends a code to an allow-listed email', async () => { const res = await POST(post('user@acme.com'), params()) expect(res.status).toBe(200) + expect(mockAfterResponse).toHaveBeenCalledTimes(1) expect(mockStoreOTP).toHaveBeenCalledWith('file', 'sh_1', 'user@acme.com', '123456') expect(mockSendEmail).toHaveBeenCalled() }) - it('rejects an email not on the allow-list with 403', async () => { + it('returns the generic acceptance response for an email not on the allow-list', async () => { + mockIsEmailAllowed.mockReturnValueOnce(false) + const res = await POST(post('user@evil.com'), params()) + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ message: 'Verification code sent' }) + expect(mockCheckRateLimitDirect).toHaveBeenCalledTimes(1) + expect(mockStoreOTP).not.toHaveBeenCalled() + expect(mockSendEmail).not.toHaveBeenCalled() + }) + + it('does not consume a send bucket for a rejected email without a client IP', async () => { + requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce(null) mockIsEmailAllowed.mockReturnValueOnce(false) + const res = await POST(post('user@evil.com'), params()) - expect(res.status).toBe(403) + + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ message: 'Verification code sent' }) + expect(mockAfterResponse).toHaveBeenCalledTimes(1) + expect(mockCheckRateLimitDirect).not.toHaveBeenCalled() expect(mockStoreOTP).not.toHaveBeenCalled() + expect(mockSendEmail).not.toHaveBeenCalled() }) it('lowercases the email for allow-list matching and OTP storage', async () => { @@ -128,6 +160,63 @@ describe('POST /api/files/public/[token]/otp', () => { expect(res.status).toBe(429) expect(res.headers.get('Retry-After')).toBe('1') }) + + it('returns the generic acceptance response when the share resource limit is exceeded', async () => { + mockCheckRateLimitDirect + .mockResolvedValueOnce({ allowed: true }) + .mockResolvedValueOnce({ allowed: false, retryAfterMs: 1000 }) + + const res = await POST(post('user@acme.com'), params()) + + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ message: 'Verification code sent' }) + expect(mockStoreOTP).not.toHaveBeenCalled() + expect(mockSendEmail).not.toHaveBeenCalled() + }) + + it('returns the generic acceptance response when the email rate limit is exceeded', async () => { + mockCheckRateLimitDirect + .mockResolvedValueOnce({ allowed: true }) + .mockResolvedValueOnce({ allowed: true }) + .mockResolvedValueOnce({ allowed: false, retryAfterMs: 1000 }) + + const res = await POST(post('user@acme.com'), params()) + + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ message: 'Verification code sent' }) + expect(mockStoreOTP).not.toHaveBeenCalled() + expect(mockSendEmail).not.toHaveBeenCalled() + }) + + it('returns the generic acceptance response when email delivery fails', async () => { + mockSendEmail.mockResolvedValueOnce({ success: false, message: 'Delivery failed' }) + + const res = await POST(post('user@acme.com'), params()) + + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ message: 'Verification code sent' }) + }) + + it('retains resource and email backstops when the client IP cannot be resolved', async () => { + requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce(null) + + const res = await POST(post('user@acme.com'), params()) + + expect(res.status).toBe(200) + expect(mockCheckRateLimitDirect).toHaveBeenCalledTimes(2) + expect(mockCheckRateLimitDirect).toHaveBeenNthCalledWith( + 1, + 'file-otp:resource:sh_1', + expect.any(Object), + { failClosed: true } + ) + expect(mockCheckRateLimitDirect).toHaveBeenNthCalledWith( + 2, + 'file-otp:email:sh_1:user@acme.com', + expect.any(Object), + { failClosed: true } + ) + }) }) describe('PUT /api/files/public/[token]/otp', () => { diff --git a/apps/sim/app/api/files/public/[token]/otp/route.ts b/apps/sim/app/api/files/public/[token]/otp/route.ts index c6b556ad41d..86c871375e1 100644 --- a/apps/sim/app/api/files/public/[token]/otp/route.ts +++ b/apps/sim/app/api/files/public/[token]/otp/route.ts @@ -19,8 +19,10 @@ import { MAX_OTP_ATTEMPTS, OTP_EMAIL_RATE_LIMIT, OTP_IP_RATE_LIMIT, + OTP_RESOURCE_RATE_LIMIT, storeOTP, } from '@/lib/core/security/otp' +import { afterResponse } from '@/lib/core/utils/after-response' import { generateRequestId, getClientIp } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { sendEmail } from '@/lib/messaging/email/mailer' @@ -48,6 +50,48 @@ function rateLimited(retryAfterMs: number | undefined, fallbackMs: number): Next return response } +function otpRequestAccepted(): NextResponse { + return NextResponse.json({ message: 'Verification code sent' }) +} + +async function deliverOtp(requestId: string, shareId: string, email: string): Promise { + const resourceRateLimit = await rateLimiter.checkRateLimitDirect( + `file-otp:resource:${shareId}`, + OTP_RESOURCE_RATE_LIMIT, + { failClosed: true } + ) + if (!resourceRateLimit.allowed) { + logger.warn(`[${requestId}] OTP resource rate limit exceeded for share ${shareId}`) + return + } + + const emailRateLimit = await rateLimiter.checkRateLimitDirect( + `file-otp:email:${shareId}:${email}`, + OTP_EMAIL_RATE_LIMIT, + { failClosed: true } + ) + if (!emailRateLimit.allowed) { + logger.warn(`[${requestId}] OTP email rate limit exceeded for ${email}`) + return + } + + const otp = generateOTP() + await storeOTP('file', shareId, email, otp) + + const emailHtml = await renderOTPEmail(otp, email, 'email-verification', SHARE_EMAIL_LABEL) + const emailResult = await sendEmail({ + to: email, + subject: getOtpSubject(SHARE_EMAIL_LABEL), + html: emailHtml, + }) + if (!emailResult.success) { + logger.error(`[${requestId}] Failed to send OTP email:`, emailResult.message) + return + } + + logger.info(`[${requestId}] OTP sent for share ${shareId}`) +} + /** * POST /api/files/public/[token]/otp * Sends a 6-digit verification code to an allow-listed email for an email-gated share. @@ -58,13 +102,16 @@ export const POST = withRouteHandler( try { const ip = getClientIp(request) - const ipRateLimit = await rateLimiter.checkRateLimitDirect( - `file-otp:ip:${ip}`, - OTP_IP_RATE_LIMIT - ) - if (!ipRateLimit.allowed) { - logger.warn(`[${requestId}] OTP IP rate limit exceeded from ${ip}`) - return rateLimited(ipRateLimit.retryAfterMs, OTP_IP_RATE_LIMIT.refillIntervalMs) + if (ip) { + const ipRateLimit = await rateLimiter.checkRateLimitDirect( + `file-otp:ip:${ip}`, + OTP_IP_RATE_LIMIT, + { failClosed: true } + ) + if (!ipRateLimit.allowed) { + logger.warn(`[${requestId}] OTP IP rate limit exceeded from ${ip}`) + return rateLimited(ipRateLimit.retryAfterMs, OTP_IP_RATE_LIMIT.refillIntervalMs) + } } const parsed = await parseRequest(requestPublicFileOtpContract, request, context) @@ -84,36 +131,13 @@ export const POST = withRouteHandler( { status: 400 } ) } + const emailAllowed = isEmailAllowed(email, shareAllowedEmails(resolved.share.allowedEmails)) - if (!isEmailAllowed(email, shareAllowedEmails(resolved.share.allowedEmails))) { - return NextResponse.json({ error: 'Email not authorized for this file' }, { status: 403 }) - } - - const emailRateLimit = await rateLimiter.checkRateLimitDirect( - `file-otp:email:${resolved.share.id}:${email}`, - OTP_EMAIL_RATE_LIMIT - ) - if (!emailRateLimit.allowed) { - logger.warn(`[${requestId}] OTP email rate limit exceeded for ${email}`) - return rateLimited(emailRateLimit.retryAfterMs, OTP_EMAIL_RATE_LIMIT.refillIntervalMs) - } - - const otp = generateOTP() - await storeOTP('file', resolved.share.id, email, otp) - - const emailHtml = await renderOTPEmail(otp, email, 'email-verification', SHARE_EMAIL_LABEL) - const emailResult = await sendEmail({ - to: email, - subject: getOtpSubject(SHARE_EMAIL_LABEL), - html: emailHtml, + afterResponse(async () => { + if (!emailAllowed) return + await deliverOtp(requestId, resolved.share.id, email) }) - if (!emailResult.success) { - logger.error(`[${requestId}] Failed to send OTP email:`, emailResult.message) - return NextResponse.json({ error: 'Failed to send verification email' }, { status: 500 }) - } - - logger.info(`[${requestId}] OTP sent for share ${resolved.share.id}`) - return NextResponse.json({ message: 'Verification code sent' }) + return otpRequestAccepted() } catch (error) { logger.error(`[${requestId}] Error processing OTP request:`, error) return NextResponse.json({ error: 'Failed to process request' }, { status: 500 }) diff --git a/apps/sim/app/api/files/public/[token]/sso/route.test.ts b/apps/sim/app/api/files/public/[token]/sso/route.test.ts index 92d78cd8b13..f771ce021eb 100644 --- a/apps/sim/app/api/files/public/[token]/sso/route.test.ts +++ b/apps/sim/app/api/files/public/[token]/sso/route.test.ts @@ -1,6 +1,7 @@ /** * @vitest-environment node */ +import { requestUtilsMockFns } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -49,6 +50,18 @@ describe('POST /api/files/public/[token]/sso', () => { const res = await POST(post('user@acme.com'), params()) expect(res.status).toBe(200) expect(await res.json()).toEqual({ eligible: true }) + expect(mockCheckRateLimitDirect).toHaveBeenNthCalledWith( + 1, + 'file-sso:ip:127.0.0.1', + expect.objectContaining({ maxTokens: 20 }), + { failClosed: true } + ) + expect(mockCheckRateLimitDirect).toHaveBeenNthCalledWith( + 2, + 'file-sso:resource:sh_1', + expect.objectContaining({ maxTokens: 100 }), + { failClosed: true } + ) }) it('returns eligible:false for a non-listed email', async () => { @@ -79,4 +92,29 @@ describe('POST /api/files/public/[token]/sso', () => { expect(res.status).toBe(429) expect(res.headers.get('Retry-After')).toBe('2') }) + + it('returns 429 when the share resource limit is exceeded', async () => { + mockCheckRateLimitDirect + .mockResolvedValueOnce({ allowed: true }) + .mockResolvedValueOnce({ allowed: false, retryAfterMs: 3000 }) + + const res = await POST(post('user@acme.com'), params()) + + expect(res.status).toBe(429) + expect(res.headers.get('Retry-After')).toBe('3') + }) + + it('uses the share resource limit when the client IP cannot be resolved', async () => { + requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce(null) + + const res = await POST(post('user@acme.com'), params()) + + expect(res.status).toBe(200) + expect(mockCheckRateLimitDirect).toHaveBeenCalledTimes(1) + expect(mockCheckRateLimitDirect).toHaveBeenCalledWith( + 'file-sso:resource:sh_1', + expect.objectContaining({ maxTokens: 100 }), + { failClosed: true } + ) + }) }) diff --git a/apps/sim/app/api/files/public/[token]/sso/route.ts b/apps/sim/app/api/files/public/[token]/sso/route.ts index b5185149440..bc94fdcd0b6 100644 --- a/apps/sim/app/api/files/public/[token]/sso/route.ts +++ b/apps/sim/app/api/files/public/[token]/sso/route.ts @@ -24,6 +24,21 @@ const SSO_IP_RATE_LIMIT: TokenBucketConfig = { refillIntervalMs: 15 * 60_000, } +const SSO_RESOURCE_RATE_LIMIT: TokenBucketConfig = { + maxTokens: 100, + refillRate: 100, + refillIntervalMs: 15 * 60_000, +} + +function rateLimited(retryAfterMs: number | undefined, fallbackMs: number): NextResponse { + const response = NextResponse.json( + { error: 'Too many requests. Please try again later.' }, + { status: 429 } + ) + response.headers.set('Retry-After', String(Math.ceil((retryAfterMs ?? fallbackMs) / 1000))) + return response +} + /** * POST /api/files/public/[token]/sso * Reports whether an email is on the allow-list for an SSO-gated share. The actual @@ -34,21 +49,16 @@ export const POST = withRouteHandler( const requestId = generateRequestId() const ip = getClientIp(request) - const ipRateLimit = await rateLimiter.checkRateLimitDirect( - `file-sso:ip:${ip}`, - SSO_IP_RATE_LIMIT - ) - if (!ipRateLimit.allowed) { - logger.warn(`[${requestId}] SSO eligibility rate limit exceeded from ${ip}`) - const response = NextResponse.json( - { error: 'Too many requests. Please try again later.' }, - { status: 429 } - ) - response.headers.set( - 'Retry-After', - String(Math.ceil((ipRateLimit.retryAfterMs ?? SSO_IP_RATE_LIMIT.refillIntervalMs) / 1000)) + if (ip) { + const ipRateLimit = await rateLimiter.checkRateLimitDirect( + `file-sso:ip:${ip}`, + SSO_IP_RATE_LIMIT, + { failClosed: true } ) - return response + if (!ipRateLimit.allowed) { + logger.warn(`[${requestId}] SSO eligibility rate limit exceeded from ${ip}`) + return rateLimited(ipRateLimit.retryAfterMs, SSO_IP_RATE_LIMIT.refillIntervalMs) + } } const parsed = await parseRequest(publicFileSSOContract, request, context) @@ -64,6 +74,18 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'This file is not configured for SSO' }, { status: 400 }) } + const resourceRateLimit = await rateLimiter.checkRateLimitDirect( + `file-sso:resource:${resolved.share.id}`, + SSO_RESOURCE_RATE_LIMIT, + { failClosed: true } + ) + if (!resourceRateLimit.allowed) { + logger.warn(`[${requestId}] SSO eligibility resource rate limit exceeded`, { + shareId: resolved.share.id, + }) + return rateLimited(resourceRateLimit.retryAfterMs, SSO_RESOURCE_RATE_LIMIT.refillIntervalMs) + } + const allowedEmails = Array.isArray(resolved.share.allowedEmails) ? (resolved.share.allowedEmails as string[]) : [] diff --git a/apps/sim/app/api/help/integration-request/route.ts b/apps/sim/app/api/help/integration-request/route.ts index 6a8faf682b6..19084dc1e16 100644 --- a/apps/sim/app/api/help/integration-request/route.ts +++ b/apps/sim/app/api/help/integration-request/route.ts @@ -26,8 +26,14 @@ export const POST = withRouteHandler(async (req: NextRequest) => { try { const ip = getClientIp(req) + if (!ip) { + logger.warn(`[${requestId}] Unable to resolve client IP for public rate limit`) + return NextResponse.json( + { error: 'Too many requests. Please try again later.' }, + { status: 429 } + ) + } const storageKey = `public:integration-request:${ip}` - const { allowed, remaining, resetAt } = await rateLimiter.checkRateLimitDirect( storageKey, PUBLIC_ENDPOINT_RATE_LIMIT diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts index bfa2f27c880..969c620e32a 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts @@ -6,6 +6,7 @@ import { import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes' import { internalKnowledgeAnalytics, + resolveInternalKnowledgeBillingAttribution, toInternalKnowledgeConnector, toInternalKnowledgeConnectorDetail, } from '@/lib/knowledge/api/internal-route' @@ -47,10 +48,12 @@ export const PATCH = defineInternalJsonRoute({ reason: 'Preserve existing internal connector-update behavior', }), errorPolicy: internalKnowledgeErrorPolicies.connectors, - mapInput: ({ params, body }) => ({ + mapInput: ({ params, body }, { principal, request }) => ({ connectorId: params.connectorId, knowledgeBaseId: params.id, updates: body, + resolveBillingAttribution: (workspaceId: string) => + resolveInternalKnowledgeBillingAttribution(request, principal, workspaceId), source: 'ui' as const, }), useCase: updateKnowledgeConnector, diff --git a/apps/sim/app/api/knowledge/connectors/sync/route.test.ts b/apps/sim/app/api/knowledge/connectors/sync/route.test.ts index a26b34469c2..2c8a6c2982b 100644 --- a/apps/sim/app/api/knowledge/connectors/sync/route.test.ts +++ b/apps/sim/app/api/knowledge/connectors/sync/route.test.ts @@ -109,6 +109,22 @@ function whereForUpdate(index: number): unknown { return dbChainMockFns.where.mock.calls[index][0] } +/** + * Position of the update targeting a given table, resolved by table rather than + * hardcoded: the tick runs several updates and a new one inserted between them + * would otherwise silently re-point every later assertion at the wrong chain. + */ +function updateIndexFor(table: unknown): number { + const index = dbChainMockFns.update.mock.calls.findIndex((call) => call[0] === table) + expect(index).toBeGreaterThanOrEqual(0) + return index +} + +/** The sync-log sweep's `.where()` condition, whichever chain it ran as. */ +function syncLogSweepWhere(): unknown { + return whereForUpdate(updateIndexFor(schemaMock.knowledgeConnectorSyncLog)) +} + beforeEach(() => { vi.clearAllMocks() resetDbChainMock() @@ -256,14 +272,14 @@ describe('connector sync scheduler stale-lock reaper', () => { it('closes orphaned sync-log rows still marked started', async () => { await runTickRecovering(['connector-1', 'connector-2']) - expect(dbChainMockFns.update.mock.calls[1][0]).toBe(schemaMock.knowledgeConnectorSyncLog) + const logUpdateIndex = updateIndexFor(schemaMock.knowledgeConnectorSyncLog) - const payload = setPayloadForUpdate(1) + const payload = setPayloadForUpdate(logUpdateIndex) expect(payload.status).toBe('failed') expect(renderedSql(payload.completedAt)).toContain('now()') expect(payload.errorMessage).toBe('Sync timed out (stale lock recovered)') - const where = dbChainMockFns.where.mock.calls[1][0] + const where = syncLogSweepWhere() expect( hasMockCondition( where, @@ -284,7 +300,7 @@ describe('connector sync scheduler stale-lock reaper', () => { /** The `NOT EXISTS` liveness fragment the sweep's WHERE carries. */ function sweepLivenessFragment(): MockSqlFragment { - const where = dbChainMockFns.where.mock.calls[1][0] + const where = syncLogSweepWhere() const fragment = flattenMockConditions(where).find( (node: MockCondition) => typeof node.toSQL === 'function' ) @@ -370,17 +386,56 @@ describe('connector sync scheduler stale-lock reaper', () => { expect(response.status).toBe(200) - const logUpdateIndex = dbChainMockFns.update.mock.calls.findIndex( - (call) => call[0] === schemaMock.knowledgeConnectorSyncLog + expect(setPayloadForUpdate(updateIndexFor(schemaMock.knowledgeConnectorSyncLog)).status).toBe( + 'failed' ) - expect(logUpdateIndex).toBeGreaterThanOrEqual(0) - expect(setPayloadForUpdate(logUpdateIndex).status).toBe('failed') + }) + + it('recovers connectors whose queued sync was never started', async () => { + await runTickRecovering(['connector-1']) + + /** Located by its `status = 'pending'` predicate, not by position in the tick. */ + const pendingIndex = dbChainMockFns.update.mock.calls.findIndex((call, index) => { + if (call[0] !== schemaMock.knowledgeConnector) return false + return hasMockCondition( + whereForUpdate(index), + (node: MockCondition) => + node.type === 'eq' && + node.left === schemaMock.knowledgeConnector.status && + node.right === 'pending' + ) + }) + expect(pendingIndex).toBeGreaterThanOrEqual(0) + + /** + * Ages against the lease, not `updatedAt`: a pending connector is still + * editable, and `updatedAt` moves on every unrelated write, so using it + * would let a config edit defer the recovery indefinitely — the bug the + * lease column was introduced to close for `syncing`. + */ + const pendingCutoff = flattenMockConditions(whereForUpdate(pendingIndex)).find( + (node: MockCondition) => typeof node.toSQL === 'function' + ) as unknown as MockSqlFragment | undefined + expect(pendingCutoff?.toSQL().sql).toBe('? <= ?') + expectLeaseExpression(pendingCutoff?.values[0]) + expect((pendingCutoff?.values[1] as { value: Date }).value).toEqual(EXPECTED_STALE_CUTOFF) + + /** Re-enters the shared failure ladder rather than re-queueing every tick. */ + const payload = setPayloadForUpdate(pendingIndex) + expect(renderedSql(payload.status)).toContain('disabled') + expect(renderedSql(payload.consecutiveFailures)).toBe('COALESCE(?, 0) + 1') + + /** + * Reports a lost hand-off, not a timeout: nothing ran, so the stale-lock + * wording would describe a run that never existed. + */ + expect(asFragment(payload.lastSyncError).values).toContain('Sync was queued but never started') }) it('never scopes the sync-log sweep to a connector id', async () => { await runTickRecovering(['connector-1']) - const where = dbChainMockFns.where.mock.calls[1][0] + const where = syncLogSweepWhere() /** * Checks every position, not just `column`. `eq()` builds `{left, right}` diff --git a/apps/sim/app/api/knowledge/connectors/sync/route.ts b/apps/sim/app/api/knowledge/connectors/sync/route.ts index c008f0fed73..4c98bbaf197 100644 --- a/apps/sim/app/api/knowledge/connectors/sync/route.ts +++ b/apps/sim/app/api/knowledge/connectors/sync/route.ts @@ -32,6 +32,14 @@ const DISPATCH_CONCURRENCY = 10 const STALE_LOCK_ERROR_MESSAGE = 'Sync timed out (stale lock recovered)' +/** + * A connector left `pending` past the TTL — its sync was queued but no worker + * ever took the lock, so the hand-off was lost (the process died between the + * two writes, or the queued run was dropped). Distinct from the stale-lock + * message because nothing timed out: the sync never started. + */ +const LOST_DISPATCH_ERROR_MESSAGE = 'Sync was queued but never started' + /** * How long the connector holding the lock has gone without proving it is alive. * @@ -57,8 +65,8 @@ function syncLockLease(): SQL { * breaker and this SQL breaker cannot drift into two different messages for one * verdict. */ -function reclaimedError(): SQL { - return sql`CASE WHEN COALESCE(${knowledgeConnector.consecutiveFailures}, 0) + 1 >= ${MAX_CONSECUTIVE_FAILURES} THEN ${CONNECTOR_AUTO_DISABLED_ERROR} ELSE ${STALE_LOCK_ERROR_MESSAGE} END` +function reclaimedError(message: string): SQL { + return sql`CASE WHEN COALESCE(${knowledgeConnector.consecutiveFailures}, 0) + 1 >= ${MAX_CONSECUTIVE_FAILURES} THEN ${CONNECTOR_AUTO_DISABLED_ERROR} ELSE ${message} END` } /** @@ -123,6 +131,22 @@ function reclaimedNextSyncAt(): SQL { return sql`CASE WHEN COALESCE(${knowledgeConnector.consecutiveFailures}, 0) + 1 >= ${MAX_CONSECUTIVE_FAILURES} THEN NULL ELSE now() + LEAST((COALESCE(${knowledgeConnector.consecutiveFailures}, 0) + 1) * ${CONNECTOR_FAILURE_BACKOFF_STEP_MINUTES}, ${CONNECTOR_FAILURE_BACKOFF_CAP_MINUTES}) * INTERVAL '1 minute' END` } +/** + * The write shared by both reclaims: a connector that stopped making progress + * re-enters the failure ladder. Factored so the two callers cannot drift into + * different ladders for the same verdict — the same reason + * {@link reclaimedError} takes the message rather than hardcoding it. + */ +function reclaimPayload(message: string) { + return { + status: reclaimedStatus(), + lastSyncError: reclaimedError(message), + nextSyncAt: reclaimedNextSyncAt(), + consecutiveFailures: reclaimedFailureCount(), + updatedAt: sql`now()`, + } +} + /** * Cron endpoint that checks for connectors due for sync and dispatches sync jobs. * Should be called every 5 minutes by an external cron service. @@ -141,29 +165,115 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const staleCutoff = new Date(now.getTime() - CONNECTOR_SYNC_STALE_LOCK_TTL_MS) - const recoveredConnectors = await db - .update(knowledgeConnector) - .set({ - status: reclaimedStatus(), - lastSyncError: reclaimedError(), - nextSyncAt: reclaimedNextSyncAt(), - consecutiveFailures: reclaimedFailureCount(), - // Releases the reclaimed run's ownership token so its terminal write can - // no longer match, even before a replacement takes the lock, and closes - // its lease so a re-locked row starts from a fresh one. - syncLockToken: null, - syncLockLeaseAt: null, - updatedAt: sql`now()`, - }) - .where( - and( - eq(knowledgeConnector.status, 'syncing'), - sql`${syncLockLease()} <= ${sql.param(staleCutoff, knowledgeConnector.syncLockLeaseAt)}`, - isNull(knowledgeConnector.archivedAt), - isNull(knowledgeConnector.deletedAt) + /** + * The three recovery passes target disjoint row sets — a held-but-silent + * lock, a queue entry that never became one, and a sync-log row orphaned by + * a killed run — and none reads another's result, so they go out together + * rather than as three serialized round trips. + * + * `logRowNotHeldByLiveRun` is the one apparent coupling and it is benign: + * it spares a log row only while its connector's lease is still live, and + * every row the lock reclaim targets has an expired lease, so the sweep + * reaches the same verdict against either snapshot. + */ + const [recoveredConnectors, recoveredPendingConnectors, closedSyncLogs] = await Promise.all([ + db + .update(knowledgeConnector) + .set({ + ...reclaimPayload(STALE_LOCK_ERROR_MESSAGE), + /** + * Releases the reclaimed run's ownership token so its terminal write + * can no longer match, even before a replacement takes the lock, and + * closes its lease so a re-locked row starts from a fresh one. + */ + syncLockToken: null, + syncLockLeaseAt: null, + }) + .where( + and( + eq(knowledgeConnector.status, 'syncing'), + sql`${syncLockLease()} <= ${sql.param(staleCutoff, knowledgeConnector.syncLockLeaseAt)}`, + isNull(knowledgeConnector.archivedAt), + isNull(knowledgeConnector.deletedAt) + ) ) - ) - .returning({ id: knowledgeConnector.id }) + .returning({ id: knowledgeConnector.id }), + /** + * Recovers connectors whose queued sync was never picked up. + * + * `pending` is written just before the hand-off to the queue, so a row that + * is still `pending` past the TTL means no worker ever took the lock: the + * process died between the two writes, or the queued run was dropped. Left + * alone the connector would sit `pending` forever — the stale-lock reclaim + * above only looks at `syncing` rows, and the due-sweep below only at + * `active`/`error`. + * + * Flipped to `error` rather than straight back to `active` so it re-enters + * through the same failure ladder as any other unsuccessful sync: repeated + * lost dispatches back off and eventually disable, instead of re-queueing + * every tick forever. + * + * Ages against {@link syncLockLease}, the same expression the stale-lock + * pass reads, because `markSyncPending` opens the lease when it queues. + * `updatedAt` would be wrong here for exactly the reason the lease column + * exists: a `pending` connector is still editable, so every unrelated write + * to the row would renew the recovery it is meant to trigger — a config + * edit on a stuck connector could defer it forever. + */ + db + .update(knowledgeConnector) + .set({ + ...reclaimPayload(LOST_DISPATCH_ERROR_MESSAGE), + /** Releases the queue entry's token so a late hand-off cannot match it. */ + syncLockToken: null, + syncLockLeaseAt: null, + }) + .where( + and( + eq(knowledgeConnector.status, 'pending'), + sql`${syncLockLease()} <= ${sql.param(staleCutoff, knowledgeConnector.syncLockLeaseAt)}`, + isNull(knowledgeConnector.archivedAt), + isNull(knowledgeConnector.deletedAt) + ) + ) + .returning({ id: knowledgeConnector.id }), + /** + * Closes sync-log rows left `started` by a killed run. Nothing else ever + * reconciles them, and `loadPreviousListingObservation` reads only + * `completed` rows, so a never-closed run silently ages out the previous + * observation it should have provided. + * + * Deliberately independent of this tick's reclaims rather than scoped to + * them. A row orphaned before this shipped — or by a transient failure of + * this very statement — belongs to a connector already flipped out of + * `syncing`, so it would never appear in a future reclaim batch and would + * stay stranded forever. Keying off the row's own `startedAt` instead makes + * the sweep self-healing and lets it drain the existing backlog. + * + * Age alone does not prove a run is dead: the in-process fallback path has + * no duration cap, so a large self-hosted sync can genuinely still be + * working past the TTL. `logRowNotHeldByLiveRun` is what makes this safe — + * a run whose lock is still being heartbeated is spared regardless of age. + * The age predicate is also per-row on `startedAt`, so a fresh run's log row + * can never be caught by it, even on a connector whose previous run is being + * reclaimed in this same tick. + */ + db + .update(knowledgeConnectorSyncLog) + .set({ + status: 'failed', + completedAt: sql`now()`, + errorMessage: STALE_LOCK_ERROR_MESSAGE, + }) + .where( + and( + eq(knowledgeConnectorSyncLog.status, 'started'), + lte(knowledgeConnectorSyncLog.startedAt, staleCutoff), + logRowNotHeldByLiveRun(staleCutoff) + ) + ) + .returning({ id: knowledgeConnectorSyncLog.id }), + ]) if (recoveredConnectors.length > 0) { logger.warn( @@ -172,42 +282,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) } - /** - * Closes sync-log rows left `started` by a killed run. Nothing else ever - * reconciles them, and `loadPreviousListingObservation` reads only - * `completed` rows, so a never-closed run silently ages out the previous - * observation it should have provided. - * - * Deliberately independent of this tick's reclaims rather than scoped to - * them. A row orphaned before this shipped — or by a transient failure of - * this very statement — belongs to a connector already flipped out of - * `syncing`, so it would never appear in a future reclaim batch and would - * stay stranded forever. Keying off the row's own `startedAt` instead makes - * the sweep self-healing and lets it drain the existing backlog. - * - * Age alone does not prove a run is dead: the in-process fallback path has - * no duration cap, so a large self-hosted sync can genuinely still be - * working past the TTL. `logRowNotHeldByLiveRun` is what makes this safe — - * a run whose lock is still being heartbeated is spared regardless of age. - * The age predicate is also per-row on `startedAt`, so a fresh run's log row - * can never be caught by it, even on a connector whose previous run is being - * reclaimed in this same tick. - */ - const closedSyncLogs = await db - .update(knowledgeConnectorSyncLog) - .set({ - status: 'failed', - completedAt: sql`now()`, - errorMessage: STALE_LOCK_ERROR_MESSAGE, - }) - .where( - and( - eq(knowledgeConnectorSyncLog.status, 'started'), - lte(knowledgeConnectorSyncLog.startedAt, staleCutoff), - logRowNotHeldByLiveRun(staleCutoff) - ) + if (recoveredPendingConnectors.length > 0) { + logger.warn( + `[${requestId}] Recovered ${recoveredPendingConnectors.length} connectors whose queued sync was never started`, + { ids: recoveredPendingConnectors.map((c) => c.id) } ) - .returning({ id: knowledgeConnectorSyncLog.id }) + } if (closedSyncLogs.length > 0) { logger.warn(`[${requestId}] Closed ${closedSyncLogs.length} orphaned connector sync log(s)`) @@ -216,6 +296,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const dueConnectors = await db .select({ id: knowledgeConnector.id, + nextSyncAt: knowledgeConnector.nextSyncAt, workspaceId: knowledgeBase.workspaceId, }) .from(knowledgeConnector) @@ -248,7 +329,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { throw new Error(`Connector ${connector.id} is missing workspace billing context`) } const billingAttribution = await resolveSystemBillingAttribution(connector.workspaceId) - await dispatchSync(connector.id, { billingAttribution, requestId }) + await dispatchSync(connector.id, { + billingAttribution, + expectedNextSyncAt: connector.nextSyncAt ?? undefined, + requestId, + requireRunnable: true, + }) } catch (error) { logger.error(`[${requestId}] Failed to dispatch sync for connector ${connector.id}`, error) } diff --git a/apps/sim/app/api/logs/export/route.ts b/apps/sim/app/api/logs/export/route.ts index 0d8ebc032a5..781aaaf7c3a 100644 --- a/apps/sim/app/api/logs/export/route.ts +++ b/apps/sim/app/api/logs/export/route.ts @@ -6,7 +6,7 @@ import { and, desc, eq, 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' -import { neutralizeCsvFormula } from '@/lib/core/utils/csv' +import { formatCsvValue, toCsvRow } from '@/lib/core/utils/csv' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { materializeExecutionDataForDisplay } from '@/lib/logs/execution/trace-store' import { buildFilterConditions, LogFilterParamsSchema } from '@/lib/logs/filters' @@ -17,15 +17,6 @@ const logger = createLogger('LogsExportAPI') export const revalidate = 0 -function escapeCsv(value: any): string { - if (value === null || value === undefined) return '' - const str = typeof value === 'string' ? neutralizeCsvFormula(value) : String(value) - if (/[",\n]/.test(str)) { - return `"${str.replace(/"/g, '""')}"` - } - return str -} - export const GET = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() @@ -61,7 +52,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ? and(workspaceCondition, filterConditions) : workspaceCondition - const header = [ + const header = toCsvRow([ 'startedAt', 'level', 'workflow', @@ -72,7 +63,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { 'executionId', 'message', 'traceSpans', - ].join(',') + ]) const access = await checkWorkspaceAccess(params.workspaceId, userId) if (!access.hasAccess) { @@ -147,18 +138,18 @@ export const GET = withRouteHandler(async (request: NextRequest) => { error: getErrorMessage(rowError), }) } - const line = [ - escapeCsv(r.startedAt?.toISOString?.() || r.startedAt), - escapeCsv(r.level), - escapeCsv(r.workflowName), - escapeCsv(r.trigger), - escapeCsv(r.totalDurationMs ?? ''), - escapeCsv(r.costTotal ?? ''), - escapeCsv(r.workflowId ?? ''), - escapeCsv(r.executionId ?? ''), - escapeCsv(message), - escapeCsv(tracesJson), - ].join(',') + 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`)) } diff --git a/apps/sim/app/api/secrets/references/route.test.ts b/apps/sim/app/api/secrets/references/route.test.ts new file mode 100644 index 00000000000..b15871f789a --- /dev/null +++ b/apps/sim/app/api/secrets/references/route.test.ts @@ -0,0 +1,122 @@ +/** + * @vitest-environment node + */ +import { authMockFns, createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ listReferences: vi.fn() })) + +vi.mock('@/lib/secrets/application/use-cases', () => ({ + listSecretReferencesUseCase: { + operation: { id: 'secrets.references' }, + execute: mocks.listReferences, + }, +})) + +import { GET } from '@/app/api/secrets/references/route' + +const url = 'http://localhost/api/secrets/references?workspaceId=workspace-1&name=API_KEY' + +describe('GET /api/secrets/references', () => { + beforeEach(() => { + vi.clearAllMocks() + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'admin-1' }, + session: { id: 'session-1' }, + }) + }) + + it('returns the workflows, blocks, and resources a secret is wired into', async () => { + mocks.listReferences.mockResolvedValue({ + workflows: [ + { + workflowId: 'workflow-1', + workflowName: 'Nightly sync', + blocks: [ + { blockId: 'block-1', blockName: 'Fetch orders', blockType: 'api', field: 'apiKey' }, + ], + }, + ], + resources: [{ id: 'tool-1', kind: 'custom-tool', name: 'Order lookup', field: 'code' }], + truncated: false, + }) + + const response = await GET(createMockRequest('GET', undefined, {}, url)) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + workflows: [ + { + workflowId: 'workflow-1', + workflowName: 'Nightly sync', + blocks: [ + { blockId: 'block-1', blockName: 'Fetch orders', blockType: 'api', field: 'apiKey' }, + ], + }, + ], + resources: [{ id: 'tool-1', kind: 'custom-tool', name: 'Order lookup', field: 'code' }], + truncated: false, + }) + }) + + it('returns empty lists for a secret referenced nowhere', async () => { + mocks.listReferences.mockResolvedValue({ workflows: [], resources: [], truncated: false }) + + const response = await GET(createMockRequest('GET', undefined, {}, url)) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ workflows: [], resources: [], truncated: false }) + }) + + it('rejects a request that names no secret', async () => { + const response = await GET( + createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost/api/secrets/references?workspaceId=workspace-1' + ) + ) + + expect(response.status).toBe(400) + expect(mocks.listReferences).not.toHaveBeenCalled() + }) + + /** + * The contract carries no `scope`. It used to, and because a reference scan is name-based and + * never narrowed by scope, asserting `personal` skipped the admin gate outright — a member + * could read the reference map for any workspace secret. A stray `scope` must therefore reach + * neither the gate nor the scan. + */ + it('ignores a scope the caller tries to assert', async () => { + mocks.listReferences.mockResolvedValue({ workflows: [], resources: [], truncated: false }) + + const response = await GET(createMockRequest('GET', undefined, {}, `${url}&scope=personal`)) + + expect(response.status).toBe(200) + expect(mocks.listReferences).toHaveBeenCalledTimes(1) + expect(mocks.listReferences.mock.calls[0]?.[0]?.input).toEqual({ + workspaceId: 'workspace-1', + name: 'API_KEY', + }) + }) + + /** + * The use case gates the read behind the same permission that reveals the value. A refusal + * has to reach the client as a refusal — surfacing it as an empty list would read as + * "referenced nowhere" and invite deleting a live key. + */ + it('surfaces the use case refusal rather than an empty list', async () => { + const { ForbiddenOperationError } = await import('@/lib/core/application/forbidden') + mocks.listReferences.mockRejectedValue( + new ForbiddenOperationError( + 'SECRET_ADMIN_ACCESS_REQUIRED', + 'Credential admin permission required to view this secret usage' + ) + ) + + const response = await GET(createMockRequest('GET', undefined, {}, url)) + + expect(response.status).toBe(403) + }) +}) diff --git a/apps/sim/app/api/secrets/references/route.ts b/apps/sim/app/api/secrets/references/route.ts new file mode 100644 index 00000000000..24cf45f638e --- /dev/null +++ b/apps/sim/app/api/secrets/references/route.ts @@ -0,0 +1,25 @@ +import { getSecretReferencesContract } from '@/lib/api/contracts/secrets' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { secretOperations } from '@/lib/secrets/application/operations' +import { listSecretReferencesUseCase } from '@/lib/secrets/application/use-cases' + +/** GET /api/secrets/references — where one secret is wired in, for the credential detail panel. */ +export const GET = defineInternalJsonRoute({ + contract: getSecretReferencesContract, + auth: internalSessionAuth, + operation: secretOperations.references, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal behavior' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ query }) => ({ + workspaceId: query.workspaceId, + name: query.name, + }), + useCase: listSecretReferencesUseCase, + /** The scan's shape is already the wire shape — nothing to project or serialize. */ + present: (scan) => scan, +}) diff --git a/apps/sim/app/api/table/[tableId]/export/route.ts b/apps/sim/app/api/table/[tableId]/export/route.ts index 17a845ed0ae..6cc9a9d50c5 100644 --- a/apps/sim/app/api/table/[tableId]/export/route.ts +++ b/apps/sim/app/api/table/[tableId]/export/route.ts @@ -6,11 +6,8 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' -import { - createTableExportStream, - exportContentType, - sanitizeExportFilename, -} from '@/lib/table/export-stream' +import { sanitizeExportFilename } from '@/lib/table/export-format' +import { createTableExportStream, exportContentType } from '@/lib/table/export-stream' import { accessError, checkAccess } from '@/app/api/table/utils' interface RouteParams { diff --git a/apps/sim/app/api/table/[tableId]/query/route.test.ts b/apps/sim/app/api/table/[tableId]/query/route.test.ts index 9d58e383f7e..bc758cfea01 100644 --- a/apps/sim/app/api/table/[tableId]/query/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/query/route.test.ts @@ -125,6 +125,119 @@ describe('POST /api/table/[tableId]/query', () => { expect(options.withExecutions).toBe(false) }) + it('selects a stable column id and returns its current name to a workflow', async () => { + authAs('internal_jwt') + mockCheckAccess.mockResolvedValue({ + ok: true, + table: createTableDefinition({ + columns: [ + { id: 'col_aaa', name: 'renamed_name', type: 'string' }, + { id: 'col_bbb', name: 'wins', type: 'number' }, + ], + maxRows: 100, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + }), + }) + mockQueryRows.mockResolvedValue({ + ...EMPTY_RESULT, + rows: [ + { + id: 'row_1', + data: { col_aaa: 'Ana' }, + executions: {}, + position: 1, + orderKey: 'a0', + createdAt: new Date('2026-08-20T10:00:00.000Z'), + updatedAt: new Date('2026-08-20T10:00:00.000Z'), + }, + ], + rowCount: 1, + totalCount: 1, + limit: 100, + }) + + const res = await callQuery({ workspaceId: 'workspace-1', columns: ['col_aaa'] }) + + expect(res.status).toBe(200) + // The service projects (so the byte budget measures the response); the route only resolves ids. + expect(mockQueryRows.mock.calls[0][1].columnIds).toEqual(new Set(['col_aaa'])) + expect((await res.json()).data.rows[0].data).toEqual({ renamed_name: 'Ana' }) + }) + + it('accepts an exact column name for direct callers', async () => { + authAs('internal_jwt') + mockQueryRows.mockResolvedValue({ + ...EMPTY_RESULT, + rows: [ + { + id: 'row_1', + data: { col_bbb: 12 }, + executions: {}, + position: 1, + orderKey: 'a0', + createdAt: new Date('2026-08-20T10:00:00.000Z'), + updatedAt: new Date('2026-08-20T10:00:00.000Z'), + }, + ], + rowCount: 1, + totalCount: 1, + limit: 100, + }) + + const res = await callQuery({ workspaceId: 'workspace-1', columns: ['wins'] }) + + expect(res.status).toBe(200) + expect(mockQueryRows.mock.calls[0][1].columnIds).toEqual(new Set(['col_bbb'])) + expect((await res.json()).data.rows[0].data).toEqual({ wins: 12 }) + }) + + it('asks for every column when the selection is omitted or empty', async () => { + authAs('internal_jwt') + + const omitted = await callQuery({ workspaceId: 'workspace-1' }) + const empty = await callQuery({ workspaceId: 'workspace-1', columns: [] }) + + expect(omitted.status).toBe(200) + expect(empty.status).toBe(200) + expect(mockQueryRows.mock.calls[0][1].columnIds).toBeUndefined() + expect(mockQueryRows.mock.calls[1][1].columnIds).toBeUndefined() + }) + + it('drops a column reference that no longer exists without exposing diagnostics', async () => { + authAs('internal_jwt') + const staleId = `col_${'0'.repeat(32)}` + + const res = await callQuery({ + workspaceId: 'workspace-1', + columns: ['col_aaa', 'missing', staleId], + }) + + expect(res.status).toBe(200) + expect(mockQueryRows.mock.calls[0][1].columnIds).toEqual(new Set(['col_aaa'])) + expect((await res.json()).data).not.toHaveProperty('ignoredColumns') + }) + + it('returns empty row data, not every column, when no requested column exists', async () => { + authAs('internal_jwt') + + const res = await callQuery({ workspaceId: 'workspace-1', columns: ['missing'] }) + + expect(res.status).toBe(200) + expect(mockQueryRows.mock.calls[0][1].columnIds).toEqual(new Set()) + expect((await res.json()).data).not.toHaveProperty('ignoredColumns') + }) + + it('does not expose ignored-column diagnostics for valid or omitted selections', async () => { + authAs('internal_jwt') + + const selected = await callQuery({ workspaceId: 'workspace-1', columns: ['col_aaa'] }) + const all = await callQuery({ workspaceId: 'workspace-1' }) + + expect((await selected.json()).data).not.toHaveProperty('ignoredColumns') + expect((await all.json()).data).not.toHaveProperty('ignoredColumns') + }) + it('accepts a root condition and executes its canonical all group', async () => { authAs('internal_jwt') const res = await callQuery({ diff --git a/apps/sim/app/api/table/[tableId]/query/route.ts b/apps/sim/app/api/table/[tableId]/query/route.ts index ebfb0507504..cd526853377 100644 --- a/apps/sim/app/api/table/[tableId]/query/route.ts +++ b/apps/sim/app/api/table/[tableId]/query/route.ts @@ -7,7 +7,12 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { Sort, TableSchema } from '@/lib/table' -import { buildIdByName, sortSpecNamesToIds } from '@/lib/table/column-keys' +import { + buildIdByName, + columnMatchesRef, + getColumnId, + sortSpecNamesToIds, +} from '@/lib/table/column-keys' import { TableQueryValidationError } from '@/lib/table/errors' import { validatePredicate, validateSortSpec } from '@/lib/table/query-builder/validate' import { assertCursorQueryBinding, decodeCursor } from '@/lib/table/rows/cursor' @@ -63,6 +68,27 @@ export const POST = withRouteHandler(async (request: NextRequest, context: RowQu const schema = table.schema as TableSchema const wire = rowWireTranslators(authResult.authType, schema) const cursor = body.cursor ? decodeCursor(body.cursor) : undefined + /** + * A reference that matches no column is dropped, not rejected: a workflow + * whose picked column was since deleted keeps running and simply gets the + * columns that still exist (the editor shows the orphaned id so it can be + * cleared). Skipped references are logged for server-side diagnostics. + */ + let selectedColumnIds: Set | undefined + const ignoredColumns: string[] = [] + if (body.columns?.length) { + selectedColumnIds = new Set() + for (const reference of body.columns) { + const column = schema.columns.find((candidate) => columnMatchesRef(candidate, reference)) + if (column) selectedColumnIds.add(getColumnId(column)) + else ignoredColumns.push(reference) + } + if (ignoredColumns.length > 0) { + logger.warn( + `[${requestId}] Ignoring output columns not on table ${tableId}: ${ignoredColumns.join(', ')}` + ) + } + } // Predicate/sort fields are column-NAME-keyed by construction (the caller // authors names), so validate against the schema then translate names → @@ -99,6 +125,8 @@ export const POST = withRouteHandler(async (request: NextRequest, context: RowQu // Executions are grid UI state; the v2 surface returns row data only // and the byte budget deliberately measures just `data`. withExecutions: false, + // Projected inside the drain so the byte budget measures the response. + columnIds: selectedColumnIds, }, requestId ) diff --git a/apps/sim/app/api/tools/agiloft/attach/route.ts b/apps/sim/app/api/tools/agiloft/attach/route.ts index 014e34eb271..a9f0ce22ac6 100644 --- a/apps/sim/app/api/tools/agiloft/attach/route.ts +++ b/apps/sim/app/api/tools/agiloft/attach/route.ts @@ -6,7 +6,9 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithPinnedIP } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' import type { RawFileInput } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -75,13 +77,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => { let fileBuffer: Buffer try { - const servable = await downloadServableFileFromStorage(userFile, requestId, logger) + const servable = await downloadServableFileFromStorage(userFile, requestId, logger, { + maxBytes: MAX_BUFFERED_TRANSFER_BYTES, + }) fileBuffer = servable.buffer } catch (error) { const notReady = docNotReadyResponse(error) if (notReady) return notReady logger.error(`[${requestId}] Failed to download file from storage:`, error) - return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 }) + return NextResponse.json( + { success: false, error: toError(error).message }, + { status: isPayloadSizeLimitError(error) ? 413 : 500 } + ) } const resolvedFileName = data.fileName || userFile.name || 'attachment' diff --git a/apps/sim/app/api/tools/box/upload/route.ts b/apps/sim/app/api/tools/box/upload/route.ts index 57ec5f4223c..f490fdc02a9 100644 --- a/apps/sim/app/api/tools/box/upload/route.ts +++ b/apps/sim/app/api/tools/box/upload/route.ts @@ -5,7 +5,9 @@ import { boxUploadContract } from '@/lib/api/contracts/storage-transfer' import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils' import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response' @@ -55,14 +57,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) if (denied) return denied try { - const result = await downloadServableFileFromStorage(userFile, requestId, logger) + const result = await downloadServableFileFromStorage(userFile, requestId, logger, { + maxBytes: MAX_BUFFERED_TRANSFER_BYTES, + }) fileBuffer = result.buffer } catch (error) { const notReady = docNotReadyResponse(error) if (notReady) return notReady return NextResponse.json( { success: false, error: getErrorMessage(error, 'Failed to download file') }, - { status: 500 } + { status: isPayloadSizeLimitError(error) ? 413 : 500 } ) } fileName = validatedData.fileName || userFile.name diff --git a/apps/sim/app/api/tools/brex/upload-receipt/route.test.ts b/apps/sim/app/api/tools/brex/upload-receipt/route.test.ts index eae6759b6cb..1702e54cc86 100644 --- a/apps/sim/app/api/tools/brex/upload-receipt/route.test.ts +++ b/apps/sim/app/api/tools/brex/upload-receipt/route.test.ts @@ -27,6 +27,7 @@ vi.mock('@/app/api/files/authorization', () => ({ assertToolFileAccess: mockAssertToolFileAccess, })) +import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { POST } from '@/app/api/tools/brex/upload-receipt/route' const mockFetch = vi.fn() @@ -194,11 +195,25 @@ describe('POST /api/tools/brex/upload-receipt', () => { expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled() }) + it('asks the downloader for at most 50 MB', async () => { + await POST(createMockRequest('POST', baseBody)) + + expect(mockDownloadFileFromStorage).toHaveBeenCalledWith( + expect.anything(), + expect.any(String), + expect.anything(), + { maxBytes: 50 * 1024 * 1024 } + ) + }) + it('rejects files over the 50 MB limit', async () => { - mockDownloadFileFromStorage.mockResolvedValueOnce({ - buffer: Buffer.alloc(50 * 1024 * 1024 + 1), - contentType: 'application/pdf', - }) + mockDownloadFileFromStorage.mockRejectedValueOnce( + new PayloadSizeLimitError({ + label: 'storage file download', + maxBytes: 50 * 1024 * 1024, + observedBytes: 50 * 1024 * 1024 + 1, + }) + ) const response = await POST(createMockRequest('POST', baseBody)) expect(response.status).toBe(400) diff --git a/apps/sim/app/api/tools/brex/upload-receipt/route.ts b/apps/sim/app/api/tools/brex/upload-receipt/route.ts index 6fb4ccfecf8..c63bb4374b7 100644 --- a/apps/sim/app/api/tools/brex/upload-receipt/route.ts +++ b/apps/sim/app/api/tools/brex/upload-receipt/route.ts @@ -9,6 +9,7 @@ import { validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils' import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -51,23 +52,25 @@ export const POST = withRouteHandler(async (request: NextRequest) => { let fileBuffer: Buffer try { - const resolved = await downloadServableFileFromStorage(userFile, requestId, logger) + const resolved = await downloadServableFileFromStorage(userFile, requestId, logger, { + maxBytes: MAX_RECEIPT_SIZE_BYTES, + }) fileBuffer = resolved.buffer } catch (error) { const notReady = docNotReadyResponse(error) if (notReady) return notReady + if (isPayloadSizeLimitError(error)) { + return NextResponse.json( + { success: false, error: 'Receipt file exceeds the 50 MB limit' }, + { status: 400 } + ) + } logger.error(`[${requestId}] Failed to download receipt file:`, error) return NextResponse.json( { success: false, error: getErrorMessage(error, 'Unknown error') }, { status: 500 } ) } - if (fileBuffer.length > MAX_RECEIPT_SIZE_BYTES) { - return NextResponse.json( - { success: false, error: 'Receipt file exceeds the 50 MB limit' }, - { status: 400 } - ) - } const effectiveReceiptName = receiptName || userFile.name const endpoint = expenseId diff --git a/apps/sim/app/api/tools/cloudwatch/put-metric-data/retry-semantics.test.ts b/apps/sim/app/api/tools/cloudwatch/put-metric-data/retry-semantics.test.ts new file mode 100644 index 00000000000..5926af4a066 --- /dev/null +++ b/apps/sim/app/api/tools/cloudwatch/put-metric-data/retry-semantics.test.ts @@ -0,0 +1,73 @@ +/** + * Pins what `maxAttempts` *means*, using the real AWS SDK. + * + * `route.test.ts` asserts the route configures `maxAttempts: 1`; on its own that + * only pins a number. This file counts how many datapoints a real + * `CloudWatchClient` actually hands to a peer that accepts the request and then + * dies before writing a response byte -- the ambiguous transport failure the + * SDK's retry layer replays, and the one that makes CloudWatch aggregate a + * duplicate. It fails if a future SDK bump changes the default budget or stops + * honouring the pin. + * + * Deliberately not mocking `@aws-sdk/client-cloudwatch` here: the SDK's retry + * middleware is the subject under test, so it must be the real one. + * + * @vitest-environment node + */ +import http from 'node:http' +import type { AddressInfo } from 'node:net' +import { CloudWatchClient, PutMetricDataCommand } from '@aws-sdk/client-cloudwatch' +import { describe, expect, it } from 'vitest' + +/** `@smithy/util-retry`'s `DEFAULT_MAX_ATTEMPTS`, which every unpinned client inherits. */ +const SDK_DEFAULT_MAX_ATTEMPTS = 3 + +async function countDeliveries(clientConfig: Record): Promise { + let received = 0 + const server = http.createServer((req, res) => { + req.on('data', () => {}) + req.on('end', () => { + if (String(req.headers['x-amz-target'] ?? '').endsWith('PutMetricData')) { + received++ + req.socket.destroy() + return + } + res.writeHead(200, { 'content-type': 'application/x-amz-json-1.0' }) + res.end('{}') + }) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() as AddressInfo + + const client = new CloudWatchClient({ + region: 'us-east-1', + endpoint: `http://127.0.0.1:${port}`, + credentials: { accessKeyId: 'AKIAEXAMPLE', secretAccessKey: 'secret' }, + ...clientConfig, + }) + + try { + await client.send( + new PutMetricDataCommand({ + Namespace: 'Sim/Test', + MetricData: [{ MetricName: 'Requests', Value: 1 }], + }) + ) + } catch { + /* Every attempt fails by design; the delivery count is the assertion. */ + } finally { + client.destroy() + await new Promise((resolve) => server.close(() => resolve())) + } + return received +} + +describe('aws sdk retry semantics for PutMetricData', () => { + it('delivers the datapoint exactly once when maxAttempts is pinned to 1', async () => { + await expect(countDeliveries({ maxAttempts: 1 })).resolves.toBe(1) + }) + + it('aggregates a duplicate for every retry the default budget allows', async () => { + await expect(countDeliveries({})).resolves.toBe(SDK_DEFAULT_MAX_ATTEMPTS) + }) +}) diff --git a/apps/sim/app/api/tools/cloudwatch/put-metric-data/route.test.ts b/apps/sim/app/api/tools/cloudwatch/put-metric-data/route.test.ts new file mode 100644 index 00000000000..080d0d30b7f --- /dev/null +++ b/apps/sim/app/api/tools/cloudwatch/put-metric-data/route.test.ts @@ -0,0 +1,65 @@ +/** + * @vitest-environment node + */ +import { createMockRequest, hybridAuthMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockSend, mockDestroy, capturedConfigs } = vi.hoisted(() => ({ + mockSend: vi.fn(), + mockDestroy: vi.fn(), + capturedConfigs: [] as Record[], +})) + +vi.mock('@aws-sdk/client-cloudwatch', () => ({ + CloudWatchClient: class { + constructor(config: Record) { + capturedConfigs.push(config) + } + send = mockSend + destroy = mockDestroy + }, + PutMetricDataCommand: class { + constructor(readonly input: Record) {} + }, +})) + +import { POST } from '@/app/api/tools/cloudwatch/put-metric-data/route' + +const body = { + region: 'us-east-1', + accessKeyId: 'AKIAEXAMPLE', + secretAccessKey: 'secret', + namespace: 'Sim/Test', + metricName: 'Requests', + value: 1, +} + +function postRoute() { + return POST(createMockRequest('POST', body)) +} + +describe('cloudwatch put-metric-data delivery class', () => { + beforeEach(() => { + vi.clearAllMocks() + capturedConfigs.length = 0 + hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({ success: true, userId: 'user-1' }) + mockSend.mockResolvedValue({}) + }) + + it('pins the client to a single attempt so the SDK cannot replay the datapoint', async () => { + const response = await postRoute() + + expect(response.status).toBe(200) + expect(capturedConfigs).toHaveLength(1) + expect(capturedConfigs[0].maxAttempts).toBe(1) + }) + + it('never lets an ambiguous failure turn into a second PutMetricData', async () => { + mockSend.mockRejectedValue(Object.assign(new Error('socket hang up'), { name: 'TimeoutError' })) + + const response = await postRoute() + + expect(response.status).toBe(500) + expect(mockSend).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/sim/app/api/tools/cloudwatch/put-metric-data/route.ts b/apps/sim/app/api/tools/cloudwatch/put-metric-data/route.ts index 5cf71338abe..0c304e50804 100644 --- a/apps/sim/app/api/tools/cloudwatch/put-metric-data/route.ts +++ b/apps/sim/app/api/tools/cloudwatch/put-metric-data/route.ts @@ -9,10 +9,38 @@ import { type NextRequest, NextResponse } from 'next/server' import { awsCloudwatchPutMetricDataContract } from '@/lib/api/contracts/tools/aws/cloudwatch-put-metric-data' import { parseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' +import type { DeliveryDeclaration } from '@/lib/core/http/classes' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CloudWatchPutMetricData') +/** + * PutMetricData is additive, not last-write-wins: CloudWatch folds every + * datapoint it receives for a (namespace, metric, dimensions, timestamp) tuple + * into the same statistic set. Two deliveries of one user call therefore + * publish `SampleCount=2, Sum=2 x value` instead of `SampleCount=1, Sum=value`, + * silently doubling the customer's series and any alarm threshold read off it. + * Nothing on the wire distinguishes this from a correct write, and the datapoint + * cannot be retracted -- CloudWatch has no delete-datapoint API. + */ +const PUT_METRIC_DATA_DELIVERY = { + deliveryClass: 'once', + why: "the customer's metric series would double-count this datapoint, skewing every statistic and alarm derived from it", + userVisibleEffect: + 'a datapoint with SampleCount=2 and Sum=2x the reported value, permanently, with no way to retract it', +} satisfies DeliveryDeclaration + +/** + * The AWS SDK's own retry layer replays a request whenever the transport fails + * ambiguously (ECONNRESET, socket hangup, 500/502/503/504) -- exactly the cases + * where the peer may already have committed. `@smithy/util-retry` defaults to + * `DEFAULT_MAX_ATTEMPTS = 3`, so an unpinned client turns one severed socket + * into three aggregated datapoints. Pinning to 1 trades a lost datapoint for a + * correct series, which is the right trade for an additive metric: a gap is + * visible and self-healing, a doubled value is neither. + */ +const NON_IDEMPOTENT_MAX_ATTEMPTS = 1 + export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) @@ -31,6 +59,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const client = new CloudWatchClient({ region: validatedData.region, + maxAttempts: NON_IDEMPOTENT_MAX_ATTEMPTS, credentials: { accessKeyId: validatedData.accessKeyId, secretAccessKey: validatedData.secretAccessKey, @@ -80,7 +109,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { client.destroy() } } catch (error) { - logger.error('PutMetricData failed', { error: toError(error).message }) + logger.error('PutMetricData failed', { + error: toError(error).message, + deliveryClass: PUT_METRIC_DATA_DELIVERY.deliveryClass, + outcome: 'indeterminate', + duplicateEffect: PUT_METRIC_DATA_DELIVERY.userVisibleEffect, + }) return NextResponse.json( { error: `Failed to publish CloudWatch metric: ${toError(error).message}` }, { status: 500 } diff --git a/apps/sim/app/api/tools/confluence/upload-attachment/route.ts b/apps/sim/app/api/tools/confluence/upload-attachment/route.ts index 00057e4d10c..8e0b14f5d4f 100644 --- a/apps/sim/app/api/tools/confluence/upload-attachment/route.ts +++ b/apps/sim/app/api/tools/confluence/upload-attachment/route.ts @@ -5,7 +5,9 @@ import { confluenceUploadAttachmentContract } from '@/lib/api/contracts/selector import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' import { processSingleFileToUserFile, type RawFileInput } from '@/lib/uploads/utils/file-utils' import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response' @@ -94,7 +96,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { let fileBuffer: Buffer let resolvedContentType: string try { - const servable = await downloadServableFileFromStorage(userFile, 'confluence-upload', logger) + const servable = await downloadServableFileFromStorage( + userFile, + 'confluence-upload', + logger, + { + maxBytes: MAX_BUFFERED_TRANSFER_BYTES, + } + ) fileBuffer = servable.buffer resolvedContentType = servable.contentType } catch (error) { @@ -105,7 +114,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { error: `Failed to download file: ${getErrorMessage(error, 'Unknown error')}`, }, - { status: 500 } + { status: isPayloadSizeLimitError(error) ? 413 : 500 } ) } diff --git a/apps/sim/app/api/tools/daytona/upload/route.ts b/apps/sim/app/api/tools/daytona/upload/route.ts index 5c52509bb75..800649f0f4d 100644 --- a/apps/sim/app/api/tools/daytona/upload/route.ts +++ b/apps/sim/app/api/tools/daytona/upload/route.ts @@ -5,6 +5,7 @@ import { daytonaUploadFileContract } from '@/lib/api/contracts/tools/daytona' import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils' import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -62,11 +63,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { logger.info(`[${requestId}] Downloading file: ${userFile.name} (${userFile.size} bytes)`) try { - const servable = await downloadServableFileFromStorage(userFile, requestId, logger) + const servable = await downloadServableFileFromStorage(userFile, requestId, logger, { + maxBytes: MAX_UPLOAD_SIZE_BYTES, + }) fileBuffer = servable.buffer } catch (error) { const notReady = docNotReadyResponse(error) if (notReady) return notReady + if (isPayloadSizeLimitError(error)) { + return NextResponse.json( + { success: false, error: 'File exceeds upload limit of 100MB' }, + { status: 400 } + ) + } logger.error(`[${requestId}] Failed to download file from storage:`, error) return NextResponse.json( { success: false, error: getErrorMessage(error, 'Failed to download file') }, diff --git a/apps/sim/app/api/tools/discord/send-message/route.ts b/apps/sim/app/api/tools/discord/send-message/route.ts index aa644457654..bc9d30d526a 100644 --- a/apps/sim/app/api/tools/discord/send-message/route.ts +++ b/apps/sim/app/api/tools/discord/send-message/route.ts @@ -6,9 +6,11 @@ import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateNumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' -import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { downloadServableFilesWithinBudget } from '@/lib/uploads/utils/file-utils.server' import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response' import { assertToolFileAccess } from '@/app/api/files/authorization' @@ -146,12 +148,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { let resolved: Array<{ buffer: Buffer; contentType: string }> try { - resolved = await Promise.all( - userFiles.map(async (file, i) => { - logger.info(`[${requestId}] Downloading file ${i}: ${file.name}`) - return await downloadServableFileFromStorage(file, requestId, logger) - }) - ) + resolved = await downloadServableFilesWithinBudget(userFiles, requestId, logger, { + totalMaxBytes: MAX_BUFFERED_TRANSFER_BYTES, + label: 'Total attachment size', + }) } catch (error) { const notReady = docNotReadyResponse(error) if (notReady) return notReady @@ -161,7 +161,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { success: false, error: `Failed to download attachment: ${getErrorMessage(error, 'Unknown error')}`, }, - { status: 500 } + { status: isPayloadSizeLimitError(error) ? 413 : 500 } ) } diff --git a/apps/sim/app/api/tools/dropbox/upload/route.ts b/apps/sim/app/api/tools/dropbox/upload/route.ts index 58873c334bc..af04b69db16 100644 --- a/apps/sim/app/api/tools/dropbox/upload/route.ts +++ b/apps/sim/app/api/tools/dropbox/upload/route.ts @@ -5,8 +5,10 @@ import { dropboxUploadContract } from '@/lib/api/contracts/storage-transfer' import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { httpHeaderSafeJson } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils' import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response' @@ -58,14 +60,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) if (denied) return denied try { - const result = await downloadServableFileFromStorage(userFile, requestId, logger) + const result = await downloadServableFileFromStorage(userFile, requestId, logger, { + maxBytes: MAX_BUFFERED_TRANSFER_BYTES, + }) fileBuffer = result.buffer } catch (error) { const notReady = docNotReadyResponse(error) if (notReady) return notReady return NextResponse.json( { success: false, error: getErrorMessage(error, 'Failed to download file') }, - { status: 500 } + { status: isPayloadSizeLimitError(error) ? 413 : 500 } ) } fileName = userFile.name diff --git a/apps/sim/app/api/tools/elevenlabs/audio/route.ts b/apps/sim/app/api/tools/elevenlabs/audio/route.ts index 969f6f4a231..871868037ad 100644 --- a/apps/sim/app/api/tools/elevenlabs/audio/route.ts +++ b/apps/sim/app/api/tools/elevenlabs/audio/route.ts @@ -20,6 +20,7 @@ import { isModelSafeWorkspaceFileKey, MODEL_UNSAFE_WORKSPACE_FILE_ERROR_MESSAGE, } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' import { getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { assertToolFileAccess } from '@/app/api/files/authorization' @@ -153,7 +154,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { status: 400 } ) } - const buffer = await downloadFileFromStorage(file, requestId, logger) + const buffer = await downloadFileFromStorage(file, requestId, logger, { + maxBytes: MAX_BUFFERED_TRANSFER_BYTES, + }) const ext = file.name.split('.').pop()?.toLowerCase() || '' source = { buffer, diff --git a/apps/sim/app/api/tools/file/manage/route.ts b/apps/sim/app/api/tools/file/manage/route.ts index fdf8d5df047..85df403dfcf 100644 --- a/apps/sim/app/api/tools/file/manage/route.ts +++ b/apps/sim/app/api/tools/file/manage/route.ts @@ -302,7 +302,11 @@ async function getFileContentProvenance( continue } const provenance = await getBoundWorkspaceFileSecretProvenance(workspaceId, source.identity) - if (provenance.status === 'unknown') { + /** + * `unrecorded` is a more specific `unknown`, and this accumulator has not opted into the + * workspace file surface's policy, so it latches exactly as it did before. + */ + if (provenance.status !== 'exact') { accumulator.markIncomplete('workspace-file-provenance-unknown') continue } diff --git a/apps/sim/app/api/tools/firecrawl/parse/route.ts b/apps/sim/app/api/tools/firecrawl/parse/route.ts index 2fb4c7d1cd0..a1b1fe8888f 100644 --- a/apps/sim/app/api/tools/firecrawl/parse/route.ts +++ b/apps/sim/app/api/tools/firecrawl/parse/route.ts @@ -11,6 +11,7 @@ import { isModelSafeWorkspaceFileKey, MODEL_UNSAFE_WORKSPACE_FILE_ERROR_MESSAGE, } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response' @@ -85,7 +86,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const { buffer, contentType } = await downloadServableFileFromStorage( userFile, requestId, - logger + logger, + { + maxBytes: MAX_BUFFERED_TRANSFER_BYTES, + } ) const formData = new FormData() diff --git a/apps/sim/app/api/tools/gmail/draft/route.ts b/apps/sim/app/api/tools/gmail/draft/route.ts index e376be1f01e..255c5242967 100644 --- a/apps/sim/app/api/tools/gmail/draft/route.ts +++ b/apps/sim/app/api/tools/gmail/draft/route.ts @@ -5,9 +5,10 @@ import { gmailDraftContract } from '@/lib/api/contracts/tools/google' import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' -import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { downloadServableFilesWithinBudget } from '@/lib/uploads/utils/file-utils.server' import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response' import { assertToolFileAccess } from '@/app/api/files/authorization' import { @@ -97,17 +98,23 @@ export const POST = withRouteHandler(async (request: NextRequest) => { let resolved: Array<{ buffer: Buffer; contentType: string }> try { - resolved = await Promise.all( - attachments.map(async (file) => { - logger.info( - `[${requestId}] Downloading attachment: ${file.name} (${file.size} bytes)` - ) - return await downloadServableFileFromStorage(file, requestId, logger) - }) - ) + resolved = await downloadServableFilesWithinBudget(attachments, requestId, logger, { + totalMaxBytes: maxSize, + label: 'Total attachment size', + }) } catch (error) { const notReady = docNotReadyResponse(error) if (notReady) return notReady + if (isPayloadSizeLimitError(error)) { + const sizeMB = ((error.observedBytes ?? totalSize) / (1024 * 1024)).toFixed(2) + return NextResponse.json( + { + success: false, + error: `Total attachment size (${sizeMB}MB) exceeds Gmail's limit of 25MB`, + }, + { status: 400 } + ) + } logger.error(`[${requestId}] Failed to download an attachment:`, error) return NextResponse.json( { @@ -118,18 +125,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const resolvedTotal = resolved.reduce((sum, r) => sum + r.buffer.length, 0) - if (resolvedTotal > maxSize) { - const sizeMB = (resolvedTotal / (1024 * 1024)).toFixed(2) - return NextResponse.json( - { - success: false, - error: `Total attachment size (${sizeMB}MB) exceeds Gmail's limit of 25MB`, - }, - { status: 400 } - ) - } - const attachmentBuffers = attachments.map((file, i) => ({ filename: file.name, mimeType: resolved[i].contentType || file.type || 'application/octet-stream', diff --git a/apps/sim/app/api/tools/gmail/edit-draft/route.ts b/apps/sim/app/api/tools/gmail/edit-draft/route.ts index f986a610f11..9e88ce6cdbd 100644 --- a/apps/sim/app/api/tools/gmail/edit-draft/route.ts +++ b/apps/sim/app/api/tools/gmail/edit-draft/route.ts @@ -5,9 +5,10 @@ import { gmailEditDraftContract } from '@/lib/api/contracts/tools/google' import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' -import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { downloadServableFilesWithinBudget } from '@/lib/uploads/utils/file-utils.server' import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response' import { assertToolFileAccess } from '@/app/api/files/authorization' import { @@ -93,17 +94,23 @@ export const POST = withRouteHandler(async (request: NextRequest) => { let resolved: Array<{ buffer: Buffer; contentType: string }> try { - resolved = await Promise.all( - attachments.map(async (file) => { - logger.info( - `[${requestId}] Downloading attachment: ${file.name} (${file.size} bytes)` - ) - return await downloadServableFileFromStorage(file, requestId, logger) - }) - ) + resolved = await downloadServableFilesWithinBudget(attachments, requestId, logger, { + totalMaxBytes: maxSize, + label: 'Total attachment size', + }) } catch (error) { const notReady = docNotReadyResponse(error) if (notReady) return notReady + if (isPayloadSizeLimitError(error)) { + const sizeMB = ((error.observedBytes ?? totalSize) / (1024 * 1024)).toFixed(2) + return NextResponse.json( + { + success: false, + error: `Total attachment size (${sizeMB}MB) exceeds Gmail's limit of 25MB`, + }, + { status: 400 } + ) + } logger.error(`[${requestId}] Failed to download an attachment:`, error) return NextResponse.json( { @@ -114,18 +121,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const resolvedTotal = resolved.reduce((sum, r) => sum + r.buffer.length, 0) - if (resolvedTotal > maxSize) { - const sizeMB = (resolvedTotal / (1024 * 1024)).toFixed(2) - return NextResponse.json( - { - success: false, - error: `Total attachment size (${sizeMB}MB) exceeds Gmail's limit of 25MB`, - }, - { status: 400 } - ) - } - const attachmentBuffers = attachments.map((file, i) => ({ filename: file.name, mimeType: resolved[i].contentType || file.type || 'application/octet-stream', diff --git a/apps/sim/app/api/tools/gmail/send/route.ts b/apps/sim/app/api/tools/gmail/send/route.ts index 59df7377b34..62b10377e88 100644 --- a/apps/sim/app/api/tools/gmail/send/route.ts +++ b/apps/sim/app/api/tools/gmail/send/route.ts @@ -5,9 +5,10 @@ import { gmailSendContract } from '@/lib/api/contracts/tools/google' import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' -import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { downloadServableFilesWithinBudget } from '@/lib/uploads/utils/file-utils.server' import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response' import { assertToolFileAccess } from '@/app/api/files/authorization' import { @@ -97,17 +98,23 @@ export const POST = withRouteHandler(async (request: NextRequest) => { let resolved: Array<{ buffer: Buffer; contentType: string }> try { - resolved = await Promise.all( - attachments.map(async (file) => { - logger.info( - `[${requestId}] Downloading attachment: ${file.name} (${file.size} bytes)` - ) - return await downloadServableFileFromStorage(file, requestId, logger) - }) - ) + resolved = await downloadServableFilesWithinBudget(attachments, requestId, logger, { + totalMaxBytes: maxSize, + label: 'Total attachment size', + }) } catch (error) { const notReady = docNotReadyResponse(error) if (notReady) return notReady + if (isPayloadSizeLimitError(error)) { + const sizeMB = ((error.observedBytes ?? totalSize) / (1024 * 1024)).toFixed(2) + return NextResponse.json( + { + success: false, + error: `Total attachment size (${sizeMB}MB) exceeds Gmail's limit of 25MB`, + }, + { status: 400 } + ) + } logger.error(`[${requestId}] Failed to download an attachment:`, error) return NextResponse.json( { @@ -118,18 +125,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const resolvedTotal = resolved.reduce((sum, r) => sum + r.buffer.length, 0) - if (resolvedTotal > maxSize) { - const sizeMB = (resolvedTotal / (1024 * 1024)).toFixed(2) - return NextResponse.json( - { - success: false, - error: `Total attachment size (${sizeMB}MB) exceeds Gmail's limit of 25MB`, - }, - { status: 400 } - ) - } - const attachmentBuffers = attachments.map((file, i) => ({ filename: file.name, mimeType: resolved[i].contentType || file.type || 'application/octet-stream', diff --git a/apps/sim/app/api/tools/google_drive/upload/route.ts b/apps/sim/app/api/tools/google_drive/upload/route.ts index 0600386324e..ca134da06cc 100644 --- a/apps/sim/app/api/tools/google_drive/upload/route.ts +++ b/apps/sim/app/api/tools/google_drive/upload/route.ts @@ -6,7 +6,9 @@ import { googleDriveUploadContract } from '@/lib/api/contracts/tools/google' import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response' @@ -124,7 +126,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { let downloadedContentType = '' try { - const result = await downloadServableFileFromStorage(userFile, requestId, logger) + const result = await downloadServableFileFromStorage(userFile, requestId, logger, { + maxBytes: MAX_BUFFERED_TRANSFER_BYTES, + }) fileBuffer = result.buffer downloadedContentType = result.contentType } catch (error) { @@ -136,7 +140,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { success: false, error: `Failed to download file: ${getErrorMessage(error, 'Unknown error')}`, }, - { status: 500 } + { status: isPayloadSizeLimitError(error) ? 413 : 500 } ) } diff --git a/apps/sim/app/api/tools/jira/add-attachment/route.ts b/apps/sim/app/api/tools/jira/add-attachment/route.ts index b23abf6656b..70218256ffc 100644 --- a/apps/sim/app/api/tools/jira/add-attachment/route.ts +++ b/apps/sim/app/api/tools/jira/add-attachment/route.ts @@ -5,6 +5,7 @@ import { jiraAddAttachmentContract } from '@/lib/api/contracts/selectors/jira' import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response' @@ -44,6 +45,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { (await getJiraCloudId(validatedData.domain, validatedData.accessToken)) const formData = new FormData() + // Every attachment lands in the same multipart body, so the ceiling covers the + // set rather than each file on its own. + let remainingBytes = MAX_BUFFERED_TRANSFER_BYTES for (const file of userFiles) { const denied = await assertToolFileAccess(file.key, authResult.userId, requestId, logger) @@ -51,7 +55,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { let buffer: Buffer let downloadedContentType = '' try { - const result = await downloadServableFileFromStorage(file, requestId, logger) + const result = await downloadServableFileFromStorage(file, requestId, logger, { + maxBytes: remainingBytes, + }) buffer = result.buffer downloadedContentType = result.contentType } catch (error) { @@ -59,6 +65,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (notReady) return notReady throw error } + remainingBytes -= buffer.length const blob = new Blob([new Uint8Array(buffer)], { type: downloadedContentType || file.type || 'application/octet-stream', }) diff --git a/apps/sim/app/api/tools/jupyter/upload/route.ts b/apps/sim/app/api/tools/jupyter/upload/route.ts index ff3656cce44..0c4efff228a 100644 --- a/apps/sim/app/api/tools/jupyter/upload/route.ts +++ b/apps/sim/app/api/tools/jupyter/upload/route.ts @@ -10,7 +10,9 @@ import { validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils' import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response' @@ -58,14 +60,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (denied) return denied try { - const result = await downloadServableFileFromStorage(userFile, requestId, logger) + const result = await downloadServableFileFromStorage(userFile, requestId, logger, { + maxBytes: MAX_BUFFERED_TRANSFER_BYTES, + }) fileBuffer = result.buffer } catch (error) { const notReady = docNotReadyResponse(error) if (notReady) return notReady return NextResponse.json( { success: false, error: getErrorMessage(error, 'Failed to download file') }, - { status: 500 } + { status: isPayloadSizeLimitError(error) ? 413 : 500 } ) } fileName = data.fileName || userFile.name diff --git a/apps/sim/app/api/tools/linq/upload/route.ts b/apps/sim/app/api/tools/linq/upload/route.ts index 6b0004df4e2..deb8ffb79d6 100644 --- a/apps/sim/app/api/tools/linq/upload/route.ts +++ b/apps/sim/app/api/tools/linq/upload/route.ts @@ -5,6 +5,7 @@ import { linqUploadAttachmentContract } from '@/lib/api/contracts/tools/communic import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils' import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -19,6 +20,16 @@ const logger = createLogger('LinqUploadAttachmentAPI') /** Linq pre-upload caps attachments at 100MB. */ const MAX_SIZE_BYTES = 100 * 1024 * 1024 +function fileTooLargeError(sizeBytes: number): NextResponse { + return NextResponse.json( + { + success: false, + error: `File exceeds Linq's 100MB attachment limit (${(sizeBytes / (1024 * 1024)).toFixed(2)}MB)`, + }, + { status: 400 } + ) +} + /** * Upload a file to Linq as a reusable attachment. * @@ -61,12 +72,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (denied) return denied let resolvedContentTypeFromStorage: string try { - const resolved = await downloadServableFileFromStorage(userFile, requestId, logger) + const resolved = await downloadServableFileFromStorage(userFile, requestId, logger, { + maxBytes: MAX_SIZE_BYTES, + }) buffer = resolved.buffer resolvedContentTypeFromStorage = resolved.contentType } catch (error) { const notReady = docNotReadyResponse(error) if (notReady) return notReady + if (isPayloadSizeLimitError(error)) + return fileTooLargeError(error.observedBytes ?? userFile.size) logger.error(`[${requestId}] Failed to download Linq attachment file:`, error) return NextResponse.json( { success: false, error: getErrorMessage(error, 'Unknown error occurred') }, @@ -93,13 +108,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: false, error: 'File is empty' }, { status: 400 }) } if (sizeBytes > MAX_SIZE_BYTES) { - return NextResponse.json( - { - success: false, - error: `File exceeds Linq's 100MB attachment limit (${(sizeBytes / (1024 * 1024)).toFixed(2)}MB)`, - }, - { status: 400 } - ) + return fileTooLargeError(sizeBytes) } logger.info(`[${requestId}] Registering Linq attachment`, { diff --git a/apps/sim/app/api/tools/microsoft-dataverse/upload-file/route.ts b/apps/sim/app/api/tools/microsoft-dataverse/upload-file/route.ts index eb78c96325f..826c547df96 100644 --- a/apps/sim/app/api/tools/microsoft-dataverse/upload-file/route.ts +++ b/apps/sim/app/api/tools/microsoft-dataverse/upload-file/route.ts @@ -6,6 +6,7 @@ import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -20,6 +21,17 @@ const logger = createLogger('DataverseUploadFileAPI') /** Dataverse Web API's absolute ceiling for a single-request (non-chunked) file column upload. */ const DATAVERSE_SINGLE_REQUEST_UPLOAD_MAX_BYTES = 128 * 1024 * 1024 +function uploadTooLargeError(observedBytes: number): NextResponse { + const sizeMB = (observedBytes / (1024 * 1024)).toFixed(2) + return NextResponse.json( + { + success: false, + error: `File size (${sizeMB}MB) exceeds Dataverse's 128MB limit for single-request file column uploads. Split the file and use chunked upload instead.`, + }, + { status: 400 } + ) +} + export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -77,11 +89,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (denied) return denied try { - const servable = await downloadServableFileFromStorage(userFile, requestId, logger) + const servable = await downloadServableFileFromStorage(userFile, requestId, logger, { + maxBytes: DATAVERSE_SINGLE_REQUEST_UPLOAD_MAX_BYTES, + }) fileBuffer = servable.buffer } catch (error) { const notReady = docNotReadyResponse(error) if (notReady) return notReady + if (isPayloadSizeLimitError(error)) + return uploadTooLargeError(error.observedBytes ?? userFile.size) logger.error(`[${requestId}] Failed to download file from storage:`, error) return NextResponse.json( { success: false, error: getErrorMessage(error, 'Failed to download file') }, @@ -100,13 +116,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (fileBuffer.length > DATAVERSE_SINGLE_REQUEST_UPLOAD_MAX_BYTES) { const sizeMB = (fileBuffer.length / (1024 * 1024)).toFixed(2) logger.warn(`[${requestId}] File too large for single-request upload: ${sizeMB}MB`) - return NextResponse.json( - { - success: false, - error: `File size (${sizeMB}MB) exceeds Dataverse's 128MB limit for single-request file column uploads. Split the file and use chunked upload instead.`, - }, - { status: 400 } - ) + return uploadTooLargeError(fileBuffer.length) } const baseUrl = getDataverseBaseUrl(validatedData.environmentUrl) diff --git a/apps/sim/app/api/tools/mistral/parse/route.ts b/apps/sim/app/api/tools/mistral/parse/route.ts index 5805142915d..e994e718ac3 100644 --- a/apps/sim/app/api/tools/mistral/parse/route.ts +++ b/apps/sim/app/api/tools/mistral/parse/route.ts @@ -15,6 +15,7 @@ import { isModelSafeWorkspaceFileKey, MODEL_UNSAFE_WORKSPACE_FILE_ERROR_MESSAGE, } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' import { extractStorageKey, isInternalFileUrl, @@ -157,7 +158,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const { buffer, contentType } = await downloadServableFileFromStorage( userFile, requestId, - logger + logger, + { + maxBytes: MAX_BUFFERED_TRANSFER_BYTES, + } ) base64 = buffer.toString('base64') if (contentType && contentType !== 'application/octet-stream') { diff --git a/apps/sim/app/api/tools/onedrive/upload/route.ts b/apps/sim/app/api/tools/onedrive/upload/route.ts index 2c94986de9f..2e7ff49837d 100644 --- a/apps/sim/app/api/tools/onedrive/upload/route.ts +++ b/apps/sim/app/api/tools/onedrive/upload/route.ts @@ -8,6 +8,7 @@ import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getExtensionFromMimeType, @@ -24,6 +25,20 @@ const logger = createLogger('OneDriveUploadAPI') const MICROSOFT_GRAPH_BASE = 'https://graph.microsoft.com/v1.0' +/** Microsoft Graph's ceiling for a simple (non-chunked) drive-item upload. */ +const MAX_SIMPLE_UPLOAD_BYTES = 250 * 1024 * 1024 + +function fileTooLargeError(observedBytes: number): NextResponse { + const sizeMB = (observedBytes / (1024 * 1024)).toFixed(2) + return NextResponse.json( + { + success: false, + error: `File size (${sizeMB}MB) exceeds OneDrive's limit of 250MB for simple uploads. Use chunked upload for larger files.`, + }, + { status: 400 } + ) +} + /** Microsoft Graph DriveItem response */ interface OneDriveFileData { id: string @@ -115,12 +130,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (denied) return denied try { - const result = await downloadServableFileFromStorage(userFile, requestId, logger) + const result = await downloadServableFileFromStorage(userFile, requestId, logger, { + maxBytes: MAX_SIMPLE_UPLOAD_BYTES, + }) fileBuffer = result.buffer mimeType = result.contentType || userFile.type || 'application/octet-stream' } catch (error) { const notReady = docNotReadyResponse(error) if (notReady) return notReady + if (isPayloadSizeLimitError(error)) { + return fileTooLargeError(error.observedBytes ?? userFile.size) + } logger.error(`[${requestId}] Failed to download file from storage:`, error) return NextResponse.json( { @@ -132,17 +152,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } } - const maxSize = 250 * 1024 * 1024 - if (fileBuffer.length > maxSize) { - const sizeMB = (fileBuffer.length / (1024 * 1024)).toFixed(2) - logger.warn(`[${requestId}] File too large: ${sizeMB}MB`) - return NextResponse.json( - { - success: false, - error: `File size (${sizeMB}MB) exceeds OneDrive's limit of 250MB for simple uploads. Use chunked upload for larger files.`, - }, - { status: 400 } + if (fileBuffer.length > MAX_SIMPLE_UPLOAD_BYTES) { + logger.warn( + `[${requestId}] File too large: ${(fileBuffer.length / (1024 * 1024)).toFixed(2)}MB` ) + return fileTooLargeError(fileBuffer.length) } let fileName = validatedData.fileName diff --git a/apps/sim/app/api/tools/outlook/draft/route.ts b/apps/sim/app/api/tools/outlook/draft/route.ts index 979634ffcf3..52b114bed2a 100644 --- a/apps/sim/app/api/tools/outlook/draft/route.ts +++ b/apps/sim/app/api/tools/outlook/draft/route.ts @@ -5,9 +5,10 @@ import { outlookDraftContract } from '@/lib/api/contracts/tools/microsoft' import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' -import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { downloadServableFilesWithinBudget } from '@/lib/uploads/utils/file-utils.server' import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response' import { assertToolFileAccess } from '@/app/api/files/authorization' @@ -110,17 +111,23 @@ export const POST = withRouteHandler(async (request: NextRequest) => { let resolved: Array<{ buffer: Buffer; contentType: string }> try { - resolved = await Promise.all( - attachments.map(async (file) => { - logger.info( - `[${requestId}] Downloading attachment: ${file.name} (${file.size} bytes)` - ) - return await downloadServableFileFromStorage(file, requestId, logger) - }) - ) + resolved = await downloadServableFilesWithinBudget(attachments, requestId, logger, { + totalMaxBytes: maxSize, + label: 'Total attachment size', + }) } catch (error) { const notReady = docNotReadyResponse(error) if (notReady) return notReady + if (isPayloadSizeLimitError(error)) { + const sizeMB = ((error.observedBytes ?? totalSize) / (1024 * 1024)).toFixed(2) + return NextResponse.json( + { + success: false, + error: `Total attachment size (${sizeMB}MB) exceeds Outlook's limit of 4MB per request`, + }, + { status: 400 } + ) + } logger.error(`[${requestId}] Failed to download an attachment:`, error) return NextResponse.json( { @@ -131,18 +138,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const resolvedTotal = resolved.reduce((sum, r) => sum + r.buffer.length, 0) - if (resolvedTotal > maxSize) { - const sizeMB = (resolvedTotal / (1024 * 1024)).toFixed(2) - return NextResponse.json( - { - success: false, - error: `Total attachment size (${sizeMB}MB) exceeds Outlook's limit of 4MB per request`, - }, - { status: 400 } - ) - } - const attachmentObjects = attachments.map((file, i) => ({ '@odata.type': '#microsoft.graph.fileAttachment', name: file.name, diff --git a/apps/sim/app/api/tools/outlook/send/route.ts b/apps/sim/app/api/tools/outlook/send/route.ts index 7a59f44410a..82dd3e4474a 100644 --- a/apps/sim/app/api/tools/outlook/send/route.ts +++ b/apps/sim/app/api/tools/outlook/send/route.ts @@ -5,9 +5,10 @@ import { outlookSendContract } from '@/lib/api/contracts/tools/microsoft' import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' -import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { downloadServableFilesWithinBudget } from '@/lib/uploads/utils/file-utils.server' import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response' import { assertToolFileAccess } from '@/app/api/files/authorization' @@ -110,17 +111,23 @@ export const POST = withRouteHandler(async (request: NextRequest) => { let resolved: Array<{ buffer: Buffer; contentType: string }> try { - resolved = await Promise.all( - attachments.map(async (file) => { - logger.info( - `[${requestId}] Downloading attachment: ${file.name} (${file.size} bytes)` - ) - return await downloadServableFileFromStorage(file, requestId, logger) - }) - ) + resolved = await downloadServableFilesWithinBudget(attachments, requestId, logger, { + totalMaxBytes: maxSize, + label: 'Total attachment size', + }) } catch (error) { const notReady = docNotReadyResponse(error) if (notReady) return notReady + if (isPayloadSizeLimitError(error)) { + const sizeMB = ((error.observedBytes ?? totalSize) / (1024 * 1024)).toFixed(2) + return NextResponse.json( + { + success: false, + error: `Total attachment size (${sizeMB}MB) exceeds Microsoft Graph API limit of 3MB per request`, + }, + { status: 400 } + ) + } logger.error(`[${requestId}] Failed to download an attachment:`, error) return NextResponse.json( { @@ -131,18 +138,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const resolvedTotal = resolved.reduce((sum, r) => sum + r.buffer.length, 0) - if (resolvedTotal > maxSize) { - const sizeMB = (resolvedTotal / (1024 * 1024)).toFixed(2) - return NextResponse.json( - { - success: false, - error: `Total attachment size (${sizeMB}MB) exceeds Microsoft Graph API limit of 3MB per request`, - }, - { status: 400 } - ) - } - const attachmentObjects = attachments.map((file, i) => ({ '@odata.type': '#microsoft.graph.fileAttachment', name: file.name, diff --git a/apps/sim/app/api/tools/persona/import-accounts/route.ts b/apps/sim/app/api/tools/persona/import-accounts/route.ts index 0844b11c16a..ca59a41b053 100644 --- a/apps/sim/app/api/tools/persona/import-accounts/route.ts +++ b/apps/sim/app/api/tools/persona/import-accounts/route.ts @@ -5,7 +5,9 @@ import { personaImportAccountsContract } from '@/lib/api/contracts/tools/persona import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response' @@ -58,7 +60,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { let buffer: Buffer try { - const resolved = await downloadServableFileFromStorage(userFile, requestId, logger) + const resolved = await downloadServableFileFromStorage(userFile, requestId, logger, { + maxBytes: MAX_BUFFERED_TRANSFER_BYTES, + }) buffer = resolved.buffer } catch (error) { const notReady = docNotReadyResponse(error) @@ -66,7 +70,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { logger.error(`[${requestId}] Failed to download Persona import file:`, error) return NextResponse.json( { success: false, error: getErrorMessage(error, 'Internal server error') }, - { status: 500 } + { status: isPayloadSizeLimitError(error) ? 413 : 500 } ) } diff --git a/apps/sim/app/api/tools/quiver/image-to-svg/route.ts b/apps/sim/app/api/tools/quiver/image-to-svg/route.ts index 8149a8b5dd0..faf79554834 100644 --- a/apps/sim/app/api/tools/quiver/image-to-svg/route.ts +++ b/apps/sim/app/api/tools/quiver/image-to-svg/route.ts @@ -11,6 +11,7 @@ import { isModelSafeWorkspaceFileKey, MODEL_UNSAFE_WORKSPACE_FILE_ERROR_MESSAGE, } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' import type { RawFileInput } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -81,7 +82,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { status: 400 } ) } - const buffer = await downloadFileFromStorage(userFiles[0], requestId, logger) + const buffer = await downloadFileFromStorage(userFiles[0], requestId, logger, { + maxBytes: MAX_BUFFERED_TRANSFER_BYTES, + }) apiImage = { base64: buffer.toString('base64') } } else { return NextResponse.json( @@ -114,7 +117,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { status: 400 } ) } - const buffer = await downloadFileFromStorage(userFiles[0], requestId, logger) + const buffer = await downloadFileFromStorage(userFiles[0], requestId, logger, { + maxBytes: MAX_BUFFERED_TRANSFER_BYTES, + }) apiImage = { base64: buffer.toString('base64') } } else { return NextResponse.json({ success: false, error: 'Invalid file input' }, { status: 400 }) diff --git a/apps/sim/app/api/tools/quiver/text-to-svg/route.ts b/apps/sim/app/api/tools/quiver/text-to-svg/route.ts index fb6df66b7e5..eb40c76997d 100644 --- a/apps/sim/app/api/tools/quiver/text-to-svg/route.ts +++ b/apps/sim/app/api/tools/quiver/text-to-svg/route.ts @@ -11,6 +11,7 @@ import { isModelSafeWorkspaceFileKey, MODEL_UNSAFE_WORKSPACE_FILE_ERROR_MESSAGE, } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' import type { RawFileInput } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -59,6 +60,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const apiReferences: Array<{ url: string } | { base64: string }> = [] + // Every reference is buffered and base64'd before the list is sliced to 4, so the + // budget has to span the whole loop rather than bound each file on its own. + let referenceBudget = MAX_BUFFERED_TRANSFER_BYTES if (data.references) { const rawRefs = Array.isArray(data.references) ? data.references : [data.references] @@ -86,7 +90,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { status: 400 } ) } - const buffer = await downloadFileFromStorage(userFiles[0], requestId, logger) + const buffer = await downloadFileFromStorage(userFiles[0], requestId, logger, { + maxBytes: referenceBudget, + }) + referenceBudget -= buffer.length apiReferences.push({ base64: buffer.toString('base64') }) } } @@ -107,7 +114,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { status: 400 } ) } - const buffer = await downloadFileFromStorage(userFiles[0], requestId, logger) + const buffer = await downloadFileFromStorage(userFiles[0], requestId, logger, { + maxBytes: referenceBudget, + }) + referenceBudget -= buffer.length apiReferences.push({ base64: buffer.toString('base64') }) } } diff --git a/apps/sim/app/api/tools/s3/put-object/route.ts b/apps/sim/app/api/tools/s3/put-object/route.ts index 2019de2b4d4..026713914fb 100644 --- a/apps/sim/app/api/tools/s3/put-object/route.ts +++ b/apps/sim/app/api/tools/s3/put-object/route.ts @@ -6,7 +6,9 @@ import { awsS3PutObjectContract } from '@/lib/api/contracts/tools/aws/s3-put-obj import { parseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response' @@ -84,7 +86,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { let downloadedContentType = '' try { - const result = await downloadServableFileFromStorage(userFile, requestId, logger) + const result = await downloadServableFileFromStorage(userFile, requestId, logger, { + maxBytes: MAX_BUFFERED_TRANSFER_BYTES, + }) uploadBody = result.buffer downloadedContentType = result.contentType } catch (error) { @@ -92,7 +96,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (notReady) return notReady return NextResponse.json( { success: false, error: getErrorMessage(error, 'Failed to download file') }, - { status: 500 } + { status: isPayloadSizeLimitError(error) ? 413 : 500 } ) } diff --git a/apps/sim/app/api/tools/sendgrid/send-mail/route.ts b/apps/sim/app/api/tools/sendgrid/send-mail/route.ts index d9b2b97876a..f0bc1b3e660 100644 --- a/apps/sim/app/api/tools/sendgrid/send-mail/route.ts +++ b/apps/sim/app/api/tools/sendgrid/send-mail/route.ts @@ -5,9 +5,10 @@ import { sendGridSendMailContract } from '@/lib/api/contracts/tools/communicatio import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' -import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { downloadServableFilesWithinBudget } from '@/lib/uploads/utils/file-utils.server' import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response' import { assertToolFileAccess } from '@/app/api/files/authorization' @@ -15,6 +16,9 @@ export const dynamic = 'force-dynamic' const logger = createLogger('SendGridSendMailAPI') +/** SendGrid rejects a message whose total attachment payload exceeds 30MB. */ +const MAX_ATTACHMENT_TOTAL_BYTES = 30 * 1024 * 1024 + export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -109,17 +113,26 @@ export const POST = withRouteHandler(async (request: NextRequest) => { let resolved: Array<{ buffer: Buffer; contentType: string }> try { - resolved = await Promise.all( - userFiles.map(async (file) => { - logger.info( - `[${requestId}] Downloading attachment: ${file.name} (${file.size} bytes)` - ) - return await downloadServableFileFromStorage(file, requestId, logger) - }) - ) + resolved = await downloadServableFilesWithinBudget(userFiles, requestId, logger, { + totalMaxBytes: MAX_ATTACHMENT_TOTAL_BYTES, + label: 'Total attachment size', + }) } catch (error) { const notReady = docNotReadyResponse(error) if (notReady) return notReady + if (isPayloadSizeLimitError(error)) { + const sizeMB = ( + (error.observedBytes ?? MAX_ATTACHMENT_TOTAL_BYTES) / + (1024 * 1024) + ).toFixed(2) + return NextResponse.json( + { + success: false, + error: `Total attachment size (${sizeMB}MB) exceeds SendGrid's limit of 30MB`, + }, + { status: 400 } + ) + } logger.error(`[${requestId}] Failed to download an attachment:`, error) return NextResponse.json( { @@ -130,19 +143,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const resolvedTotal = resolved.reduce((sum, r) => sum + r.buffer.length, 0) - const maxSize = 30 * 1024 * 1024 - if (resolvedTotal > maxSize) { - const sizeMB = (resolvedTotal / (1024 * 1024)).toFixed(2) - return NextResponse.json( - { - success: false, - error: `Total attachment size (${sizeMB}MB) exceeds SendGrid's limit of 30MB`, - }, - { status: 400 } - ) - } - const sendGridAttachments = userFiles.map((file, i) => ({ content: resolved[i].buffer.toString('base64'), filename: file.name, diff --git a/apps/sim/app/api/tools/servicenow/upload-attachment/route.ts b/apps/sim/app/api/tools/servicenow/upload-attachment/route.ts index 78dcdb0883b..532b3712572 100644 --- a/apps/sim/app/api/tools/servicenow/upload-attachment/route.ts +++ b/apps/sim/app/api/tools/servicenow/upload-attachment/route.ts @@ -6,7 +6,9 @@ import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response' @@ -56,7 +58,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { let fileBuffer: Buffer let resolvedContentType: string try { - const servable = await downloadServableFileFromStorage(userFile, requestId, logger) + const servable = await downloadServableFileFromStorage(userFile, requestId, logger, { + maxBytes: MAX_BUFFERED_TRANSFER_BYTES, + }) fileBuffer = servable.buffer resolvedContentType = servable.contentType } catch (error) { @@ -65,7 +69,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { logger.error(`[${requestId}] Failed to download file from storage:`, error) return NextResponse.json( { success: false, error: getErrorMessage(error, 'Failed to download file') }, - { status: 500 } + { status: isPayloadSizeLimitError(error) ? 413 : 500 } ) } diff --git a/apps/sim/app/api/tools/sftp/upload/route.ts b/apps/sim/app/api/tools/sftp/upload/route.ts index 09029d5cd81..bca922e7d20 100644 --- a/apps/sim/app/api/tools/sftp/upload/route.ts +++ b/apps/sim/app/api/tools/sftp/upload/route.ts @@ -5,6 +5,7 @@ import { sftpUploadContract } from '@/lib/api/contracts/storage-transfer' import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -109,16 +110,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { logger.info( `[${requestId}] Downloading file for upload: ${file.name} (${file.size} bytes)` ) - const { buffer } = await downloadServableFileFromStorage(file, requestId, logger) + const { buffer } = await downloadServableFileFromStorage(file, requestId, logger, { + maxBytes: maxSize - resolvedTotal, + }) resolvedTotal += buffer.length - if (resolvedTotal > maxSize) { - const sizeMB = (resolvedTotal / (1024 * 1024)).toFixed(2) - return NextResponse.json( - { success: false, error: `Total file size (${sizeMB}MB) exceeds limit of 100MB` }, - { status: 400 } - ) - } const safeFileName = sanitizeFileName(file.name) const fullRemotePath = remotePath.endsWith('/') @@ -155,6 +151,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } catch (error) { const notReady = docNotReadyResponse(error) if (notReady) return notReady + if (isPayloadSizeLimitError(error)) { + const observed = resolvedTotal + (error.observedBytes ?? file.size) + const sizeMB = (observed / (1024 * 1024)).toFixed(2) + return NextResponse.json( + { success: false, error: `Total file size (${sizeMB}MB) exceeds limit of 100MB` }, + { status: 400 } + ) + } logger.error(`[${requestId}] Failed to upload file ${file.name}:`, error) throw new Error( `Failed to upload file "${file.name}": ${getErrorMessage(error, 'Unknown error')}` diff --git a/apps/sim/app/api/tools/sharepoint/upload/route.ts b/apps/sim/app/api/tools/sharepoint/upload/route.ts index 55bb4eec935..b29a51fd387 100644 --- a/apps/sim/app/api/tools/sharepoint/upload/route.ts +++ b/apps/sim/app/api/tools/sharepoint/upload/route.ts @@ -6,6 +6,7 @@ import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -88,34 +89,39 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (denied) return denied logger.info(`[${requestId}] Uploading file: ${userFile.name}`) - let buffer: Buffer - let downloadedContentType = '' - try { - const result = await downloadServableFileFromStorage(userFile, requestId, logger) - buffer = result.buffer - downloadedContentType = result.contentType - } catch (error) { - const notReady = docNotReadyResponse(error) - if (notReady) return notReady - throw error - } - const fileName = validatedData.fileName || userFile.name const folderPath = validatedData.folderPath?.trim() || '' - const fileSizeMB = buffer.length / (1024 * 1024) - - if (buffer.length > MAX_SHAREPOINT_UPLOAD_BYTES) { + const skipOversized = (size: number) => { logger.warn( - `[${requestId}] File ${fileName} is ${fileSizeMB.toFixed(2)}MB, exceeds 250MB limit` + `[${requestId}] File ${fileName} is ${(size / (1024 * 1024)).toFixed(2)}MB, exceeds 250MB limit` ) skippedFiles.push({ name: fileName, - size: buffer.length, + size, limit: MAX_SHAREPOINT_UPLOAD_BYTES, reason: 'File exceeds the 250 MB Microsoft Graph small upload limit', }) - continue + } + + let buffer: Buffer + let downloadedContentType = '' + try { + const result = await downloadServableFileFromStorage(userFile, requestId, logger, { + maxBytes: MAX_SHAREPOINT_UPLOAD_BYTES, + }) + buffer = result.buffer + downloadedContentType = result.contentType + } catch (error) { + const notReady = docNotReadyResponse(error) + if (notReady) return notReady + // An oversized file is skipped rather than failing the whole batch, exactly as + // it was when the size was only discovered after the download. + if (isPayloadSizeLimitError(error)) { + skipOversized(error.observedBytes ?? userFile.size) + continue + } + throw error } let uploadPath = '' diff --git a/apps/sim/app/api/tools/slack/utils.ts b/apps/sim/app/api/tools/slack/utils.ts index b3ff2205806..d40b3a81a66 100644 --- a/apps/sim/app/api/tools/slack/utils.ts +++ b/apps/sim/app/api/tools/slack/utils.ts @@ -1,5 +1,6 @@ import type { Logger } from '@sim/logger' import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' +import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { FileAccessDeniedError, verifyFileAccess } from '@/app/api/files/authorization' @@ -80,6 +81,9 @@ async function uploadFilesToSlack( const userFiles = processFilesToUserFiles(files, requestId, logger) const uploadedFileIds: string[] = [] const uploadedFiles: ToolFileData[] = [] + // One share can carry several files, so the ceiling spans the set: each file may + // only use what its predecessors left. + let remainingBytes = MAX_BUFFERED_TRANSFER_BYTES for (const userFile of userFiles) { logger.info(`[${requestId}] Uploading file: ${userFile.name}`) @@ -92,8 +96,10 @@ async function uploadFilesToSlack( const { buffer, contentType } = await downloadServableFileFromStorage( userFile, requestId, - logger + logger, + { maxBytes: remainingBytes } ) + remainingBytes -= buffer.length const getUrlResponse = await fetch('https://slack.com/api/files.getUploadURLExternal', { method: 'POST', diff --git a/apps/sim/app/api/tools/smtp/send/route.ts b/apps/sim/app/api/tools/smtp/send/route.ts index 920f6925784..83a48f3ce2b 100644 --- a/apps/sim/app/api/tools/smtp/send/route.ts +++ b/apps/sim/app/api/tools/smtp/send/route.ts @@ -7,10 +7,11 @@ import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateDatabaseHost } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getSmtpEhloName } from '@/lib/messaging/email/ehlo' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' -import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { downloadServableFilesWithinBudget } from '@/lib/uploads/utils/file-utils.server' import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response' import { assertToolFileAccess } from '@/app/api/files/authorization' @@ -132,17 +133,23 @@ export const POST = withRouteHandler(async (request: NextRequest) => { let resolved: Array<{ buffer: Buffer; contentType: string }> try { - resolved = await Promise.all( - attachments.map(async (file) => { - logger.info( - `[${requestId}] Downloading attachment: ${file.name} (${file.size} bytes)` - ) - return await downloadServableFileFromStorage(file, requestId, logger) - }) - ) + resolved = await downloadServableFilesWithinBudget(attachments, requestId, logger, { + totalMaxBytes: maxSize, + label: 'Total attachment size', + }) } catch (error) { const notReady = docNotReadyResponse(error) if (notReady) return notReady + if (isPayloadSizeLimitError(error)) { + const sizeMB = ((error.observedBytes ?? totalSize) / (1024 * 1024)).toFixed(2) + return NextResponse.json( + { + success: false, + error: `Total attachment size (${sizeMB}MB) exceeds SMTP limit of 25MB`, + }, + { status: 400 } + ) + } logger.error(`[${requestId}] Failed to download an attachment:`, error) return NextResponse.json( { @@ -153,18 +160,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const resolvedTotal = resolved.reduce((sum, r) => sum + r.buffer.length, 0) - if (resolvedTotal > maxSize) { - const sizeMB = (resolvedTotal / (1024 * 1024)).toFixed(2) - return NextResponse.json( - { - success: false, - error: `Total attachment size (${sizeMB}MB) exceeds SMTP limit of 25MB`, - }, - { status: 400 } - ) - } - const attachmentBuffers = attachments.map((file, i) => ({ filename: file.name, content: resolved[i].buffer, diff --git a/apps/sim/app/api/tools/square/catalog-image/route.ts b/apps/sim/app/api/tools/square/catalog-image/route.ts index 622c5c1c7a2..229e70deff3 100644 --- a/apps/sim/app/api/tools/square/catalog-image/route.ts +++ b/apps/sim/app/api/tools/square/catalog-image/route.ts @@ -7,6 +7,7 @@ import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { assertToolFileAccess } from '@/app/api/files/authorization' @@ -52,7 +53,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) if (denied) return denied - const fileBuffer = await downloadFileFromStorage(userFile, requestId, logger) + const fileBuffer = await downloadFileFromStorage(userFile, requestId, logger, { + maxBytes: MAX_BUFFERED_TRANSFER_BYTES, + }) const fileName = validatedData.fileName || userFile.name const mimeType = userFile.type || 'application/octet-stream' diff --git a/apps/sim/app/api/tools/stt/route.ts b/apps/sim/app/api/tools/stt/route.ts index f8f468ea11f..3ff3cb3eaaa 100644 --- a/apps/sim/app/api/tools/stt/route.ts +++ b/apps/sim/app/api/tools/stt/route.ts @@ -117,7 +117,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { status: 400 } ) } - audioBuffer = await downloadFileFromStorage(file, requestId, logger) + audioBuffer = await downloadFileFromStorage(file, requestId, logger, { + maxBytes: MAX_FILE_SIZE, + }) audioFileName = file.name // file.type may be missing if the file came from a block that doesn't preserve it // Infer from filename extension as fallback @@ -143,7 +145,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { status: 400 } ) } - audioBuffer = await downloadFileFromStorage(file, requestId, logger) + audioBuffer = await downloadFileFromStorage(file, requestId, logger, { + maxBytes: MAX_FILE_SIZE, + }) audioFileName = file.name const ext = file.name.split('.').pop()?.toLowerCase() || '' diff --git a/apps/sim/app/api/tools/supabase/storage-upload/route.ts b/apps/sim/app/api/tools/supabase/storage-upload/route.ts index 7f02f7791ea..ac9d6bda605 100644 --- a/apps/sim/app/api/tools/supabase/storage-upload/route.ts +++ b/apps/sim/app/api/tools/supabase/storage-upload/route.ts @@ -6,7 +6,9 @@ import { parseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateSupabaseProjectId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response' @@ -152,7 +154,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { let buffer: Buffer let resolvedContentType: string try { - const resolved = await downloadServableFileFromStorage(userFile, requestId, logger) + const resolved = await downloadServableFileFromStorage(userFile, requestId, logger, { + maxBytes: MAX_BUFFERED_TRANSFER_BYTES, + }) buffer = resolved.buffer resolvedContentType = resolved.contentType } catch (error) { @@ -161,7 +165,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { logger.error(`[${requestId}] Failed to download file for Supabase upload:`, error) return NextResponse.json( { success: false, error: getErrorMessage(error, 'Internal server error') }, - { status: 500 } + { status: isPayloadSizeLimitError(error) ? 413 : 500 } ) } diff --git a/apps/sim/app/api/tools/telegram/send-document/route.ts b/apps/sim/app/api/tools/telegram/send-document/route.ts index f454717e290..6d4cf533d64 100644 --- a/apps/sim/app/api/tools/telegram/send-document/route.ts +++ b/apps/sim/app/api/tools/telegram/send-document/route.ts @@ -5,6 +5,7 @@ import { telegramSendDocumentContract } from '@/lib/api/contracts/tools/communic import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -97,12 +98,24 @@ export const POST = withRouteHandler(async (request: NextRequest) => { let buffer: Buffer let contentType: string try { - const downloaded = await downloadServableFileFromStorage(userFile, requestId, logger) + const downloaded = await downloadServableFileFromStorage(userFile, requestId, logger, { + maxBytes: maxSize, + }) buffer = downloaded.buffer contentType = downloaded.contentType } catch (error) { const notReady = docNotReadyResponse(error) if (notReady) return notReady + if (isPayloadSizeLimitError(error)) { + const sizeMB = ((error.observedBytes ?? userFile.size) / (1024 * 1024)).toFixed(2) + return NextResponse.json( + { + success: false, + error: `The following files exceed Telegram's 50MB limit: ${userFile.name} (${sizeMB}MB)`, + }, + { status: 400 } + ) + } logger.error(`[${requestId}] Failed to download document ${userFile.name}:`, error) return NextResponse.json( { diff --git a/apps/sim/app/api/tools/textract/shared.ts b/apps/sim/app/api/tools/textract/shared.ts index b6b570cb506..0150c8f0c99 100644 --- a/apps/sim/app/api/tools/textract/shared.ts +++ b/apps/sim/app/api/tools/textract/shared.ts @@ -12,6 +12,7 @@ import { isModelSafeWorkspaceFileKey, MODEL_UNSAFE_WORKSPACE_FILE_ERROR_MESSAGE, } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' import type { RawFileInput } from '@/lib/uploads/utils/file-utils' import { extractStorageKey, @@ -160,7 +161,10 @@ export async function resolveDocumentInput( const { buffer, contentType } = await downloadServableFileFromStorage( userFile, requestId, - logger + logger, + { + maxBytes: MAX_BUFFERED_TRANSFER_BYTES, + } ) const resolvedContentType = contentType || userFile.type || 'application/octet-stream' diff --git a/apps/sim/app/api/tools/uptimerobot/server-utils.ts b/apps/sim/app/api/tools/uptimerobot/server-utils.ts index d7a1ef9ea8c..9c3c7d2f316 100644 --- a/apps/sim/app/api/tools/uptimerobot/server-utils.ts +++ b/apps/sim/app/api/tools/uptimerobot/server-utils.ts @@ -1,5 +1,6 @@ import type { Logger } from '@sim/logger' import { NextResponse } from 'next/server' +import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils' import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { assertToolFileAccess } from '@/app/api/files/authorization' @@ -47,7 +48,14 @@ async function appendPspImage( const denied = await assertToolFileAccess(userFile.key, userId, requestId, logger) if (denied) return denied - const { buffer, contentType } = await downloadServableFileFromStorage(userFile, requestId, logger) + const { buffer, contentType } = await downloadServableFileFromStorage( + userFile, + requestId, + logger, + { + maxBytes: MAX_BUFFERED_TRANSFER_BYTES, + } + ) const mimeType = contentType || userFile.type || 'application/octet-stream' form.append(field, new Blob([new Uint8Array(buffer)], { type: mimeType }), userFile.name) return null diff --git a/apps/sim/app/api/tools/vanta/upload/route.ts b/apps/sim/app/api/tools/vanta/upload/route.ts index 81785b515c8..932b68658c7 100644 --- a/apps/sim/app/api/tools/vanta/upload/route.ts +++ b/apps/sim/app/api/tools/vanta/upload/route.ts @@ -5,6 +5,7 @@ import { vantaUploadContract } from '@/lib/api/contracts/tools/vanta' import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils' import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -72,7 +73,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const resolved = await downloadServableFileFromStorage(userFile, requestId, logger) + const resolved = await downloadServableFileFromStorage(userFile, requestId, logger, { + maxBytes: MAX_UPLOAD_SIZE_BYTES, + }) fileBuffer = resolved.buffer fileName = params.fileName || userFile.name mimeType = @@ -80,6 +83,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } catch (error) { const notReady = docNotReadyResponse(error) if (notReady) return notReady + if (isPayloadSizeLimitError(error)) { + return uploadSizeError(error.observedBytes ?? userFile.size) + } logger.error(`[${requestId}] Failed to download Vanta upload file`, { error: getErrorMessage(error), }) diff --git a/apps/sim/app/api/tools/vision/analyze/route.ts b/apps/sim/app/api/tools/vision/analyze/route.ts index fb74630943f..74a0cb0560f 100644 --- a/apps/sim/app/api/tools/vision/analyze/route.ts +++ b/apps/sim/app/api/tools/vision/analyze/route.ts @@ -16,6 +16,7 @@ import { isModelSafeWorkspaceFileKey, MODEL_UNSAFE_WORKSPACE_FILE_ERROR_MESSAGE, } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' import { extractStorageKey, isInternalFileUrl, @@ -125,7 +126,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { status: 400 } ) } - const buffer = await downloadFileFromStorage(userFile, requestId, logger) + // The three providers this route serves disagree too much for a single + // route-wide image limit to be right (Anthropic: 10MB base64 per image; + // OpenAI: 512MB total request payload), and picking the lowest would reject + // images the others accept. So bound the buffer we hold and let each provider + // reject what it will not take, with its own message. + const buffer = await downloadFileFromStorage(userFile, requestId, logger, { + maxBytes: MAX_BUFFERED_TRANSFER_BYTES, + }) base64 = buffer.toString('base64') bufferLength = buffer.length } diff --git a/apps/sim/app/api/tools/wordpress/upload/route.ts b/apps/sim/app/api/tools/wordpress/upload/route.ts index aac9e9e3354..b7b9ba8c2a1 100644 --- a/apps/sim/app/api/tools/wordpress/upload/route.ts +++ b/apps/sim/app/api/tools/wordpress/upload/route.ts @@ -5,7 +5,9 @@ import { wordpressUploadContract } from '@/lib/api/contracts/storage-transfer' import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' import { getFileExtension, getMimeTypeFromExtension, @@ -93,7 +95,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { let resolvedContentType: string try { - const servable = await downloadServableFileFromStorage(userFile, requestId, logger) + const servable = await downloadServableFileFromStorage(userFile, requestId, logger, { + maxBytes: MAX_BUFFERED_TRANSFER_BYTES, + }) fileBuffer = servable.buffer resolvedContentType = servable.contentType } catch (error) { @@ -105,7 +109,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { success: false, error: `Failed to download file: ${getErrorMessage(error, 'Unknown error')}`, }, - { status: 500 } + { status: isPayloadSizeLimitError(error) ? 413 : 500 } ) } diff --git a/apps/sim/app/api/users/me/usage-logs/export/route.ts b/apps/sim/app/api/users/me/usage-logs/export/route.ts index cc58ebee2b2..e9c20256455 100644 --- a/apps/sim/app/api/users/me/usage-logs/export/route.ts +++ b/apps/sim/app/api/users/me/usage-logs/export/route.ts @@ -9,8 +9,8 @@ import { toBillingUsageLogSource, toInternalUsageLogSources, } from '@/lib/billing/usage-sources' +import { formatCsvValue, toCsvRow } from '@/lib/core/utils/csv' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { formatCsvValue, toCsvRow } from '@/lib/table/export-format' import { resolveDateRange } from '@/app/api/users/me/usage-logs/shared' const logger = createLogger('UsageLogsExportAPI') diff --git a/apps/sim/app/api/v1/admin/dashboard/organizations/[id]/billing-terms/preview/route.ts b/apps/sim/app/api/v1/admin/dashboard/organizations/[id]/reporting-period/preview/route.ts similarity index 51% rename from apps/sim/app/api/v1/admin/dashboard/organizations/[id]/billing-terms/preview/route.ts rename to apps/sim/app/api/v1/admin/dashboard/organizations/[id]/reporting-period/preview/route.ts index fd5c3d494c4..8ca902fade3 100644 --- a/apps/sim/app/api/v1/admin/dashboard/organizations/[id]/billing-terms/preview/route.ts +++ b/apps/sim/app/api/v1/admin/dashboard/organizations/[id]/reporting-period/preview/route.ts @@ -1,6 +1,6 @@ import { getErrorMessage } from '@sim/utils/errors' -import { previewDashboardEnterpriseBillingTerms } from '@/lib/admin/dashboard' -import { adminDashboardPreviewBillingTermsContract } from '@/lib/api/contracts/v1/admin/dashboard' +import { previewDashboardEnterpriseReportingPeriod } from '@/lib/admin/dashboard' +import { adminDashboardPreviewReportingPeriodContract } from '@/lib/api/contracts/v1/admin/dashboard' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' @@ -13,18 +13,23 @@ import { export const POST = withRouteHandler( withAdminAuthParams<{ id: string }>(async (request, context) => { - const parsed = await parseRequest(adminDashboardPreviewBillingTermsContract, request, context, { - validationErrorResponse: adminValidationErrorResponse, - invalidJsonResponse: adminInvalidJsonResponse, - }) + const parsed = await parseRequest( + adminDashboardPreviewReportingPeriodContract, + request, + context, + { + validationErrorResponse: adminValidationErrorResponse, + invalidJsonResponse: adminInvalidJsonResponse, + } + ) if (!parsed.success) return parsed.response try { return singleResponse( - await previewDashboardEnterpriseBillingTerms(parsed.data.params.id, parsed.data.body) + await previewDashboardEnterpriseReportingPeriod(parsed.data.params.id, parsed.data.body) ) } catch (error) { return badRequestResponse( - getErrorMessage(error, 'Failed to preview Enterprise billing terms') + getErrorMessage(error, 'Failed to preview Enterprise reporting period') ) } }) diff --git a/apps/sim/app/api/v1/admin/dashboard/organizations/[id]/billing-terms/route.ts b/apps/sim/app/api/v1/admin/dashboard/organizations/[id]/reporting-period/route.ts similarity index 57% rename from apps/sim/app/api/v1/admin/dashboard/organizations/[id]/billing-terms/route.ts rename to apps/sim/app/api/v1/admin/dashboard/organizations/[id]/reporting-period/route.ts index e9afd5d98ea..78c2057b99a 100644 --- a/apps/sim/app/api/v1/admin/dashboard/organizations/[id]/billing-terms/route.ts +++ b/apps/sim/app/api/v1/admin/dashboard/organizations/[id]/reporting-period/route.ts @@ -1,6 +1,6 @@ import { getErrorMessage } from '@sim/utils/errors' -import { updateDashboardEnterpriseBillingTerms } from '@/lib/admin/dashboard' -import { adminDashboardUpdateBillingTermsContract } from '@/lib/api/contracts/v1/admin/dashboard' +import { updateDashboardEnterpriseReportingPeriod } from '@/lib/admin/dashboard' +import { adminDashboardUpdateReportingPeriodContract } from '@/lib/api/contracts/v1/admin/dashboard' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getAdminAuditActor } from '@/app/api/v1/admin/dashboard/actor' @@ -14,20 +14,27 @@ import { export const PATCH = withRouteHandler( withAdminAuthParams<{ id: string }>(async (request, context) => { - const parsed = await parseRequest(adminDashboardUpdateBillingTermsContract, request, context, { - validationErrorResponse: adminValidationErrorResponse, - invalidJsonResponse: adminInvalidJsonResponse, - }) + const parsed = await parseRequest( + adminDashboardUpdateReportingPeriodContract, + request, + context, + { + validationErrorResponse: adminValidationErrorResponse, + invalidJsonResponse: adminInvalidJsonResponse, + } + ) if (!parsed.success) return parsed.response try { - await updateDashboardEnterpriseBillingTerms( + await updateDashboardEnterpriseReportingPeriod( parsed.data.params.id, parsed.data.body, await getAdminAuditActor(request) ) return singleResponse({ success: true as const }) } catch (error) { - return badRequestResponse(getErrorMessage(error, 'Failed to update Enterprise billing terms')) + return badRequestResponse( + getErrorMessage(error, 'Failed to update Enterprise reporting period') + ) } }) ) diff --git a/apps/sim/app/api/v2/chat/route.test.ts b/apps/sim/app/api/v2/chat/route.test.ts new file mode 100644 index 00000000000..5c7072319ac --- /dev/null +++ b/apps/sim/app/api/v2/chat/route.test.ts @@ -0,0 +1,307 @@ +/** + * @vitest-environment node + */ + +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + MockV2ApiKeyUnauthenticatedError, + MockWorkspaceAccessDeniedError, + billingAttributionSnapshot, + mockAssertActiveWorkspaceAccess, + mockAuthenticateV2ApiKey, + mockCheckOperationRate, + mockCheckPreAuthRate, + mockGenerateId, + mockRequestExplicitStreamAbort, + mockResolveBillingAttribution, + mockRunHeadlessCopilotLifecycle, +} = vi.hoisted(() => ({ + MockV2ApiKeyUnauthenticatedError: class MockV2ApiKeyUnauthenticatedError extends Error {}, + MockWorkspaceAccessDeniedError: class MockWorkspaceAccessDeniedError extends Error {}, + mockAssertActiveWorkspaceAccess: vi.fn(), + mockAuthenticateV2ApiKey: vi.fn(), + billingAttributionSnapshot: { + actorUserId: 'user-1', + workspaceId: 'workspace-1', + organizationId: null, + billedAccountUserId: 'user-1', + billingEntity: { type: 'user', id: 'user-1' }, + billingPeriod: { start: '2026-08-01T00:00:00.000Z', end: '2026-09-01T00:00:00.000Z' }, + payerSubscription: null, + }, + mockCheckOperationRate: vi.fn(), + mockCheckPreAuthRate: vi.fn(), + mockGenerateId: vi.fn(), + mockResolveBillingAttribution: vi.fn(), + mockRequestExplicitStreamAbort: vi.fn().mockResolvedValue(undefined), + mockRunHeadlessCopilotLifecycle: vi.fn(), +})) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mockAuthenticateV2ApiKey, + V2ApiKeyUnauthenticatedError: MockV2ApiKeyUnauthenticatedError, +})) + +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = mockCheckPreAuthRate + checkRateLimitDirectOrThrow = mockCheckOperationRate + }, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +vi.mock('@sim/utils/id', () => ({ + generateId: mockGenerateId, + generateShortId: vi.fn(() => 'mock-short-id'), +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + assertActiveWorkspaceAccess: mockAssertActiveWorkspaceAccess, + isWorkspaceAccessDeniedError: (error: unknown) => error instanceof MockWorkspaceAccessDeniedError, +})) + +vi.mock('@/lib/billing/core/billing-attribution', () => ({ + resolveBillingAttribution: mockResolveBillingAttribution, +})) + +vi.mock('@/lib/environment/utils', () => ({ + getPersonalAndWorkspaceEnv: vi.fn().mockResolvedValue({ personal: {}, workspace: {} }), +})) + +vi.mock('@/lib/copilot/environment-context', () => ({ + createCopilotEnvironmentContext: vi.fn().mockResolvedValue({ id: 'env-context' }), +})) + +vi.mock('@/lib/copilot/chat/workspace-context', () => ({ + generateWorkspaceContext: vi.fn().mockResolvedValue('workspace context'), +})) + +vi.mock('@/lib/copilot/chat/payload', () => ({ + buildIntegrationToolSchemas: vi.fn().mockResolvedValue([{ name: 'run_workflow' }]), +})) + +vi.mock('@/lib/copilot/entitlements', () => ({ + computeWorkspaceEntitlements: vi.fn().mockResolvedValue([]), +})) + +vi.mock('@/lib/copilot/request/lifecycle/headless', () => ({ + runHeadlessCopilotLifecycle: mockRunHeadlessCopilotLifecycle, +})) + +vi.mock('@/lib/copilot/request/session/explicit-abort', () => ({ + requestExplicitStreamAbort: mockRequestExplicitStreamAbort, +})) + +vi.mock('@/lib/copilot/secret-mount-policy', () => ({ + normalizeSecretMountPolicy: vi.fn(() => ({ secretScope: 'all', mountedSecrets: [] })), +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ + isDocSandboxEnabled: false, +})) + +import { POST } from '@/app/api/v2/chat/route' + +const personalAuth = { + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'], + rateLimitSubscription: null, + keyType: 'personal', +} + +const successResult = { + success: true, + content: 'Hello there', + toolCalls: [{ name: 'run_workflow' }, { name: 'internal_only' }], + usage: { prompt: 10, completion: 5 }, + cost: { total: 0.01 }, +} + +function callChat(body: Record, headers: Record = {}) { + const req = createMockRequest('POST', body, { 'X-API-Key': 'test-key', ...headers }) + return POST(req, { params: Promise.resolve({}) }) +} + +async function readNdjsonEvents(response: Response): Promise>> { + const raw = await response.text() + return raw + .split('\n') + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line)) +} + +describe('POST /api/v2/chat', () => { + beforeEach(() => { + vi.clearAllMocks() + let generated = 0 + mockGenerateId.mockImplementation(() => `generated-${++generated}`) + mockAuthenticateV2ApiKey.mockResolvedValue(personalAuth) + mockCheckPreAuthRate.mockResolvedValue({ allowed: true, remaining: 10, resetAt: new Date() }) + mockCheckOperationRate.mockResolvedValue({ allowed: true, remaining: 10, resetAt: new Date() }) + mockAssertActiveWorkspaceAccess.mockResolvedValue({ permission: 'admin' }) + mockResolveBillingAttribution.mockResolvedValue(billingAttributionSnapshot) + mockRequestExplicitStreamAbort.mockResolvedValue(undefined) + mockRunHeadlessCopilotLifecycle.mockResolvedValue(successResult) + }) + + it('rejects a missing or invalid API key', async () => { + mockAuthenticateV2ApiKey.mockRejectedValue( + new MockV2ApiKeyUnauthenticatedError('API key required') + ) + + const response = await callChat({ workspaceId: 'workspace-1', message: 'hi' }) + + expect(response.status).toBe(401) + }) + + it('rejects a workspace API key: chat has no acting user to attribute', async () => { + mockAuthenticateV2ApiKey.mockResolvedValue({ + ...personalAuth, + principal: { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'key-2' }, + keyType: 'workspace', + }) + + const response = await callChat({ workspaceId: 'workspace-1', message: 'hi' }) + + expect(response.status).toBe(403) + const body = await response.json() + expect(body.error.details.code).toBe('PRINCIPAL_KIND_NOT_PERMITTED') + expect(mockRunHeadlessCopilotLifecycle).not.toHaveBeenCalled() + }) + + it('rejects an empty message before running anything', async () => { + const response = await callChat({ workspaceId: 'workspace-1', message: '' }) + + expect(response.status).toBe(400) + expect(mockRunHeadlessCopilotLifecycle).not.toHaveBeenCalled() + }) + + it('answers 403 when the caller cannot access the workspace', async () => { + mockAssertActiveWorkspaceAccess.mockRejectedValue(new MockWorkspaceAccessDeniedError('denied')) + + const response = await callChat({ workspaceId: 'workspace-1', message: 'hi' }) + + expect(response.status).toBe(403) + expect(mockRunHeadlessCopilotLifecycle).not.toHaveBeenCalled() + }) + + it('runs one turn and answers the reply with a generated conversation id', async () => { + const response = await callChat({ workspaceId: 'workspace-1', message: 'hi' }) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.data).toEqual({ + content: 'Hello there', + model: 'mothership', + conversationId: 'generated-1', + tokens: { prompt: 10, completion: 5, total: 15 }, + cost: { total: 0.01 }, + toolCalls: [{ name: 'run_workflow' }], + }) + + const [payload, options] = mockRunHeadlessCopilotLifecycle.mock.calls[0] + expect(payload).toMatchObject({ + messages: [{ role: 'user', content: 'hi' }], + userId: 'user-1', + workspaceId: 'workspace-1', + chatId: 'generated-1', + mode: 'agent', + isHosted: true, + workspaceContext: 'workspace context', + integrationTools: [{ name: 'run_workflow' }], + userPermission: 'admin', + }) + expect(options).toMatchObject({ + userId: 'user-1', + workspaceId: 'workspace-1', + chatId: 'generated-1', + goRoute: '/api/mothership/execute', + autoExecuteTools: true, + interactive: false, + // Hosted execution refuses to run without attribution, so the resolved + // snapshot must always ride along. + billingAttribution: billingAttributionSnapshot, + }) + expect(mockResolveBillingAttribution).toHaveBeenCalledWith({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + }) + }) + + it('continues the conversation the caller names', async () => { + const response = await callChat({ + workspaceId: 'workspace-1', + message: 'and then?', + conversationId: 'conv-9', + }) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.data.conversationId).toBe('conv-9') + expect(mockRunHeadlessCopilotLifecycle.mock.calls[0][0]).toMatchObject({ chatId: 'conv-9' }) + }) + + it('answers a failed run as a 500 with the run error', async () => { + mockRunHeadlessCopilotLifecycle.mockResolvedValue({ success: false, error: 'model exploded' }) + + const response = await callChat({ workspaceId: 'workspace-1', message: 'hi' }) + + expect(response.status).toBe(500) + const body = await response.json() + expect(body.error.message).toBe('model exploded') + }) + + it('streams heartbeats, chunks, and a final event for NDJSON callers', async () => { + mockRunHeadlessCopilotLifecycle.mockImplementation( + async (_payload: unknown, options: { onEvent?: (event: unknown) => Promise }) => { + await options.onEvent?.({ + type: 'text', + payload: { channel: 'assistant', text: 'Hello' }, + }) + await options.onEvent?.({ + type: 'text', + payload: { channel: 'assistant', text: 'Hello there' }, + }) + return successResult + } + ) + + const response = await callChat( + { workspaceId: 'workspace-1', message: 'hi' }, + { accept: 'application/x-ndjson' } + ) + + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toContain('application/x-ndjson') + const events = await readNdjsonEvents(response) + + expect(events[0].type).toBe('heartbeat') + const chunks = events.filter((event) => event.type === 'chunk') + expect(chunks.map((chunk) => chunk.content)).toEqual(['Hello', ' there']) + const final = events.at(-1) as { type: string; data: Record } + expect(final.type).toBe('final') + expect(final.data).toMatchObject({ content: 'Hello there', conversationId: 'generated-1' }) + }) + + it('ends the NDJSON stream with an error event when the run fails', async () => { + mockRunHeadlessCopilotLifecycle.mockResolvedValue({ success: false, error: 'model exploded' }) + + const response = await callChat( + { workspaceId: 'workspace-1', message: 'hi' }, + { accept: 'application/x-ndjson' } + ) + + expect(response.status).toBe(200) + const events = await readNdjsonEvents(response) + const last = events.at(-1) as { type: string; error?: string } + expect(last.type).toBe('error') + expect(last.error).toBe('model exploded') + }) +}) diff --git a/apps/sim/app/api/v2/chat/route.ts b/apps/sim/app/api/v2/chat/route.ts new file mode 100644 index 00000000000..a7df7680448 --- /dev/null +++ b/apps/sim/app/api/v2/chat/route.ts @@ -0,0 +1,386 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import type { NextRequest } from 'next/server' +import { v2ChatContract } from '@/lib/api/contracts/v2/chat' +import { parseRequest } from '@/lib/api/server' +import { + admitV2Request, + V2_PARSE_DEFAULTS, + V2RouteInfrastructureError, + v2ApiKeyAuth, + v2RateLimits, +} from '@/lib/api/server/routes' +import { resolveBillingAttribution } from '@/lib/billing/core/billing-attribution' +import { chatOperations } from '@/lib/copilot/application/operations' +import { buildIntegrationToolSchemas } from '@/lib/copilot/chat/payload' +import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context' +import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements' +import { + type CopilotEnvironmentContext, + createCopilotEnvironmentContext, +} from '@/lib/copilot/environment-context' +import { + MothershipStreamV1EventType, + MothershipStreamV1TextChannel, +} from '@/lib/copilot/generated/mothership-stream-v1' +import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless' +import { requestExplicitStreamAbort } from '@/lib/copilot/request/session/explicit-abort' +import type { StreamEvent } from '@/lib/copilot/request/types' +import { normalizeSecretMountPolicy } from '@/lib/copilot/secret-mount-policy' +import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' +import { + assertActiveWorkspaceAccess, + isWorkspaceAccessDeniedError, +} from '@/lib/workspaces/permissions/utils' +import { v2Data, v2Error } from '@/app/api/v2/lib/response' + +export const dynamic = 'force-dynamic' +export const maxDuration = 3600 + +const logger = createLogger('V2ChatAPI') + +const CHAT_STREAM_CONTENT_TYPE = 'application/x-ndjson' +const CHAT_STREAM_HEADER = 'x-mothership-execute-stream' +const CHAT_STREAM_VALUE = 'ndjson' +const CHAT_HEARTBEAT_INTERVAL_MS = 15_000 +const ndjsonEncoder = new TextEncoder() + +function isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === 'AbortError' +} + +function wantsStreamedChatResponse(req: NextRequest): boolean { + return ( + req.headers.get(CHAT_STREAM_HEADER) === CHAT_STREAM_VALUE || + req.headers.get('accept')?.includes(CHAT_STREAM_CONTENT_TYPE) === true + ) +} + +function encodeNdjson(value: unknown): Uint8Array { + return ndjsonEncoder.encode(`${JSON.stringify(value)}\n`) +} + +/** + * Same projection as the Sim Chat block's execute endpoint: the full assistant + * reply, the conversation id that continues this conversation, and the client + * tool calls the run surfaced. + */ +function buildChatResultPayload( + result: Awaited>, + conversationId: string, + integrationTools: Array<{ name: string }> +) { + const clientToolNames = new Set(integrationTools.map((t) => t.name)) + const clientToolCalls = (result.toolCalls || []).filter( + (tc: { name: string }) => clientToolNames.has(tc.name) || tc.name.startsWith('mcp-') + ) + + return { + content: result.content ?? '', + model: 'mothership', + conversationId, + tokens: result.usage + ? { + prompt: result.usage.prompt, + completion: result.usage.completion, + total: (result.usage.prompt || 0) + (result.usage.completion || 0), + } + : {}, + cost: result.cost || undefined, + toolCalls: clientToolCalls, + } +} + +/** + * POST /api/v2/chat + * + * One conversational turn against the same headless execution path as the Sim + * Chat block (`/api/mothership/execute`), authenticated with a personal API key + * instead of the executor's internal JWT. JSON callers get one final response; + * NDJSON callers (`Accept: application/x-ndjson`) get heartbeats and incremental + * `chunk` events followed by a `final` event, so long-running turns do not look + * idle to intermediaries. + * + * A raw special route rather than a builder route: the response is a + * long-running protocol stream, and the work is copilot orchestration rather + * than a domain use case. + */ +export const POST = withRouteHandler( + async (req: NextRequest) => { + const admission = await admitV2Request( + req, + chatOperations.send, + v2ApiKeyAuth, + v2RateLimits.publicApi + ) + if (!admission.success) return admission.response + const { principal } = admission.auth + + if (principal.kind !== 'personal_api_key') { + return v2Error('FORBIDDEN', 'Chat requires a personal API key', { + details: { code: 'PRINCIPAL_KIND_NOT_PERMITTED' }, + }) + } + const userId = principal.userId + + const parsed = await parseRequest(v2ChatContract, req, {}, { ...V2_PARSE_DEFAULTS }) + if (!parsed.success) return parsed.response + const { workspaceId, message, conversationId } = parsed.data.body + + const chatId = conversationId || generateId() + const messageId = generateId() + const requestId = generateId() + const reqLogger = logger.withMetadata({ chatId, messageId, requestId }) + + try { + const workspaceAccess = await assertActiveWorkspaceAccess(workspaceId, userId) + const userPermission = workspaceAccess.permission + const secretMountPolicy = normalizeSecretMountPolicy(undefined) + + let environmentContext: CopilotEnvironmentContext | undefined + try { + const environment = await getPersonalAndWorkspaceEnv(userId, workspaceId, { + workspaceAccess, + }) + environmentContext = await createCopilotEnvironmentContext(userId, workspaceId, environment) + } catch (error) { + reqLogger.warn('Failed to build chat environment context', { + error: getErrorMessage(error), + userId, + workspaceId, + }) + } + + const [workspaceContext, integrationTools, entitlements, billingAttribution] = + await Promise.all([ + generateWorkspaceContext(workspaceId, userId, { workspaceAccess, secretMountPolicy }), + buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId), + computeWorkspaceEntitlements(workspaceId, userId), + // Hosted execution refuses to run without an attribution snapshot; + // the executor path receives it as a header, this path resolves it + // from the authenticated actor and asserted workspace. + resolveBillingAttribution({ actorUserId: userId, workspaceId }), + ]) + + const requestPayload: Record = { + messages: [{ role: 'user', content: message }], + userId, + workspaceId, + chatId, + mode: 'agent', + messageId, + isHosted: true, + workspaceContext, + ...(isDocSandboxEnabled ? { docCompiler: 'python' } : {}), + ...(integrationTools.length > 0 ? { integrationTools } : {}), + ...(userPermission ? { userPermission } : {}), + ...(entitlements.length > 0 ? { entitlements } : {}), + } + + let allowExplicitAbort = true + let explicitAbortRequest: Promise | undefined + const lifecycleAbortController = new AbortController() + const requestExplicitAbortOnce = () => { + if (!allowExplicitAbort || explicitAbortRequest) { + return + } + + explicitAbortRequest = requestExplicitStreamAbort({ + streamId: messageId, + userId, + chatId, + workspaceId, + }).catch((error) => { + reqLogger.warn('Failed to send explicit abort for chat request', { + error: toError(error).message, + }) + }) + } + const abortLifecycle = (reason?: unknown) => { + if (!lifecycleAbortController.signal.aborted) { + lifecycleAbortController.abort(reason ?? 'chat_request_aborted') + } + requestExplicitAbortOnce() + } + const onAbort = () => { + abortLifecycle(req.signal.reason ?? 'request_aborted') + } + + if (req.signal.aborted) { + onAbort() + } else { + req.signal.addEventListener('abort', onAbort, { once: true }) + } + + const runLifecycle = (onEvent?: (event: StreamEvent) => Promise) => + runHeadlessCopilotLifecycle(requestPayload, { + userId, + workspaceId, + chatId, + simRequestId: requestId, + // The Go copilot route this turn is POSTed to — the same headless + // execute surface the Sim Chat block uses (it also selects the + // mothership sandbox profile for code tools). + goRoute: '/api/mothership/execute', + autoExecuteTools: true, + interactive: false, + abortSignal: lifecycleAbortController.signal, + billingAttribution, + ...(userPermission ? { userPermission } : {}), + secretActorUserId: userId, + secretMountPolicy, + environmentContext, + onEvent, + }) + + if (wantsStreamedChatResponse(req)) { + let cancelled = false + let heartbeatId: ReturnType | undefined + + const stream = new ReadableStream({ + start(controller) { + let forwardedAssistantContent = '' + const send = (event: unknown) => { + if (!cancelled) { + controller.enqueue(encodeNdjson(event)) + } + } + + // Flush response headers promptly and keep long turns from looking + // idle to proxy HTTP stacks. + send({ type: 'heartbeat', timestamp: new Date().toISOString() }) + heartbeatId = setInterval(() => { + send({ type: 'heartbeat', timestamp: new Date().toISOString() }) + }, CHAT_HEARTBEAT_INTERVAL_MS) + + void (async () => { + try { + const result = await runLifecycle(async (event) => { + if ( + event.type === MothershipStreamV1EventType.text && + event.payload.channel === MothershipStreamV1TextChannel.assistant && + event.payload.text + ) { + const text = event.payload.text + const content = text.startsWith(forwardedAssistantContent) + ? text.slice(forwardedAssistantContent.length) + : text + if (content) { + forwardedAssistantContent += content + send({ type: 'chunk', content }) + } + } + }) + allowExplicitAbort = false + + if (lifecycleAbortController.signal.aborted) { + send({ type: 'error', error: 'Chat request aborted' }) + return + } + + if (!result.success) { + reqLogger.error('Chat request failed', { + error: result.error, + errors: result.errors, + }) + send({ + type: 'error', + error: result.error || 'Chat request failed', + content: result.content || '', + }) + return + } + + send({ + type: 'final', + data: buildChatResultPayload(result, chatId, integrationTools), + }) + } catch (error) { + if ( + lifecycleAbortController.signal.aborted || + req.signal.aborted || + isAbortError(error) + ) { + reqLogger.info('Chat request aborted') + send({ type: 'error', error: 'Chat request aborted' }) + return + } + + reqLogger.error('Chat request error', { + error: getErrorMessage(error, 'Unknown error'), + }) + send({ type: 'error', error: getErrorMessage(error, 'Internal server error') }) + } finally { + allowExplicitAbort = false + if (heartbeatId) { + clearInterval(heartbeatId) + } + req.signal.removeEventListener('abort', onAbort) + await explicitAbortRequest + if (!cancelled) { + controller.close() + } + } + })() + }, + cancel(reason) { + cancelled = true + if (heartbeatId) { + clearInterval(heartbeatId) + } + abortLifecycle(reason ?? 'chat_stream_cancelled') + }, + }) + + return new Response(stream, { + headers: { + 'Content-Type': `${CHAT_STREAM_CONTENT_TYPE}; charset=utf-8`, + 'Cache-Control': 'no-cache, no-transform', + }, + }) + } + + try { + const result = await runLifecycle() + allowExplicitAbort = false + + if (lifecycleAbortController.signal.aborted || req.signal.aborted) { + reqLogger.info('Chat request aborted after lifecycle completion') + return v2Error('CLIENT_CLOSED_REQUEST', 'Chat request aborted') + } + + if (!result.success) { + reqLogger.error('Chat request failed', { error: result.error, errors: result.errors }) + return v2Error('INTERNAL_ERROR', result.error || 'Chat request failed') + } + + return v2Data(buildChatResultPayload(result, chatId, integrationTools)) + } finally { + allowExplicitAbort = false + req.signal.removeEventListener('abort', onAbort) + await explicitAbortRequest + } + } catch (error) { + if (req.signal.aborted || isAbortError(error)) { + reqLogger.info('Chat request aborted') + return v2Error('CLIENT_CLOSED_REQUEST', 'Chat request aborted') + } + + if (isWorkspaceAccessDeniedError(error)) { + return v2Error('FORBIDDEN', 'Workspace access denied') + } + + reqLogger.error('Chat request error', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + }, + { + unhandledErrorResponse: ({ error }) => + error instanceof V2RouteInfrastructureError + ? v2Error('SERVICE_UNAVAILABLE', 'Service temporarily unavailable') + : v2Error('INTERNAL_ERROR', 'Internal server error'), + } +) diff --git a/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts index 7d33f8c91f9..4a45583c1ae 100644 --- a/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts @@ -210,7 +210,7 @@ describe('PATCH /api/v2/files/[fileId]/share', () => { workspaceId: WORKSPACE_ID, isActive: true, authType: 'password', - password: 'hunter2hunter2', + password: 'hunter2hunter2!', }) expect(response.status).toBe(200) @@ -222,7 +222,7 @@ describe('PATCH /api/v2/files/[fileId]/share', () => { assertedWorkspaceId: WORKSPACE_ID, isActive: true, authType: 'password', - password: 'hunter2hunter2', + password: 'hunter2hunter2!', allowedEmails: undefined, }, request: expect.anything(), diff --git a/apps/sim/app/api/v2/knowledge/[id]/connectors/route.test.ts b/apps/sim/app/api/v2/knowledge/[id]/connectors/route.test.ts index 685a477429a..d2e58cc9b38 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/connectors/route.test.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/connectors/route.test.ts @@ -264,6 +264,31 @@ describe('v2 knowledge connector routes', () => { expect(mocks.connectorRemoved).toHaveBeenCalledOnce() }) + it('passes source changes to application billing without an adapter resolver', async () => { + const response = await updateConnector( + request(`/api/v2/knowledge/${KNOWLEDGE_BASE_ID}/connectors/${CONNECTOR_ID}`, 'PATCH', { + workspaceId: WORKSPACE_ID, + sourceConfig: { pageIds: ['page-2'] }, + }), + connectorContext + ) + + expect(response.status).toBe(200) + expect(mocks.update).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + assertedWorkspaceId: WORKSPACE_ID, + updates: expect.objectContaining({ sourceConfig: { pageIds: ['page-2'] } }), + }), + }) + ) + expect(mocks.update).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.not.objectContaining({ resolveBillingAttribution: expect.anything() }), + }) + ) + }) + it('queues connector synchronization without an adapter billing resolver', async () => { const response = await syncConnector( request(`/api/v2/knowledge/${KNOWLEDGE_BASE_ID}/connectors/${CONNECTOR_ID}/sync`, 'POST', { diff --git a/apps/sim/app/desktop/connect/connect-launcher.tsx b/apps/sim/app/desktop/connect/connect-launcher.tsx index c1c8bfde4a4..fdb6276aea3 100644 --- a/apps/sim/app/desktop/connect/connect-launcher.tsx +++ b/apps/sim/app/desktop/connect/connect-launcher.tsx @@ -8,8 +8,13 @@ import { DesktopHandoffShell } from '@/app/desktop/components/desktop-handoff-sh interface ConnectLauncherProps { providerId: string - /** Same-origin path better-auth returns the browser to after the callback. */ - completePath: string + /** + * Absolute URL better-auth returns the browser to after the callback. Better + * Auth stores it verbatim in the OAuth state and the callback reads the + * credential draft back off it, so a bare path would be parsed without an + * origin — keep this a full URL, as every other connect surface passes. + */ + completeUrl: string } /** @@ -19,7 +24,7 @@ interface ConnectLauncherProps { * leaves for the provider immediately, so the UI is just a brief interstitial * plus an error state with retry. */ -export function ConnectLauncher({ providerId, completePath }: ConnectLauncherProps) { +export function ConnectLauncher({ providerId, completeUrl }: ConnectLauncherProps) { const startedRef = useRef(false) const [error, setError] = useState(null) @@ -28,18 +33,18 @@ export function ConnectLauncher({ providerId, completePath }: ConnectLauncherPro try { await client.oauth2.link({ providerId, - callbackURL: completePath, + callbackURL: completeUrl, // Failed flows bounce to the same complete page (which forwards the // failure to the loopback) instead of waiting out the handoff TTL. // Do NOT bake in a query param here: better-auth appends its own // `&error=`, and a second `error` key deserializes to an array // that the complete page can't read — so it would look like success. - errorCallbackURL: completePath, + errorCallbackURL: completeUrl, }) } catch (err) { setError(getErrorMessage(err, 'Could not start the connection.')) } - }, [providerId, completePath]) + }, [providerId, completeUrl]) useEffect(() => { if (startedRef.current) return diff --git a/apps/sim/app/desktop/connect/page.test.tsx b/apps/sim/app/desktop/connect/page.test.tsx new file mode 100644 index 00000000000..d44f769d668 --- /dev/null +++ b/apps/sim/app/desktop/connect/page.test.tsx @@ -0,0 +1,160 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession, mockRedirect, baseUrl } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockRedirect: vi.fn((url: string) => { + throw new Error(`NEXT_REDIRECT:${url}`) + }), + /** Mutable so a test can give the deployment a trailing-slash base URL. */ + baseUrl: { value: 'https://sim.test' }, +})) + +vi.mock('@/lib/auth', () => ({ + auth: { api: { getSession: mockGetSession } }, + getSession: vi.fn(), +})) + +vi.mock('@/lib/auth/auth-client', () => ({ + client: { oauth2: { link: vi.fn() } }, + signOut: vi.fn(), +})) + +vi.mock('@/lib/core/utils/urls', () => ({ + getBaseUrl: () => baseUrl.value, +})) + +/** Keeps the landing-page barrel the real shell pulls in out of this graph. */ +vi.mock('@/app/desktop/components/desktop-handoff-shell', () => ({ + DesktopHandoffShell: () => null, +})) + +vi.mock('next/navigation', () => ({ + redirect: mockRedirect, +})) + +vi.mock('next/headers', () => ({ + headers: vi.fn(async () => new Headers()), +})) + +import DesktopConnectPage from '@/app/desktop/connect/page' + +const VALID_STATE = 'a'.repeat(32) +const PORT = '57979' + +function pageProps(params: Record) { + return { searchParams: Promise.resolve(params) } +} + +async function renderPage(params: Record) { + const result = (await DesktopConnectPage(pageProps(params))) as unknown as { + type: { name: string } + props: Record + } + return result +} + +describe('DesktopConnectPage', () => { + beforeEach(() => { + vi.clearAllMocks() + baseUrl.value = 'https://sim.test' + mockGetSession.mockResolvedValue({ user: { id: 'user-1', email: 'user@example.com' } }) + }) + + it('hands the launcher an absolute complete URL so the callback can read the draft back', async () => { + // Better Auth stores `callbackURL` verbatim, and the OAuth callback parses it + // with `new URL`. A bare path threw there, failing the whole callback with a + // 500 after the provider had already authorized. + const result = await renderPage({ + provider: 'google-email', + state: VALID_STATE, + port: PORT, + draftId: 'draft-1', + }) + + expect(result.type.name).toBe('ConnectLauncher') + expect(result.props.providerId).toBe('google-email') + + const completeUrl = new URL(result.props.completeUrl as string) + expect(completeUrl.origin).toBe('https://sim.test') + expect(completeUrl.pathname).toBe('/desktop/connect/complete') + expect(completeUrl.searchParams.get('state')).toBe(VALID_STATE) + expect(completeUrl.searchParams.get('port')).toBe(PORT) + expect(completeUrl.searchParams.get('credentialDraftId')).toBe('draft-1') + }) + + it('keeps the complete URL absolute when no draft rides along', async () => { + const result = await renderPage({ + provider: 'google-email', + state: VALID_STATE, + port: PORT, + }) + + expect(result.type.name).toBe('ConnectLauncher') + expect(() => new URL(result.props.completeUrl as string)).not.toThrow() + }) + + it('keeps the completion route intact when the deployment base URL has a trailing slash', async () => { + // `//desktop/connect/complete` matches no route, so the provider result + // would never reach the loopback and the connect would hang. + baseUrl.value = 'https://sim.test/' + + const launcher = await renderPage({ + provider: 'google-email', + state: VALID_STATE, + port: PORT, + }) + expect(new URL(launcher.props.completeUrl as string).pathname).toBe('/desktop/connect/complete') + + await expect( + DesktopConnectPage( + pageProps({ + provider: 'google-email', + state: VALID_STATE, + port: PORT, + workspaceId: 'workspace-1', + }) + ) + ).rejects.toThrow('NEXT_REDIRECT:') + const callbackUrl = new URL(mockRedirect.mock.calls[0][0]).searchParams.get('callbackURL') + expect(new URL(callbackUrl as string).pathname).toBe('/desktop/connect/complete') + }) + + it('sends a workspace-scoped connect to the authorize route with an absolute callback', async () => { + await expect( + DesktopConnectPage( + pageProps({ + provider: 'google-email', + state: VALID_STATE, + port: PORT, + workspaceId: 'workspace-1', + }) + ) + ).rejects.toThrow('NEXT_REDIRECT:') + + const authorize = new URL(mockRedirect.mock.calls[0][0]) + expect(authorize.pathname).toBe('/api/auth/oauth2/authorize') + expect(authorize.searchParams.get('providerId')).toBe('google-email') + expect(authorize.searchParams.get('workspaceId')).toBe('workspace-1') + expect(authorize.searchParams.get('callbackURL')).toBe( + `https://sim.test/desktop/connect/complete?state=${VALID_STATE}&port=${PORT}` + ) + }) + + it('rejects a malformed request without reading the session', async () => { + const invalid = [ + { provider: 'Google', state: VALID_STATE, port: PORT }, + { provider: 'google-email', state: 'short', port: PORT }, + { provider: 'google-email', state: VALID_STATE }, + { provider: 'google-email', state: VALID_STATE, port: PORT, draftId: 'bad draft' }, + ] + + for (const params of invalid) { + const result = await renderPage(params) + expect(result.type.name).toBe('InvalidRequest') + } + expect(mockGetSession).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/desktop/connect/page.tsx b/apps/sim/app/desktop/connect/page.tsx index 4e473134b6b..9110d733599 100644 --- a/apps/sim/app/desktop/connect/page.tsx +++ b/apps/sim/app/desktop/connect/page.tsx @@ -34,6 +34,17 @@ function InvalidRequest() { ) } +/** + * Absolute URL better-auth returns the browser to once the OAuth callback is + * done. Composed through the URL API rather than concatenated, so a trailing + * slash on `NEXT_PUBLIC_APP_URL` cannot yield a `//desktop/...` pathname that + * matches no route — this page is what bounces the result to the app's + * loopback, so a base-URL typo would otherwise strand the whole flow. + */ +function buildConnectCompleteUrl(state: string, port: number, draftId?: string): string { + return new URL(buildConnectCompletePath(state, port, draftId), getBaseUrl()).toString() +} + /** * Desktop OAuth-connect landing. The desktop app opens this page in the * system browser with the provider to connect, a one-time state, and the port @@ -112,10 +123,7 @@ export default async function DesktopConnectPage({ searchParams }: DesktopConnec const authorize = new URL('/api/auth/oauth2/authorize', getBaseUrl()) authorize.searchParams.set('providerId', providerId) authorize.searchParams.set('workspaceId', workspaceId) - authorize.searchParams.set( - 'callbackURL', - `${getBaseUrl()}${buildConnectCompletePath(state, port)}` - ) + authorize.searchParams.set('callbackURL', buildConnectCompleteUrl(state, port)) if (credentialId) { authorize.searchParams.set('credentialId', credentialId) } @@ -125,7 +133,7 @@ export default async function DesktopConnectPage({ searchParams }: DesktopConnec return ( ) } diff --git a/apps/sim/app/workspace/[workspaceId]/chat/[chatId]/loading.tsx b/apps/sim/app/workspace/[workspaceId]/chat/[chatId]/loading.tsx new file mode 100644 index 00000000000..ff1e96e036e --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/chat/[chatId]/loading.tsx @@ -0,0 +1,19 @@ +import { HomeFallback } from '@/app/workspace/[workspaceId]/home/home-fallback' + +/** + * Route-level loading boundary for a chat. + * + * Its real job is prefetching, not painting. With `cacheComponents` off, a + * default `` prefetch degrades to Next's LoadingBoundary strategy, which + * prefetches a dynamic route only as far as its nearest `loading` segment — so + * a route without one is prefetched as nothing, and clicking a chat leaves the + * previous chat frozen on screen until the server responds. This file is what + * makes that click commit immediately. + * + * Renders the same surface `HomeFallback` gives the Suspense boundary inside + * the page, so the loading frame and the mounted frame share a background and + * the transition reads as one step rather than two. + */ +export default function ChatLoading() { + return +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-detail-layout.tsx b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-detail-layout.tsx index 8efb140820d..b19f6aaea86 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-detail-layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-detail-layout.tsx @@ -7,6 +7,13 @@ interface CredentialDetailLayoutProps { back: ReactNode /** Optional controls grouped at the end of the action bar. */ actions?: ReactNode + /** + * Page title, for a view whose subject is the view itself rather than a resource — the same + * slot `SettingsPanel` fills for a detail sub-view like the Forks "Activity" page. A surface + * that leads with a resource uses {@link CredentialDetailHeading} instead; the two are + * alternatives, not a pair. + */ + title?: ReactNode children: ReactNode } @@ -16,7 +23,12 @@ interface CredentialDetailLayoutProps { * supply the slots and body sections; all layout chrome lives here so callsites * stay free of bespoke styling. */ -export function CredentialDetailLayout({ back, actions, children }: CredentialDetailLayoutProps) { +export function CredentialDetailLayout({ + back, + actions, + title, + children, +}: CredentialDetailLayoutProps) { return (
@@ -24,7 +36,11 @@ export function CredentialDetailLayout({ back, actions, children }: CredentialDe {actions ?
{actions}
: null}
-
{children}
+
+ {/* Same element, class and column position the settings shell gives its page title. */} + {title ?

{title}

: null} + {children} +
) diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-context-menu.tsx index 41b8682aef6..35f72a05818 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-context-menu.tsx @@ -14,6 +14,7 @@ import { import { Duplicate, Eye, FolderInput, Pencil, Pin, Trash } from '@sim/emcn/icons' import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders/move-options' import { renderMoveOptions } from '@/app/workspace/[workspaceId]/components/folders/move-options' +import { selectionActionLabel } from '@/app/workspace/[workspaceId]/components/resource/selection-label' interface FolderContextMenuProps { isOpen: boolean @@ -29,6 +30,7 @@ interface FolderContextMenuProps { pinned: boolean moveOptions?: MoveOptionNode[] canEdit: boolean + selectedCount: number } /** @@ -56,8 +58,12 @@ export const FolderContextMenu = memo(function FolderContextMenu({ pinned, moveOptions, canEdit, + selectedCount, }: FolderContextMenuProps) { + const isMultiSelect = selectedCount > 1 const hasMove = Boolean(onMove && moveOptions && moveOptions.length > 0) + const hasActionsAboveDestructive = !isMultiSelect || hasMove + const hasAvailableActions = !isMultiSelect || canEdit return ( !open && onClose()} modal={false}> @@ -75,43 +81,54 @@ export const FolderContextMenu = memo(function FolderContextMenu({ sideOffset={4} onCloseAutoFocus={(e) => e.preventDefault()} > - - - Open - - - - {pinned ? 'Unpin' : 'Pin'} - - {onCopyId && ( - - - Copy ID - - )} - {canEdit && ( + {!hasAvailableActions ? ( + No actions available + ) : ( <> - - - - Rename - - {hasMove && ( - - - - Move to - - - {renderMoveOptions(moveOptions!, onMove!)} - - + {!isMultiSelect && ( + <> + + + Open + + + + {pinned ? 'Unpin' : 'Pin'} + + {onCopyId && ( + + + Copy ID + + )} + + )} + {canEdit && ( + <> + {!isMultiSelect && ( + + + Rename + + )} + {hasMove && ( + + + + {selectionActionLabel('Move', selectedCount, 'Move to')} + + + {renderMoveOptions(moveOptions!, onMove!)} + + + )} + {hasActionsAboveDestructive && } + + + {selectionActionLabel('Delete', selectedCount)} + + )} - - - - Delete - )} diff --git a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx index 9e0085ae9cd..2baae14945d 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx @@ -8,6 +8,7 @@ import { ChipModalField, ChipModalFooter, ChipModalHeader, + type ClipboardContent, cn, Duplicate, Split, @@ -15,6 +16,7 @@ import { ThumbsUp, Tooltip, toast, + useCopyToClipboard, } from '@sim/emcn' import { useParams, useRouter } from 'next/navigation' import { isLiveAssistantMessageId } from '@/lib/copilot/chat/effective-transcript' @@ -23,34 +25,15 @@ import { useSubmitCopilotFeedback } from '@/hooks/queries/copilot-feedback' import { useForkMothershipChat } from '@/hooks/queries/mothership-chats' import { useFolderStore } from '@/stores/folders/store' -const SPECIAL_TAGS = 'thinking|options|usage_upgrade|credential|mothership-error|file|question' - -function toPlainText(raw: string): string { - return ( - raw - // Strip special tags and their contents - .replace(new RegExp(`<\\/?(${SPECIAL_TAGS})(?:>[\\s\\S]*?<\\/(${SPECIAL_TAGS})>|>)`, 'g'), '') - // Strip markdown - .replace(/^#{1,6}\s+/gm, '') - .replace(/\*\*(.+?)\*\*/g, '$1') - .replace(/\*(.+?)\*/g, '$1') - .replace(/`{3}[\s\S]*?`{3}/g, '') - .replace(/`(.+?)`/g, '$1') - .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') - .replace(/^[>\-*]\s+/gm, '') - .replace(/!\[[^\]]*\]\([^)]+\)/g, '') - // Normalize whitespace - .replace(/\n{3,}/g, '\n\n') - .trim() - ) -} - const ICON_CLASS = 'size-[14px]' const BUTTON_CLASS = 'flex size-[26px] items-center justify-center rounded-[6px] text-[var(--text-icon)] transition-colors hover-hover:bg-[var(--surface-hover)] focus-visible:outline-none' interface MessageActionsProps { content: string + getCopyContent?: () => string + hasCopyContent?: boolean + prepareContentForCopy?: (content: string) => ClipboardContent userQuery?: string requestId?: string messageId?: string @@ -58,6 +41,9 @@ interface MessageActionsProps { export const MessageActions = memo(function MessageActions({ content, + getCopyContent, + hasCopyContent, + prepareContentForCopy, userQuery, requestId, messageId, @@ -65,40 +51,28 @@ export const MessageActions = memo(function MessageActions({ const router = useRouter() const params = useParams<{ workspaceId: string }>() const { chatId } = useChatSurface() - const [copied, setCopied] = useState(false) + const { copied, copy: copyMessage } = useCopyToClipboard({ resetMs: 1500 }) const [copiedRequestId, setCopiedRequestId] = useState(false) const [pendingFeedback, setPendingFeedback] = useState<'up' | 'down' | null>(null) const [feedbackText, setFeedbackText] = useState('') - const resetTimeoutRef = useRef(null) const requestIdTimeoutRef = useRef(null) const submitFeedback = useSubmitCopilotFeedback() const forkChat = useForkMothershipChat(params.workspaceId) useEffect(() => { return () => { - if (resetTimeoutRef.current !== null) { - window.clearTimeout(resetTimeoutRef.current) - } if (requestIdTimeoutRef.current !== null) { window.clearTimeout(requestIdTimeoutRef.current) } } }, []) - const copyToClipboard = async () => { - if (!content) return - const text = toPlainText(content) - if (!text) return - try { - await navigator.clipboard.writeText(text) - setCopied(true) - if (resetTimeoutRef.current !== null) { - window.clearTimeout(resetTimeoutRef.current) - } - resetTimeoutRef.current = window.setTimeout(() => setCopied(false), 1500) - } catch { - /* clipboard unavailable */ - } + const copyToClipboard = () => { + const contentToCopy = getCopyContent?.() ?? content + if (!contentToCopy) return + const copyContent = prepareContentForCopy?.(contentToCopy) ?? contentToCopy + if (typeof copyContent === 'string' && !copyContent) return + void copyMessage(copyContent) } const copyRequestId = async () => { @@ -166,18 +140,18 @@ export const MessageActions = memo(function MessageActions({ } } - const hasContent = Boolean(content) + const canCopyContent = hasCopyContent ?? Boolean(content) const canSubmitFeedback = Boolean(chatId && userQuery) // A live (just-streamed) assistant message carries a synthetic id that the // persisted transcript doesn't know — forking it would 400. The button // appears once the transcript refetch swaps in the persisted message id. const canFork = Boolean(chatId && messageId && !isLiveAssistantMessageId(messageId)) - if (!hasContent && !canSubmitFeedback && !canFork) return null + if (!canCopyContent && !canSubmitFeedback && !canFork) return null return ( <>
- {hasContent && ( + {canCopyContent && ( + ), + Chip: ({ + children, + leftIcon: LeftIcon, + onClick, + disabled, + }: { + children: ReactNode + leftIcon?: ComponentType<{ className?: string }> + onClick?: () => void + disabled?: boolean + }) => ( + + ), + ChipModal: ({ + open, + children, + dismissDisabled, + className, + }: { + open: boolean + children: ReactNode + dismissDisabled?: boolean + className?: string + }) => + open ? ( +
+ {children} +
+ ) : null, + ChipConfirmModal: ({ + open, + onOpenChange, + title, + text, + confirm, + }: { + open: boolean + onOpenChange: (open: boolean) => void + title: ReactNode + text?: ReactNode + confirm: MockFooterAction & { pending?: boolean; pendingLabel?: string } + }) => + open ? ( +
+

{title}

+ {text ?

{text}

: null} + + +
+ ) : null, + ChipModalHeader: ({ children, onClose }: { children: ReactNode; onClose: () => void }) => ( +
+ {children} + +
+ ), + ChipModalBody: ({ children, className }: { children: ReactNode; className?: string }) => ( +
+ {children} +
+ ), + ChipModalField: ({ + type, + title, + children, + value, + onChange, + hint, + disabled, + }: { + type: string + title: string + children?: ReactNode + value?: string[] + onChange?: (value: string[]) => void + hint?: ReactNode + disabled?: boolean + }) => ( +
+ {title} + {type === 'emails' ? ( + onChange?.(event.target.value.split(',').filter(Boolean))} + disabled={disabled} + /> + ) : ( + children + )} + {hint ?

{hint}

: null} +
+ ), + ChipModalFooter: ({ + onCancel, + primaryAction, + secondaryActions, + }: { + onCancel: () => void + primaryAction: MockFooterAction + secondaryActions?: MockFooterSlot[] + }) => ( +
+
+ {secondaryActions?.map((action, index) => + 'custom' in action ? ( + {action.custom} + ) : ( + + ) + )} +
+ + +
+ ), + useCopyToClipboard: () => ({ copied: false, copy: mockCopy }), +})) + +vi.mock('@/components/ui', () => ({ + GeneratedPasswordInput: ({ + value, + onChange, + placeholder, + disabled, + }: { + value: string + onChange: (value: string) => void + placeholder?: string + disabled?: boolean + }) => ( + onChange(event.target.value)} + disabled={disabled} + /> + ), +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ isSsoEnabled: true })) +vi.mock('@/lib/messaging/email/validation', () => ({ + validateAllowlistEntry: () => null, +})) +vi.mock('@/hooks/use-permission-config', () => ({ + usePermissionConfig: () => ({ + config: permissionConfigState.current, + }), +})) +vi.mock('@/hooks/queries/public-shares', () => ({ + useFileShare: () => ({ data: fileShareState.current, ...fileShareQueryState }), + useUpsertFileShare: () => ({ + mutate: mockMutate, + isPending: mutationState.isPending, + }), +})) + +import { ShareModal } from '@/app/workspace/[workspaceId]/files/components/share-modal/share-modal' + +const SHARE_URL = 'https://sim.example.com/f/persisted-token' + +function createShare(overrides: Partial = {}): ShareRecord { + return { + id: 'share-1', + token: 'persisted-token', + url: SHARE_URL, + isActive: true, + resourceType: 'file', + resourceId: 'file-1', + authType: 'public', + hasPassword: false, + allowedEmails: [], + ...overrides, + } +} + +let container: HTMLDivElement +let onOpenChange: ReturnType void>> +let root: Root + +async function renderModal(initialShare: ShareRecord | null = null) { + await act(async () => { + root.render( + + ) + }) +} + +function button(label: string): HTMLButtonElement { + const match = [...container.querySelectorAll('button')].find( + (candidate) => candidate.textContent === label + ) + if (!match) throw new Error(`No button labelled "${label}"`) + return match +} + +function queryButton(label: string): HTMLButtonElement | undefined { + return [...container.querySelectorAll('button')].find( + (candidate) => candidate.textContent === label + ) +} + +async function click(label: string) { + await act(async () => button(label).click()) +} + +async function clickConfirmation(label: string) { + const dialog = container.querySelector('[role="alertdialog"]') + const match = [...(dialog?.querySelectorAll('button') ?? [])].find( + (candidate) => candidate.textContent === label + ) + if (!match) throw new Error(`No confirmation button labelled "${label}"`) + await act(async () => match.click()) +} + +async function changePassword(value: string) { + const input = container.querySelector('[aria-label="Password"]') + if (!input) throw new Error('Password input was not rendered') + const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + if (!valueSetter) throw new Error('Password input has no value setter') + await act(async () => { + valueSetter.call(input, value) + input.dispatchEvent(new Event('input', { bubbles: true })) + }) +} + +async function changeAllowedEmails(value: string) { + const input = container.querySelector('[aria-label="Allowed emails"]') + if (!input) throw new Error('Allowed emails input was not rendered') + const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + if (!valueSetter) throw new Error('Allowed emails input has no value setter') + await act(async () => { + valueSetter.call(input, value) + input.dispatchEvent(new Event('input', { bubbles: true })) + }) +} + +describe('ShareModal', () => { + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + onOpenChange = vi.fn() + fileShareState.current = null + fileShareQueryState.isFetchedAfterMount = true + fileShareQueryState.isError = false + mutationState.isPending = false + permissionConfigState.current = { + allowedFileShareAuthTypes: null, + disablePublicFileSharing: false, + } + mockMutate.mockImplementation( + (variables: MockMutationVariables, callbacks?: MockMutationCallbacks) => { + const existing = fileShareState.current + const authType = variables.authType ?? existing?.authType ?? 'public' + fileShareState.current = { + id: existing?.id ?? 'share-1', + token: existing?.token ?? 'persisted-token', + url: existing?.url ?? SHARE_URL, + isActive: variables.isActive, + resourceType: 'file', + resourceId: 'file-1', + authType, + hasPassword: Boolean(variables.password) || existing?.hasPassword === true, + allowedEmails: variables.allowedEmails ?? existing?.allowedEmails ?? [], + } + callbacks?.onSuccess?.() + } + ) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.clearAllMocks() + }) + + it('shares without closing, then exposes the durable link and unshare action', async () => { + await renderModal() + + expect(container.querySelector('[data-testid="modal-body"]')).not.toHaveClass('h-[280px]') + expect(container.querySelector('[data-testid="modal-body"]')).not.toHaveClass('flex-none') + expect(button('Public')).toHaveAttribute('aria-checked', 'true') + expect(queryButton('Copy link')).toBeUndefined() + expect(button('Share')).toBeEnabled() + expect(button('Share')).toHaveAttribute('data-variant', 'primary') + + await click('Share') + + expect(mockMutate).toHaveBeenLastCalledWith( + { + workspaceId: 'workspace-1', + fileId: 'file-1', + token: 'pending-token-1234567890', + isActive: true, + authType: 'public', + }, + expect.objectContaining({ onSuccess: expect.any(Function) }) + ) + expect(onOpenChange).not.toHaveBeenCalled() + expect(mockToastSuccess).toHaveBeenLastCalledWith('File shared') + + await renderModal() + + expect(button('Unshare')).toBeEnabled() + expect(button('Unshare')).toHaveAttribute('data-variant', 'destructive') + expect(button('Copy link').querySelector('[data-testid="link-icon"]')).not.toBeNull() + + await click('Copy link') + expect(mockCopy).toHaveBeenCalledWith(SHARE_URL) + + mockMutate.mockClear() + await click('Unshare') + expect(mockMutate).not.toHaveBeenCalled() + expect(button('Unsharing...')).toHaveAttribute('data-variant', 'destructive') + const confirmDialog = container.querySelector('[role="alertdialog"]') + expect(confirmDialog).not.toBeNull() + expect(confirmDialog).toHaveTextContent('Unshare file?') + + await clickConfirmation('Unshare') + + expect(mockMutate).toHaveBeenLastCalledWith( + expect.objectContaining({ isActive: false }), + expect.objectContaining({ onSuccess: expect.any(Function) }) + ) + expect(onOpenChange).not.toHaveBeenCalled() + expect(mockToastSuccess).toHaveBeenLastCalledWith('File unshared') + + await renderModal() + expect(button('Share')).toBeEnabled() + expect(queryButton('Copy link')).toBeUndefined() + }) + + it('keeps the link visible and changes Unshare to Update while editing the publish mode', async () => { + fileShareState.current = createShare() + await renderModal() + + expect(button('Unshare')).toBeEnabled() + await click('Password') + + expect(button('Copy link')).toBeEnabled() + expect(button('Update')).toBeDisabled() + expect(button('Update')).toHaveAttribute('data-variant', 'primary') + + await changePassword('correct horse battery staple') + expect(button('Update')).toBeEnabled() + + await click('Update') + + expect(mockMutate).toHaveBeenLastCalledWith( + { + workspaceId: 'workspace-1', + fileId: 'file-1', + token: undefined, + isActive: true, + authType: 'password', + password: 'correct horse battery staple', + }, + expect.objectContaining({ onSuccess: expect.any(Function) }) + ) + expect(onOpenChange).not.toHaveBeenCalled() + expect(mockToastSuccess).toHaveBeenLastCalledWith('Sharing updated') + }) + + it.each([ + { + description: 'null', + initialShare: null, + pendingAction: 'Share', + expectedHint: 'Share to make this file accessible to anyone with the link.', + }, + { + description: 'stale', + initialShare: createShare(), + pendingAction: 'Unshare', + expectedHint: 'Anyone with the link can view and download this file.', + }, + ])( + 'waits for the authoritative share read when initial display data is $description', + async ({ initialShare, pendingAction, expectedHint }) => { + fileShareQueryState.isFetchedAfterMount = false + await renderModal(initialShare) + + expect(button(pendingAction)).toBeDisabled() + expect(container).toHaveTextContent(expectedHint) + expect(container).not.toHaveTextContent('Loading the current sharing settings...') + + fileShareState.current = createShare({ + authType: 'password', + hasPassword: true, + }) + fileShareQueryState.isFetchedAfterMount = true + await renderModal(initialShare) + + expect(button('Password')).toHaveAttribute('aria-checked', 'true') + expect(button('Unshare')).toBeEnabled() + } + ) + + it.each([ + { mode: 'Email' as const, authType: 'email' as const, entry: 'person@example.com' }, + { mode: 'SSO' as const, authType: 'sso' as const, entry: 'example.com' }, + ])('requires an allow-list before sharing in $mode mode', async ({ mode, authType, entry }) => { + await renderModal() + await click(mode) + + expect(button('Share')).toBeDisabled() + + await changeAllowedEmails(entry) + expect(button('Share')).toBeEnabled() + + await click('Share') + + expect(mockMutate).toHaveBeenLastCalledWith( + { + workspaceId: 'workspace-1', + fileId: 'file-1', + token: 'pending-token-1234567890', + isActive: true, + authType, + allowedEmails: [entry], + }, + expect.objectContaining({ onSuccess: expect.any(Function) }) + ) + expect(onOpenChange).not.toHaveBeenCalled() + }) + + it.each([ + { mode: 'Password' as const, value: 'correct horse battery staple' }, + { mode: 'Email' as const, value: 'person@example.com' }, + ])('locks access edits and dismissal while a $mode share is pending', async ({ mode, value }) => { + await renderModal() + await click(mode) + if (mode === 'Password') { + await changePassword(value) + } else { + await changeAllowedEmails(value) + } + + let finishMutation: (() => void) | undefined + mockMutate.mockImplementationOnce( + (_variables: MockMutationVariables, callbacks?: MockMutationCallbacks) => { + mutationState.isPending = true + finishMutation = callbacks?.onSuccess + } + ) + + await click('Share') + await renderModal() + + expect(container.querySelector('[role="dialog"]')).toHaveAttribute( + 'data-dismiss-disabled', + 'true' + ) + expect(button('Public')).toBeDisabled() + expect(button('Password')).toBeDisabled() + expect(button('Email')).toBeDisabled() + expect(button('SSO')).toBeDisabled() + expect(button('Sharing...')).toBeDisabled() + + const editor = container.querySelector( + mode === 'Password' ? '[aria-label="Password"]' : '[aria-label="Allowed emails"]' + ) + expect(editor).toBeDisabled() + + await act(async () => { + mutationState.isPending = false + finishMutation?.() + }) + }) + + it('blocks a new share when public file sharing is disabled', async () => { + permissionConfigState.current = { + allowedFileShareAuthTypes: null, + disablePublicFileSharing: true, + } + + await renderModal() + + expect(button('Share')).toBeDisabled() + }) + + it('blocks sharing an inactive saved mode that is no longer allowed', async () => { + permissionConfigState.current = { + allowedFileShareAuthTypes: ['public'], + disablePublicFileSharing: false, + } + fileShareState.current = createShare({ + isActive: false, + authType: 'email', + allowedEmails: ['person@example.com'], + }) + + await renderModal() + + expect(button('Email')).toHaveAttribute('aria-checked', 'true') + expect(button('Share')).toBeDisabled() + }) + + it('allows unsharing an active saved mode that is no longer allowed', async () => { + permissionConfigState.current = { + allowedFileShareAuthTypes: ['public'], + disablePublicFileSharing: false, + } + fileShareState.current = createShare({ + authType: 'email', + allowedEmails: ['person@example.com'], + }) + + await renderModal() + + expect(button('Email')).toHaveAttribute('aria-checked', 'true') + expect(button('Unshare')).toBeEnabled() + + await click('Unshare') + await clickConfirmation('Unshare') + expect(mockMutate).toHaveBeenLastCalledWith( + expect.objectContaining({ isActive: false }), + expect.objectContaining({ onSuccess: expect.any(Function) }) + ) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx index 073ce695588..26d9016c13d 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx @@ -4,18 +4,21 @@ import { useState } from 'react' import { ButtonGroup, ButtonGroupItem, + Chip, + ChipConfirmModal, ChipModal, ChipModalBody, ChipModalField, ChipModalFooter, ChipModalHeader, + toast, + useCopyToClipboard, } from '@sim/emcn' -import { Send } from '@sim/emcn/icons' +import { Check, Link, Send } from '@sim/emcn/icons' import { generateShortId } from '@sim/utils/id' import { GeneratedPasswordInput } from '@/components/ui' import type { ShareAuthType, ShareRecord } from '@/lib/api/contracts/public-shares' import { isSsoEnabled } from '@/lib/core/config/env-flags' -import { getBaseUrl } from '@/lib/core/utils/urls' import { validateAllowlistEntry } from '@/lib/messaging/email/validation' import { useFileShare, useUpsertFileShare } from '@/hooks/queries/public-shares' import { usePermissionConfig } from '@/hooks/use-permission-config' @@ -30,22 +33,30 @@ interface ShareModalProps { initialShare?: ShareRecord | null } -type AccessMode = 'private' | ShareAuthType - -const ACCESS_LABELS: Record = { - private: 'Private', +const ACCESS_LABELS: Record = { public: 'Public', password: 'Password', email: 'Email', sso: 'SSO', } +const PRIMARY_ACTION_LABELS = { + share: { idle: 'Share', pending: 'Sharing...' }, + update: { idle: 'Update', pending: 'Updating...' }, + unshare: { idle: 'Unshare', pending: 'Unsharing...' }, +} as const + +const PRIMARY_ACTION_SUCCESS_MESSAGES = { + share: 'File shared', + update: 'Sharing updated', + unshare: 'File unshared', +} as const + /** Stable identity so the emails field's reconcile effect no-ops while unset. */ const EMPTY_EMAILS: string[] = [] -function savedMode(share: ShareRecord | null): AccessMode { - if (!share?.isActive) return 'private' - return share.authType +function savedMode(share: ShareRecord | null): ShareAuthType { + return share?.authType ?? 'public' } export function ShareModal({ @@ -56,32 +67,26 @@ export function ShareModal({ fileName, initialShare, }: ShareModalProps) { - const { data: share, isFetched } = useFileShare(workspaceId, fileId, { enabled: open }) + const { + data: share, + isError: isShareError, + isFetchedAfterMount, + } = useFileShare(workspaceId, fileId, { enabled: open }) const { config: permissionConfig } = usePermissionConfig() const upsertShare = useUpsertFileShare() + const { copied, copy } = useCopyToClipboard({ resetMs: 1500 }) - const saved = share ?? initialShare ?? null + const shareReadReady = isFetchedAfterMount && !isShareError + const saved = shareReadReady ? (share ?? null) : (share ?? initialShare ?? null) const savedAccessMode = savedMode(saved) - // Reserve a token on open (one per mount — the modal remounts each open) so the - // link can be shown and copied before the first save; it's persisted on save. - // Only used once we've confirmed no share row exists yet, so a copied link - // always matches what gets stored. - const [pendingToken] = useState(() => generateShortId()) - const noExistingShare = isFetched && !share && !initialShare - const shareUrl = saved?.url ?? (noExistingShare ? `${getBaseUrl()}/f/${pendingToken}` : null) - - // `null` until the user changes the selector, so the control always reflects the - // authoritative saved state (which may resolve after mount via useFileShare). - const [draftMode, setDraftMode] = useState(null) + const [draftMode, setDraftMode] = useState(null) const [draftPassword, setDraftPassword] = useState('') const [draftEmails, setDraftEmails] = useState(null) + const [unshareConfirmOpen, setUnshareConfirmOpen] = useState(false) const effectiveMode = draftMode ?? savedAccessMode - const effectiveActive = effectiveMode !== 'private' const effectiveEmails = draftEmails ?? saved?.allowedEmails ?? EMPTY_EMAILS - // Org access-control may restrict which auth modes are allowed (`null` = all). - // The route is the source of truth; this just hides disallowed options. const allowedAuthTypes = permissionConfig.allowedFileShareAuthTypes const isAuthTypeAllowed = (mode: ShareAuthType) => allowedAuthTypes === null || allowedAuthTypes.includes(mode) @@ -93,22 +98,16 @@ export function ShareModal({ 'email', ...(ssoEnabled ? (['sso'] as const) : []), ] - // Keep the saved mode visible even if newly disallowed, so the current state shows. - const accessModes: AccessMode[] = [ - 'private', - ...candidateAuthTypes.filter((mode) => isAuthTypeAllowed(mode) || mode === savedAccessMode), - ] + const accessModes = candidateAuthTypes.filter( + (mode) => isAuthTypeAllowed(mode) || mode === savedAccessMode + ) - // The selected mode is blocked when org policy disables public sharing entirely - // (enabling a new share) or when the chosen auth mode isn't allowed. - const modeDisallowed = effectiveMode !== 'private' && !isAuthTypeAllowed(effectiveMode) + const modeDisallowed = !isAuthTypeAllowed(effectiveMode) const enableBlockedByPolicy = (permissionConfig.disablePublicFileSharing && !saved?.isActive) || modeDisallowed - // A password share needs a secret: either one already stored or a freshly typed one. const passwordMissing = effectiveMode === 'password' && !saved?.hasPassword && draftPassword.trim().length === 0 - // Email/SSO shares need at least one allowed email/domain. const emailsMissing = (effectiveMode === 'email' || effectiveMode === 'sso') && effectiveEmails.length === 0 @@ -119,6 +118,11 @@ export function ShareModal({ (draftMode !== null && draftMode !== savedAccessMode) || (effectiveMode === 'password' && draftPassword.length > 0) || ((effectiveMode === 'email' || effectiveMode === 'sso') && emailsDirty) + const primaryAction = saved?.isActive ? (isDirty ? 'update' : 'unshare') : 'share' + const isUnshareAction = primaryAction === 'unshare' + const primaryActionPending = upsertShare.isPending || (isUnshareAction && unshareConfirmOpen) + const primaryLabel = + PRIMARY_ACTION_LABELS[primaryAction][primaryActionPending ? 'pending' : 'idle'] const resetDraft = () => { setDraftMode(null) @@ -127,122 +131,163 @@ export function ShareModal({ } const handleClose = () => { + setUnshareConfirmOpen(false) resetDraft() onOpenChange(false) } - const handleSave = () => { - // Persist the reserved token only when creating the row; existing shares keep - // their own token (the server ignores this on conflict). - const base = { workspaceId, fileId, token: saved ? undefined : pendingToken } - const vars = - effectiveMode === 'private' - ? { ...base, isActive: false as const } - : effectiveMode === 'password' + const submitPrimaryAction = () => { + if (!shareReadReady || upsertShare.isPending) return + + const base = { workspaceId, fileId, token: saved ? undefined : generateShortId() } + const vars = isUnshareAction + ? { ...base, isActive: false as const } + : effectiveMode === 'password' + ? { + ...base, + isActive: true as const, + authType: 'password' as const, + password: draftPassword.trim() || undefined, + } + : effectiveMode === 'email' || effectiveMode === 'sso' ? { ...base, isActive: true as const, - authType: 'password' as const, - password: draftPassword.trim() || undefined, + authType: effectiveMode, + allowedEmails: effectiveEmails, } - : effectiveMode === 'email' || effectiveMode === 'sso' - ? { - ...base, - isActive: true as const, - authType: effectiveMode, - allowedEmails: effectiveEmails, - } - : { ...base, isActive: true as const, authType: 'public' as const } + : { ...base, isActive: true as const, authType: 'public' as const } upsertShare.mutate(vars, { onSuccess: () => { + toast.success(PRIMARY_ACTION_SUCCESS_MESSAGES[primaryAction]) + setUnshareConfirmOpen(false) resetDraft() - onOpenChange(false) }, }) } + const handlePrimaryAction = () => { + if (isUnshareAction) { + setUnshareConfirmOpen(true) + return + } + submitPrimaryAction() + } + const accessHint = (() => { + if (isShareError) return 'Unable to load the current sharing settings. Close and try again.' if (modeDisallowed) return 'This sharing method is disabled by an administrator.' if (enableBlockedByPolicy) return 'Public sharing is disabled for this workspace by an administrator.' - if (effectiveMode === 'private') return 'Only workspace members can access this file.' if (effectiveMode === 'password') return 'Anyone with the link and the password can view and download this file.' if (effectiveMode === 'email') return 'Only allowed emails can access this file after a one-time code.' if (effectiveMode === 'sso') return 'Only allowed emails signed in via SSO can access this file.' - return isDirty - ? 'Save to make this file accessible to anyone with the link.' - : 'Anyone with the link can view and download this file.' + return saved?.isActive && !isDirty + ? 'Anyone with the link can view and download this file.' + : `${saved?.isActive ? 'Update' : 'Share'} to make this file accessible to anyone with the link.` })() return ( - - - Share file - - - - setDraftMode(value as AccessMode)} - aria-label='File access' - > - {accessModes.map((mode) => ( - - {ACCESS_LABELS[mode]} - - ))} - - - {effectiveMode === 'password' ? ( - - + <> + + + Share file + + + + setDraftMode(value as ShareAuthType)} + aria-label='File access' + disabled={upsertShare.isPending} + > + {accessModes.map((mode) => ( + + {ACCESS_LABELS[mode]} + + ))} + - ) : null} - {effectiveMode === 'email' || effectiveMode === 'sso' ? ( - - ) : null} - {effectiveMode !== 'private' && shareUrl ? ( - - ) : null} - - + + + ) : null} + {effectiveMode === 'email' || effectiveMode === 'sso' ? ( + + ) : null} + + copy(saved.url)}> + {copied ? 'Copied!' : 'Copy link'} + + ), + }, + ] + : undefined + } + primaryAction={{ + label: primaryLabel, + onClick: handlePrimaryAction, + variant: isUnshareAction ? 'destructive' : 'primary', + disabled: + upsertShare.isPending || + !shareReadReady || + (!isUnshareAction && (passwordMissing || emailsMissing || enableBlockedByPolicy)), + }} + /> + + - + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 71800c82e2c..c7f64d21a77 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -16,8 +16,9 @@ import { Trash, toast, Upload, + useCopyToClipboard, } from '@sim/emcn' -import { Download, Send } from '@sim/emcn/icons' +import { Check, Download, Link, Send } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { useParams, useRouter } from 'next/navigation' @@ -128,7 +129,6 @@ import { } from '@/app/workspace/[workspaceId]/files/untitled-title' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' -import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { usePinItem, usePinnedIds, useUnpinItem } from '@/hooks/queries/pinned-items' import { useWorkspaceMembersQuery, type WorkspaceMember } from '@/hooks/queries/workspace' import { @@ -147,6 +147,7 @@ import { useUploadWorkspaceFile, useWorkspaceFiles, } from '@/hooks/queries/workspace-files' +import { useContextMenu } from '@/hooks/use-context-menu' import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' import { useInlineRename } from '@/hooks/use-inline-rename' import { usePermissionConfig } from '@/hooks/use-permission-config' @@ -273,6 +274,7 @@ export function Files() { const userPermissions = useUserPermissionsContext() const canEdit = userPermissions.canEdit === true const { config: permissionConfig } = usePermissionConfig() + const { copied: copiedFileLink, copy: copyFileLink } = useCopyToClipboard({ resetMs: 1500 }) // Joined for the live file tree: a `workspace-files-changed` broadcast invalidates the // browser. "Who's in this file" comes from the file-doc room (see FileDocRoomProvider), @@ -1397,6 +1399,19 @@ export function Files() { closeContextMenu() }, [selectedRowIds, handleBulkDownload, closeContextMenu, downloadArchive, handleDownload]) + const handleContextMenuCopyLink = useCallback(() => { + const item = contextMenuItemRef.current + if (item?.kind === 'file') { + void copyFileLink( + `${window.location.origin}/workspace/${workspaceId}/files/${item.file.id}` + ).then((copied) => { + if (copied) toast.success('Copied link to clipboard') + else toast.error('Failed to copy link') + }) + } + closeContextMenu() + }, [closeContextMenu, copyFileLink, workspaceId]) + const handleContextMenuRename = useCallback(() => { const item = contextMenuItemRef.current if (item?.kind === 'file') listRename.startRename(item.file.id, item.file.name) @@ -1613,7 +1628,6 @@ export function Files() { const isSimPage = selectedFile.type === SIM_PAGE_CONTENT_TYPE const hasSplitView = canEditText && canPreview && !isInlineMarkdown && !isSimPage const showPreviewToggle = canPreview && !isInlineMarkdown && !isSimPage - const nextModeLabel = previewMode === 'editor' ? 'Split' : previewMode === 'split' ? 'Preview' : 'Edit' const nextModeIcon = @@ -1637,6 +1651,15 @@ export function Files() { }, ] : []), + { + id: 'copy-link', + text: copiedFileLink ? 'Copied!' : 'Copy Link', + icon: copiedFileLink ? Check : Link, + onSelect: () => + void copyFileLink( + `${window.location.origin}/workspace/${workspaceId}/files/${selectedFile.id}` + ), + }, { text: 'Download', icon: Download, @@ -1665,6 +1688,9 @@ export function Files() { handleCyclePreviewMode, handleTogglePreview, handleDownloadSelected, + copiedFileLink, + copyFileLink, + workspaceId, handleShareSelected, handleDeleteSelected, ]) @@ -2244,6 +2270,7 @@ export function Files() { position={contextMenuPosition} onClose={closeContextMenu} onOpen={handleContextMenuOpen} + onCopyLink={contextMenuItem?.kind === 'file' ? handleContextMenuCopyLink : undefined} onDownload={handleContextMenuDownload} onRename={handleContextMenuRename} onDelete={handleContextMenuDelete} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/index.ts index 1d1a257d880..7a83ebc996e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/index.ts @@ -1,5 +1,6 @@ export { assistantMessageHasRenderableContent, + getOrchestratorMessageText, MessageContent, } from './message-content' export type { MessagePhase } from './utils' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts index d50471c4337..76b2976c67c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts @@ -27,6 +27,7 @@ import type { ContentBlock } from '../../types' import { assistantMessageHasVisibleExecutingTool, deriveThinkingLabel, + getOrchestratorMessageText, parseBlocks, shouldSmoothTextSegment, } from './message-content' @@ -100,6 +101,66 @@ function toolEnvelope( } as PersistedStreamEventEnvelope } +describe('getOrchestratorMessageText', () => { + it('copies only orchestrator text from span-based messages', () => { + const blocks: ContentBlock[] = [ + subagentStart('research', 'span-visible', 'main'), + { + type: 'subagent_text', + content: 'Visible research. ', + spanId: 'span-visible', + timestamp: 2, + }, + { + type: 'subagent_text', + content: 'Hidden orphan. ', + spanId: 'span-orphan', + timestamp: 3, + }, + mainText('Main answer.'), + ] + + expect(getOrchestratorMessageText(blocks, 'Fallback.')).toBe('Main answer.') + }) + + it('copies only orchestrator text from legacy messages', () => { + const blocks: ContentBlock[] = [ + { type: 'subagent_text', content: 'Hidden orphan. ', timestamp: 1 }, + { + type: 'subagent', + content: 'research', + parentToolCallId: 'dispatch-visible', + timestamp: 2, + }, + { + type: 'subagent_text', + content: 'Visible research. ', + parentToolCallId: 'dispatch-visible', + timestamp: 3, + }, + mainText('Main answer.'), + ] + + expect(getOrchestratorMessageText(blocks, 'Fallback.')).toBe('Main answer.') + }) + + it('separates orchestrator text blocks around excluded subagent output', () => { + const blocks: ContentBlock[] = [ + mainText('Starting answer.'), + subagentStart('research', 'span-visible', 'main'), + { + type: 'subagent_text', + content: 'Visible research.', + spanId: 'span-visible', + timestamp: 2, + }, + mainText('Main answer.'), + ] + + expect(getOrchestratorMessageText(blocks, 'Fallback.')).toBe('Starting answer.\n\nMain answer.') + }) +}) + describe('parseBlocks span-identity tree', () => { it('refines a completed credential rename with its previous and new names', () => { const segments = parseBlocks([ diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index e16282ea66b..5a13215a565 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -492,6 +492,23 @@ export function parseBlocks(blocks: ContentBlock[]): MessageSegment[] { return parseBlocksLegacy(blocks) } +function joinRenderableText(parts: string[]): string { + return parts.filter(Boolean).join('\n\n') +} + +/** Returns only top-level orchestrator text, excluding agent groups and other UI segments. */ +export function getOrchestratorMessageText( + blocks: ContentBlock[], + fallbackContent: string +): string { + const parsed = blocks.length > 0 ? parseBlocks(blocks) : [] + if (parsed.length === 0) return fallbackContent + + return joinRenderableText( + parsed.map((segment) => (segment.type === 'text' ? segment.content : '')) + ) +} + function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] { const segments: MessageSegment[] = [] const groupsByKey = new Map() diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.test.ts new file mode 100644 index 00000000000..1bdc3dd78a5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it, vi } from 'vitest' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +import { + prepareCopyableMarkdown, + toCopyableMarkdown, +} from '@/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown' +import { parseChipLinks } from '@/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec' + +const WORKSPACE_FILES: WorkspaceFileRecord[] = [ + { + id: 'file_bell', + workspaceId: 'workspace-1', + name: 'The Bell at Low Tide.md', + key: 'workspace/workspace-1/file_bell', + path: '/api/files/view/file_bell', + size: 0, + type: 'text/markdown', + uploadedBy: 'user-1', + uploadedAt: new Date(0), + updatedAt: new Date(0), + }, +] + +describe('toCopyableMarkdown', () => { + it('preserves message Markdown, including fenced code and its language', () => { + const message = [ + '# Elevator diagnosis', + '', + 'The bug is in `dispatch_legacy.py`:', + '', + '```python', + 'def next_stop(requests, current):', + ' ranked = sorted(requests)', + ' return ranked[1:]', + '```', + '', + '**Result:** the closest request *was not* always selected.', + ].join('\n') + + expect(toCopyableMarkdown(message)).toBe(message) + }) + + it('removes internal structured tags without flattening surrounding Markdown', () => { + const message = [ + 'Before **formatted text**.', + '{"type":"service_account","provider":"gmail"}', + 'After [a link](https://example.com).', + ].join('\n') + + expect(toCopyableMarkdown(message)).toBe( + ['Before **formatted text**.', '', 'After [a link](https://example.com).'].join('\n') + ) + }) + + it('preserves tag-shaped text that the chat renders literally', () => { + const message = [ + 'Document `example`.', + '', + '```html', + 'example', + 'example', + '```', + ].join('\n') + + expect(toCopyableMarkdown(message)).toBe(message) + }) + + it('copies workspace resources as portable Markdown links with real ids', () => { + const message = [ + 'Read', + '{"type":"file","path":"files/The%20Bell%20at%20Low%20Tide.md","title":"The Bell at Low Tide.md"}', + 'and', + `${JSON.stringify({ + type: 'table', + id: 'tbl_f26af6dae98d4222b014b250494d00fb', + title: 'Checked_[rare]\\portal', + })}.`, + ].join('') + + const markdown = toCopyableMarkdown(message, WORKSPACE_FILES) + + expect(markdown).toBe( + 'Read [The Bell at Low Tide.md](sim:file/file_bell) and [Checked_\\[rare\\]\\\\portal](sim:table/tbl_f26af6dae98d4222b014b250494d00fb).' + ) + expect(parseChipLinks(markdown)).toEqual([ + { + kind: 'file', + id: 'file_bell', + label: 'The Bell at Low Tide.md', + start: 5, + end: 50, + }, + { + kind: 'table', + id: 'tbl_f26af6dae98d4222b014b250494d00fb', + label: 'Checked_[rare]\\portal', + start: 55, + end: 129, + }, + ]) + }) + + it('uses resolved file metadata for a resource without a title', () => { + const message = + 'Read {"type":"file","path":"files/The%20Bell%20at%20Low%20Tide.md"}.' + + expect(toCopyableMarkdown(message, WORKSPACE_FILES)).toBe( + 'Read [The Bell at Low Tide.md](sim:file/file_bell).' + ) + }) + + it('refreshes missing file metadata before producing copyable Markdown', async () => { + const message = + 'Read {"type":"file","path":"files/The%20Bell%20at%20Low%20Tide.md","title":"The Bell at Low Tide.md"}.' + const refreshWorkspaceFiles = vi.fn().mockResolvedValue(WORKSPACE_FILES) + + const content = prepareCopyableMarkdown(message, [], refreshWorkspaceFiles) + expect(content).not.toBeTypeOf('string') + if (typeof content === 'string') throw new Error('Expected deferred clipboard content') + expect(content.fallback).toBe('Read The Bell at Low Tide.md.') + expect(parseChipLinks(content.fallback)).toEqual([]) + await expect(content.prepare()).resolves.toBe( + 'Read [The Bell at Low Tide.md](sim:file/file_bell).' + ) + expect(refreshWorkspaceFiles).toHaveBeenCalledOnce() + }) + + it('copies unresolved file references as plain text', () => { + const message = + 'Read {"type":"file","path":"files/Q1 plan).md","title":"Q1 plan).md"}.' + + const markdown = toCopyableMarkdown(message) + + expect(markdown).toBe('Read Q1 plan).md.') + expect(parseChipLinks(markdown)).toEqual([]) + }) + + it('keeps the plain-text fallback when refreshing file metadata fails', async () => { + const message = + 'Read {"type":"file","path":"files/notes.md","title":"notes.md"}.' + const refreshWorkspaceFiles = vi.fn().mockRejectedValue(new Error('Refresh failed')) + + const content = prepareCopyableMarkdown(message, [], refreshWorkspaceFiles) + + expect(content).not.toBeTypeOf('string') + if (typeof content === 'string') throw new Error('Expected deferred clipboard content') + expect(content.fallback).toBe('Read notes.md.') + await expect(content.prepare()).resolves.toBe('Read notes.md.') + expect(refreshWorkspaceFiles).toHaveBeenCalledOnce() + }) + + it('does not refresh metadata when all workspace resources already resolve', () => { + const message = + 'Read {"type":"file","path":"files/The%20Bell%20at%20Low%20Tide.md","title":"The Bell at Low Tide.md"}.' + const refreshWorkspaceFiles = vi.fn() + + expect(prepareCopyableMarkdown(message, WORKSPACE_FILES, refreshWorkspaceFiles)).toBe( + 'Read [The Bell at Low Tide.md](sim:file/file_bell).' + ) + expect(refreshWorkspaceFiles).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.ts new file mode 100644 index 00000000000..0a27f697f6a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.ts @@ -0,0 +1,101 @@ +import type { ClipboardContent } from '@sim/emcn' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +import { sanitizeChatDisplayContent } from '@/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-sanitize' +import { + type ContentSegment, + parseSpecialTags, + type WorkspaceResourceTagData, +} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' +import { serializePortableChipLink } from '@/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec' +import { resolveWorkspaceResourceRef } from '@/app/workspace/[workspaceId]/home/resolve-resource-ref' + +interface CopyableMarkdownResult { + markdown: string + hasUnresolvedFile: boolean +} + +function workspaceResourceLabel(data: WorkspaceResourceTagData): string { + if (data.title) return data.title + return data.type === 'file' ? (data.path ?? data.id ?? '') : (data.id ?? '') +} + +function appendInlineReferenceMarkdown( + currentMarkdown: string, + referenceMarkdown: string, + nextSegment?: ContentSegment +): string { + const followingText = + nextSegment?.type === 'text' + ? nextSegment.content + : nextSegment?.type === 'workspace_resource' + ? nextSegment.data.title || nextSegment.data.id || '' + : '' + const leadingSpace = /[A-Za-z0-9_)]$/.test(currentMarkdown) ? ' ' : '' + const trailingSpace = + /^[A-Za-z0-9_(]/.test(followingText) && !/\s$/.test(referenceMarkdown) ? ' ' : '' + return `${currentMarkdown}${leadingSpace}${referenceMarkdown}${trailingSpace}` +} + +function portableWorkspaceResourceMarkdown( + data: WorkspaceResourceTagData, + workspaceFiles: readonly WorkspaceFileRecord[] +): CopyableMarkdownResult { + const label = workspaceResourceLabel(data) + const resource = resolveWorkspaceResourceRef({ ...data, title: data.title ?? '' }, workspaceFiles) + return { + markdown: resource + ? serializePortableChipLink(data.type, resource.id, resource.title || label) + : label, + hasUnresolvedFile: data.type === 'file' && !resource, + } +} + +function serializeCopyableMarkdown( + raw: string, + workspaceFiles: readonly WorkspaceFileRecord[] = [] +): CopyableMarkdownResult { + const displayContent = sanitizeChatDisplayContent(raw) + const { segments } = parseSpecialTags(displayContent, false) + let hasUnresolvedFile = false + + const markdown = segments + .reduce((markdown, segment, index) => { + if (segment.type === 'text') return markdown + segment.content + if (segment.type === 'workspace_resource') { + const portable = portableWorkspaceResourceMarkdown(segment.data, workspaceFiles) + hasUnresolvedFile ||= portable.hasUnresolvedFile + return appendInlineReferenceMarkdown(markdown, portable.markdown, segments[index + 1]) + } + return markdown + }, '') + .trim() + + return { markdown, hasUnresolvedFile } +} + +export function toCopyableMarkdown( + raw: string, + workspaceFiles: readonly WorkspaceFileRecord[] = [] +): string { + return serializeCopyableMarkdown(raw, workspaceFiles).markdown +} + +export function prepareCopyableMarkdown( + raw: string, + workspaceFiles: readonly WorkspaceFileRecord[], + refreshWorkspaceFiles: () => Promise +): ClipboardContent { + const initial = serializeCopyableMarkdown(raw, workspaceFiles) + if (!initial.hasUnresolvedFile) return initial.markdown + + return { + fallback: initial.markdown, + prepare: async () => { + try { + return toCopyableMarkdown(raw, await refreshWorkspaceFiles()) + } catch { + return initial.markdown + } + }, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx index 84025228547..0bd179c7d7f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx @@ -10,14 +10,17 @@ import { useRef, useState, } from 'react' -import { cn } from '@sim/emcn' +import { type ClipboardContent, cn } from '@sim/emcn' +import { useQueryClient } from '@tanstack/react-query' import { defaultRangeExtractor, type Range, useVirtualizer } from '@tanstack/react-virtual' import { SMOOTH_CHASE_RATE } from '@/lib/core/utils/smooth-bottom-chase' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { MessageActions } from '@/app/workspace/[workspaceId]/components' import { ChatMessageAttachments } from '@/app/workspace/[workspaceId]/home/components/chat-message-attachments' import { ChatSurfaceProvider } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context' import { assistantMessageHasRenderableContent, + getOrchestratorMessageText, MessageContent, type MessagePhase, } from '@/app/workspace/[workspaceId]/home/components/message-content' @@ -29,6 +32,7 @@ import { parseLastCredentialTag, parseLastQuestionTag, } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' +import { prepareCopyableMarkdown } from '@/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown' import { nextSizerFloor } from '@/app/workspace/[workspaceId]/home/components/mothership-chat/sizer-floor' import { QueuedMessages } from '@/app/workspace/[workspaceId]/home/components/queued-messages' import { @@ -46,12 +50,14 @@ import type { WorkspaceResourceRef, } from '@/app/workspace/[workspaceId]/home/types' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { getWorkspaceFilesQueryOptions, workspaceFilesKeys } from '@/hooks/queries/workspace-files' import { useAutoScroll } from '@/hooks/use-auto-scroll' import type { ChatContext } from '@/stores/panel' import { MothershipChatSkeleton } from './components/mothership-chat-skeleton' import { shouldShowAssistantMessageActions } from './message-actions-visibility' interface MothershipChatProps { + workspaceId: string messages: ChatMessage[] isSending: boolean isReconnecting?: boolean @@ -148,6 +154,7 @@ const LAYOUT_STYLES = { } as const const EMPTY_BLOCKS: ContentBlock[] = [] +const EMPTY_WORKSPACE_FILES: readonly WorkspaceFileRecord[] = [] interface UserMessageRowProps { content: string @@ -185,6 +192,7 @@ const UserMessageRow = memo(function UserMessageRow({ interface AssistantMessageRowProps { message: ChatMessage + prepareContentForCopy: (content: string) => ClipboardContent isStreaming: boolean isLast: boolean precedingUserContent?: string @@ -201,6 +209,7 @@ interface AssistantMessageRowProps { const AssistantMessageRow = memo(function AssistantMessageRow({ message, + prepareContentForCopy, isStreaming, isLast, precedingUserContent, @@ -225,6 +234,10 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ onAnimatingChangeRef.current?.(phase !== 'settled') }, [phase]) + const getCopyContent = useCallback( + () => getOrchestratorMessageText(blocks, message.content), + [blocks, message.content] + ) const hasRenderableAssistant = assistantMessageHasRenderableContent(blocks, message.content ?? '') if (!hasRenderableAssistant && !trimmedContent && !isStreaming) { return null @@ -281,6 +294,9 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ actionsEligible ? ( (undefined) const floorDrainRafRef = useRef(0) + const prepareContentForCopy = useCallback( + (content: string) => + prepareCopyableMarkdown( + content, + queryClient.getQueryData( + workspaceFilesKeys.list(workspaceId) + ) ?? EMPTY_WORKSPACE_FILES, + () => + queryClient.fetchQuery({ + ...getWorkspaceFilesQueryOptions(workspaceId), + staleTime: 0, + }) + ), + [queryClient, workspaceId] + ) useEffect(() => () => cancelAnimationFrame(floorDrainRafRef.current), []) /** @@ -760,6 +793,7 @@ export function MothershipChat({ ) : ( ({ + items: listIntegrationsByPopularity().map((integration) => ({ id: integration.blockType, name: integration.name, iconComponent: integration.icon, @@ -265,12 +266,26 @@ export function useAvailableResources( type: 'task' as const, items: (tasks ?? []).map((t) => ({ id: t.id, name: t.name })), }, + /** + * The chip's `name` keeps the absolute timestamp because it is persisted + * with the chat, where "2m ago" would age into a lie; the row renders the + * relative form, which is what reads at a glance. `mentionFamily` is what + * lets `@logs` reach rows named after their workflow. + */ { type: 'log' as const, items: logs.map((log) => { const workflowName = log.workflow?.name ?? log.workflowId ?? 'Unknown' - const time = formatDate(log.createdAt).compact - return { id: log.id, name: `${workflowName} · ${time}`, workflowName, time } + const when = formatDate(log.createdAt) + return { + id: log.id, + name: `${workflowName} · ${when.compact}`, + mentionFamily: getResourceConfig('log').label, + executionId: log.executionId ?? undefined, + workflowName, + time: when.relative, + status: log.status, + } }), }, ] @@ -364,7 +379,7 @@ export function ResourceFolderTreeItems({ node.kind === 'item' ? ( onSelect({ type, id: node.id, title: node.item.name })} + onClick={() => onSelect(resourceFromItem(type, node.item))} > {config.renderDropdownItem({ item: node.item })} @@ -518,10 +533,7 @@ export function ResourceMenuSections({ if (!section && (type === 'browser' || type === 'terminal')) { const item = items[0] return ( - onSelect({ type, id: item.id, title: item.name })} - > + onSelect(resourceFromItem(type, item))}> {config.label} @@ -546,7 +558,7 @@ export function ResourceMenuSections({ items.map((item) => ( onSelect({ type, id: item.id, title: item.name })} + onClick={() => onSelect(resourceFromItem(type, item))} > {config.renderDropdownItem({ item })} @@ -642,7 +654,7 @@ export function AddResourceDropdown({ if (filtered.length > 0 && filtered[activeIndex]) { e.preventDefault() const { type, item } = filtered[activeIndex] - select({ type, id: item.id, title: item.name }) + select(resourceFromItem(type, item)) } } } @@ -694,7 +706,7 @@ export function AddResourceDropdown({ key={`${type}:${item.id}`} className={cn(index === activeIndex && 'bg-[var(--surface-hover)]')} onMouseEnter={() => setActiveIndex(index)} - onClick={() => select({ type, id: item.id, title: item.name })} + onClick={() => select(resourceFromItem(type, item))} > {config.renderDropdownItem({ item })} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/index.ts index d419eca23be..02297bd7301 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/index.ts @@ -6,3 +6,4 @@ export { useAvailableResources, useResourceTreeSections, } from './add-resource-dropdown' +export { resourceFromItem } from './resource-from-item' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-from-item.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-from-item.test.ts new file mode 100644 index 00000000000..6aecf863be6 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-from-item.test.ts @@ -0,0 +1,30 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { resourceFromItem } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-from-item' + +describe('resourceFromItem', () => { + it('carries a log item execution id onto the resource', () => { + expect( + resourceFromItem('log', { + id: 'log-row-1', + name: 'Nightly sync · Aug 21 11:03:46', + executionId: 'exec-9', + }) + ).toEqual({ + type: 'log', + id: 'log-row-1', + title: 'Nightly sync · Aug 21 11:03:46', + executionId: 'exec-9', + }) + }) + + it('builds the plain resource for a family that carries no extra identifier', () => { + expect(resourceFromItem('workflow', { id: 'wf-1', name: 'Nightly sync' })).toEqual({ + type: 'workflow', + id: 'wf-1', + title: 'Nightly sync', + }) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-from-item.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-from-item.ts new file mode 100644 index 00000000000..e0aee706b30 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-from-item.ts @@ -0,0 +1,22 @@ +import type { AvailableItem } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-folder-tree' +import type { + MothershipResource, + MothershipResourceType, +} from '@/app/workspace/[workspaceId]/home/types' + +/** + * Builds the resource a picker row stands for. + * + * Every menu that selects a candidate goes through here so a family's extra + * identifier reaches the resource. Constructing the literal inline silently + * drops it — a log selected that way loses the execution id its chat context is + * addressed by. Only `executionId` is carried today; add a field here when + * another family needs one. + */ +export function resourceFromItem( + type: MothershipResourceType, + item: AvailableItem +): MothershipResource { + const executionId = typeof item.executionId === 'string' ? item.executionId : undefined + return { type, id: item.id, title: item.name, ...(executionId ? { executionId } : {}) } +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-downloads.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-downloads.tsx index a3067fb1e1e..e574d7be79f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-downloads.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-downloads.tsx @@ -163,7 +163,7 @@ export function BrowserDownloads({ scopeId, open, requestOpen, onClose }: Browse )} - + Downloads {downloads.map((download) => { const completed = download.state === 'completed' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-tab-strip.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-tab-strip.tsx index 839455c141e..2945162eac1 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-tab-strip.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-tab-strip.tsx @@ -207,33 +207,32 @@ export function BrowserTabStrip({ onTabContextMenu={openTabContextMenu} onTabDragStart={startTabDrag} onReorder={onReorderTab} - > - onSetTabPinned(contextTab.tabId, !contextTab.pinned) : undefined - } - onDuplicate={contextTab ? () => onDuplicateTab(contextTab.tabId) : undefined} - // Pinned tabs are durable and deliberately have no close action. - {...(contextTab && !contextTab.pinned - ? { onCloseTab: () => onCloseTab(contextTab.tabId), showCloseTab: true } - : {})} - onDelete={() => {}} - showPin={Boolean(contextTab)} - isPinned={Boolean(contextTab?.pinned)} - showRename={false} - showDuplicate={Boolean(contextTab)} - showDelete={false} - /> - + overlays={ + onSetTabPinned(contextTab.tabId, !contextTab.pinned) : undefined + } + onDuplicate={contextTab ? () => onDuplicateTab(contextTab.tabId) : undefined} + // Pinned tabs are durable and deliberately have no close action. + {...(contextTab && !contextTab.pinned + ? { onCloseTab: () => onCloseTab(contextTab.tabId), showCloseTab: true } + : {})} + onDelete={() => {}} + showPin={Boolean(contextTab)} + isPinned={Boolean(contextTab?.pinned)} + showRename={false} + showDuplicate={Boolean(contextTab)} + showDelete={false} + /> + } + /> ) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.test.ts index ea0a4eae4b9..58769251e92 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.test.ts @@ -9,8 +9,45 @@ import { terminalFontSizeForZoom, terminalSelectionLabel, terminalSelectionSnapshot, + terminalTooltip, } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session' +describe('terminal tab tooltips', () => { + it('summarizes a long compound heredoc command by its foreground program', () => { + const running = `mkdir -p ~/.doordash-bot/bin && cat > ~/.doordash-bot/bin/dd-cli-mock <<'EOF' +#!/usr/bin/env node +const carts = new Map() +process.stdout.write(JSON.stringify([...carts])) +EOF +chmod +x ~/.doordash-bot/bin/dd-cli-mock && echo '--- smoke test ---' && ~/.doordash-bot/bin/dd-cli-mock submit mock_123` + const tooltip = terminalTooltip({ + terminalId: 'terminal-1', + title: 'mkdir', + cwd: '/Users/emirkarabeg', + running, + interactive: false, + active: false, + }) + + expect(tooltip).toBe('/Users/emirkarabeg — dd-cli-mock') + expect(tooltip).not.toContain('const carts') + }) + + it('preserves the working-directory tooltip for idle terminals', () => { + const idleTab = { + terminalId: 'terminal-1', + title: 'sim', + cwd: '/Users/emirkarabeg/sim', + running: null, + interactive: false, + active: true, + } + + expect(terminalTooltip(idleTab)).toBe('/Users/emirkarabeg/sim') + expect(terminalTooltip({ ...idleTab, cwd: null })).toBe('Terminal') + }) +}) + describe('suspended terminal resource lifecycle', () => { it('does not remove a resource when administrative suspension clears its PTYs', () => { expect(shouldRemoveTerminalResource(0, true, true)).toBe(false) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx index 39fefe98e06..cb124f6e479 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx @@ -34,6 +34,7 @@ import { WebLinksAddon } from '@xterm/addon-web-links' import { WebglAddon } from '@xterm/addon-webgl' import { type IBufferRange, Terminal } from '@xterm/xterm' import { useTheme } from 'next-themes' +import { useContextMenu } from '@/hooks/use-context-menu' import '@xterm/xterm/css/xterm.css' import { describeRunningCommand, @@ -73,7 +74,6 @@ import { useMothershipResources } from '@/app/workspace/[workspaceId]/home/compo import { TerminalContextMenu } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-context-menu' import { TerminalTabIcon } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-tab-icon' import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu' -import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { useDesktopPreferenceMutation } from '@/hooks/use-desktop-preference-mutation' import { useCopilotTerminalStore } from '@/stores/copilot-terminal/store' import type { ChatContext, TerminalTextSelection } from '@/stores/panel' @@ -114,10 +114,10 @@ function hideMountedMenuSurfaces(): void { */ const COMMAND_SETTLE_MS = 1_000 -/** Full working directory, plus whatever the shell is running in it. */ -function terminalTooltip(tab: TerminalTabState): string { +/** Full working directory, plus a concise name for whatever the shell is running. */ +export function terminalTooltip(tab: TerminalTabState): string { const where = tab.cwd ?? 'Terminal' - return tab.running ? `${where} — ${tab.running}` : where + return tab.running ? `${where} — ${describeRunningCommand(tab.running)}` : where } function sameIds(a: ReadonlySet, b: ReadonlySet): boolean { @@ -902,8 +902,8 @@ export function TerminalSession({ visible, scopeId }: TerminalSessionProps) { id: tab.terminalId, title: counts.get(label) === 1 ? label : `${label} ${occurrence}`, // The label is a basename, and the tab may be running something it - // is not naming yet, so hovering gives the whole picture: where the - // shell is, and what it is doing there. + // is not naming yet, so hovering identifies the working directory and + // foreground program without exposing the literal command. tooltip: terminalTooltip(tab), icon: ( - handleDuplicate(contextTab.cwd) : undefined} - onCloseOtherTabs={contextTab ? closeOtherTabs : undefined} - onCloseTabsToRight={contextTab ? closeTabsToRight : undefined} - disableCloseOtherTabs={tabs.length <= 1} - disableCloseTabsToRight={contextIndex < 0 || contextIndex === tabs.length - 1} - {...(contextTab - ? { onCloseTab: () => handleClose(contextTab.terminalId), showCloseTab: true } - : {})} - onDelete={() => {}} - showRename={false} - showDuplicate={Boolean(contextTab)} - showDelete={false} - /> - + overlays={ + handleDuplicate(contextTab.cwd) : undefined} + onCloseOtherTabs={contextTab ? closeOtherTabs : undefined} + onCloseTabsToRight={contextTab ? closeTabsToRight : undefined} + disableCloseOtherTabs={tabs.length <= 1} + disableCloseTabsToRight={contextIndex < 0 || contextIndex === tabs.length - 1} + {...(contextTab + ? { onCloseTab: () => handleClose(contextTab.terminalId), showCloseTab: true } + : {})} + onDelete={() => {}} + showRename={false} + showDuplicate={Boolean(contextTab)} + showDelete={false} + /> + } + />
{tabs.map((tab) => ( diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/index.ts index e8ae4e4ba60..73523336fc0 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/index.ts @@ -3,6 +3,7 @@ export { byResourceMenuOrder, getResourceConfig, invalidateResourceQueries, + MENTION_PREVIEW_DEFAULT_LIMIT, RESOURCE_MENU_ORDER, RESOURCE_REGISTRY, } from './resource-registry' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx index 12af4420995..a4b32224b73 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx @@ -20,6 +20,7 @@ import type { MothershipResource, MothershipResourceType, } from '@/app/workspace/[workspaceId]/home/types' +import { getDisplayStatus, STATUS_CONFIG } from '@/app/workspace/[workspaceId]/logs/utils' import { BrandIcon, type StyleableIcon } from '@/blocks/brand-icon' import { logKeys } from '@/hooks/queries/logs' import { mothershipChatKeys } from '@/hooks/queries/mothership-chats' @@ -40,6 +41,13 @@ export interface ResourceTypeConfig { icon: ElementType renderTabIcon: (resource: MothershipResource, className: string) => ReactNode renderDropdownItem: (props: DropdownItemRenderProps) => ReactNode + /** + * How many of this family's candidates an unfiltered `@` list shows, overriding + * {@link MENTION_PREVIEW_DEFAULT_LIMIT}. Raise it only for a family whose rows a + * user browses; the unfiltered list is a preview, not a browser, and typing a + * query lifts the cap entirely — see `buildMentionPreview`. + */ + mentionPreviewLimit?: number } function WorkflowDropdownItem({ item }: DropdownItemRenderProps) { @@ -91,15 +99,37 @@ function IntegrationDropdownItem({ item }: DropdownItemRenderProps) { ) } +/** + * A run, not the workflow it ran — the Logs icon is what says so, and it is the + * same one the sidebar, the search palette, and the resulting chip already use. + * + * A run that did not simply succeed carries the same dot `Badge` draws at `sm`, + * so a status reads identically here and on the logs page. Marking every row + * would mark nothing, so a plain success gets none. + */ function LogDropdownItem({ item }: DropdownItemRenderProps) { const workflowName = (item.workflowName as string) ?? item.name const time = (item.time as string) ?? '' + const status = getDisplayStatus(item.status as string | null | undefined) + const statusColor = status === 'info' ? null : STATUS_CONFIG[status].color return ( <> - + {workflowName} + {statusColor && ( +
+ )} {time && ( - + {time} )} @@ -219,6 +249,13 @@ export const RESOURCE_REGISTRY: Record {}, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls.ts index 5d5697d7be9..6578225b857 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls.ts @@ -1,17 +1,41 @@ -export const RESOURCE_TAB_GAP_CLASS = 'gap-1.5' - -export const RESOURCE_TAB_ICON_BUTTON_CLASS = 'shrink-0 bg-transparent px-2 py-[5px] text-caption' +/** + * Icon-only controls in the resource header — add, preview mode, the per-resource + * actions — fill the tab strip's control band, so they match the strip's own + * new-tab button and the panel's collapse toggle and the header reads as one row. + */ +export const RESOURCE_TAB_ICON_BUTTON_CLASS = 'size-[var(--tab-strip-band,30px)] shrink-0 p-0' export const RESOURCE_TAB_ICON_CLASS = 'size-[16px] text-[var(--text-icon)]' /** Shared geometry for the resource header and controls positioned over it. */ export const RESOURCE_HEADER_CLASSES = { layout: - '[--resource-header-controls-height:43px] [--resource-header-end-inset:16px] [--resource-header-fixed-reserve:54px] [--resource-header-toggle-size:30px]', - bar: 'h-[calc(var(--resource-header-controls-height)_+_1px)]', + '[--resource-header-controls-height:40px] [--resource-header-end-inset:16px] [--resource-header-fixed-reserve:54px] [--resource-header-toggle-size:30px]', + /** + * Drives the tab strip from this header's own tokens rather than restating the + * strip's defaults, so the height the overlaid controls below are positioned + * against and the height the strip renders at cannot drift apart. Set on the + * strip itself, not an ancestor — the browser and terminal strips nested in + * this panel keep their own geometry. + * + * The `+ 1px` is the strip's own bottom border. The controls height is the + * CONTENT box both clusters centre in, so the strip's box has to be a pixel + * taller than it or the tabs would centre in 43px while the overlaid toggle + * centres in 44px, and the two rows would sit half a pixel apart. + * + * The band is the tabs' own height, set below the 30px the collapse toggle + * keeps: a tab paints a fill, so its box is visible and wants air around it, + * where the toggle and the action buttons are bare glyphs whose box only shows + * on hover. + */ + stripGeometry: + '[--tab-strip-height:calc(var(--resource-header-controls-height)_+_1px)] [--tab-strip-band:26px] [--tab-strip-max-tab-width:160px] [--tab-strip-inline-start:var(--resource-header-end-inset)] [--tab-strip-inline-end:var(--resource-header-fixed-reserve)]', + /** + * Centred, matching the `floating` strip: its tabs and controls sit centred in + * the header band rather than hanging from the top, so an overlaid control has + * to centre too or it lands a pixel below the row it belongs to. + */ overlay: 'absolute top-0 flex h-[var(--resource-header-controls-height)] items-center', - startPadding: 'pl-[var(--resource-header-end-inset)]', - endPadding: 'pr-[var(--resource-header-fixed-reserve)]', endPosition: 'right-[var(--resource-header-end-inset)]', /** * Sits a control 1px clear of the overlaid 30px collapse toggle — the same diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx index b4333837c48..490467861e3 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx @@ -1,16 +1,24 @@ import { type ComponentProps, - type Dispatch, - memo, + type DragEvent as ReactDragEvent, + type MouseEvent as ReactMouseEvent, type ReactNode, - type SetStateAction, useCallback, useEffect, useMemo, useRef, useState, } from 'react' -import { Button, cn, Tooltip, tabStripWheelPosition } from '@sim/emcn' +import { + Button, + cn, + TabStrip, + type TabStripDragContext, + type TabStripItem, + type TabStripSelectionSource, + Tooltip, + tabStripItemSelector, +} from '@sim/emcn' import { Columns3, Eye, Pencil } from '@sim/emcn/icons' import { sendBrowserPanelAction } from '@/lib/browser-agent/transport' import { SIM_RESOURCE_DRAG_TYPE, SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types' @@ -22,7 +30,6 @@ import { AddResourceDropdown } from '@/app/workspace/[workspaceId]/home/componen import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' import { RESOURCE_HEADER_CLASSES, - RESOURCE_TAB_GAP_CLASS, RESOURCE_TAB_ICON_BUTTON_CLASS, RESOURCE_TAB_ICON_CLASS, } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls' @@ -41,9 +48,6 @@ import { useTablesList } from '@/hooks/queries/tables' import { useWorkflows } from '@/hooks/queries/workflows' import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' -const EDGE_ZONE = 40 -const SCROLL_SPEED = 8 - /** Opens another inner tab when a singleton desktop resource already exists. */ export function openExistingResourceTab( resource: MothershipResource, @@ -96,10 +100,10 @@ function findNearestId( * snapshotted it. */ function buildMultiDragImage( - scrollNode: HTMLElement | null, + tabList: Element | null, selected: MothershipResource[] ): HTMLElement | null { - if (!scrollNode || selected.length === 0) return null + if (!tabList || selected.length === 0) return null const container = document.createElement('div') Object.assign(container.style, { position: 'fixed', @@ -113,9 +117,7 @@ function buildMultiDragImage( } satisfies Partial) let appendedAny = false for (const r of selected) { - const original = scrollNode.querySelector( - `[data-resource-tab-id="${CSS.escape(r.id)}"]` - ) + const original = tabList.querySelector(tabStripItemSelector(r.id)) if (!original) continue const clone = original.cloneNode(true) as HTMLElement clone.style.opacity = '0.95' @@ -140,10 +142,9 @@ const PREVIEW_MODE_LABELS: Record = { } /** - * Stable identity for the empty lookup across `enabled` toggles. Unlike - * `NO_RESOURCE_GROUPS`, nothing downstream keys on this identity — tab rows - * receive the derived `displayName` string — so it is cheap insurance rather - * than a guard against busting a downstream memo. + * Stable identity for the empty lookup across `enabled` toggles. The tab list + * memo below takes this map as a dependency, so a fresh empty map each time + * `enabled` flips would rebuild every tab for no change in what they say. */ const NO_RESOURCE_NAMES = new Map() @@ -172,118 +173,6 @@ function useResourceNameLookup(workspaceId: string, enabled: boolean): Map void - onDragOver: (e: React.DragEvent, idx: number) => void - onDragLeave: () => void - onDragEnd: () => void - onTabClick: (e: React.MouseEvent, idx: number) => void - setHoveredTabId: Dispatch> - onRemove: (e: React.SyntheticEvent, resource: MothershipResource) => void -} - -const ResourceTabItem = memo(function ResourceTabItem({ - resource, - idx, - isActive, - isHovered, - isDragging, - isSelected, - hasActivity, - showGapBefore, - showGapAfter, - displayName, - onDragStart, - onDragOver, - onDragLeave, - onDragEnd, - onTabClick, - setHoveredTabId, - onRemove, -}: ResourceTabItemProps) { - const config = getResourceConfig(resource.type) - return ( -
- {showGapBefore && ( -
- )} - - {showGapAfter && ( -
- )} -
- ) -}) - interface ResourceTabsProps { workspaceId: string desktopScopeId: string @@ -298,6 +187,15 @@ interface ResourceTabsProps { onAddResourceClose?: () => Promise } +/** + * The resource panel's tab strip: the shared {@link TabStrip} plus the three + * things only this surface has — a multi-tab selection that drags into the chat + * as context, an add control that is a resource picker rather than a plain + * button, and the active resource's own actions trailing the row. Everything + * else — fixed tab widths, clipped-title tooltips, the scroll-edge fades, + * keyboard navigation, drag reordering — comes from the strip, which is the same + * component the browser and terminal panels nested inside this one use. + */ export function ResourceTabs({ workspaceId, desktopScopeId, @@ -319,59 +217,26 @@ export function ResourceTabs({ removeResource: onRemoveResource, reorderResources: onReorderResources, } = useMothershipResources() - const scrollNodeRef = useRef(null) - - useEffect(() => { - const node = scrollNodeRef.current - if (!node) return - const handler = (e: WheelEvent) => { - const next = tabStripWheelPosition( - node.scrollLeft, - node.scrollWidth, - node.clientWidth, - e.deltaX, - e.deltaY - ) - if (next === null) return - node.scrollLeft = next - e.preventDefault() - } - node.addEventListener('wheel', handler, { passive: false }) - return () => node.removeEventListener('wheel', handler) - }, []) - - useEffect(() => { - const node = scrollNodeRef.current - if (!node || !activeId) return - const tab = node.querySelector(`[data-resource-tab-id="${CSS.escape(activeId)}"]`) - if (!tab) return - // Use bounding rects because the tab's offsetParent is a `position: relative` - // wrapper, so `offsetLeft` is relative to that wrapper rather than `node`. - const tabRect = tab.getBoundingClientRect() - const nodeRect = node.getBoundingClientRect() - const tabLeft = tabRect.left - nodeRect.left + node.scrollLeft - const tabRight = tabLeft + tabRect.width - const viewLeft = node.scrollLeft - const viewRight = viewLeft + node.clientWidth - if (tabLeft < viewLeft) { - node.scrollTo({ left: tabLeft, behavior: 'smooth' }) - } else if (tabRight > viewRight) { - node.scrollTo({ left: tabRight - node.clientWidth, behavior: 'smooth' }) - } - }, [activeId]) const addResource = useAddChatResource(chatId) const removeResource = useRemoveChatResource(chatId) const reorderResources = useReorderChatResources(chatId) - const [hoveredTabId, setHoveredTabId] = useState(null) - const [draggedIdx, setDraggedIdx] = useState(null) - const [dropGapIdx, setDropGapIdx] = useState(null) const [selectedIds, setSelectedIds] = useState>(new Set()) - const dragStartIdx = useRef(null) - const autoScrollRaf = useRef(null) const anchorIdRef = useRef(null) const prevChatIdRef = useRef(chatId) + // The drag image lives on `document.body` rather than in the React tree, + // because `setDragImage` snapshots a real, laid-out element. Holding it lets + // a drag whose source tab unmounts mid-gesture still be cleaned up. + const dragImageRef = useRef(null) + + useEffect( + () => () => { + dragImageRef.current?.remove() + dragImageRef.current = null + }, + [] + ) // Reset selection when switching chats — component instance persists across // chat switches so stale IDs would otherwise carry over. @@ -381,7 +246,23 @@ export function ResourceTabs({ anchorIdRef.current = null } - const existingKeys = new Set(resources.map((r) => `${r.type}:${r.id}`)) + const existingKeys = useMemo( + () => new Set(resources.map((r) => `${r.type}:${r.id}`)), + [resources] + ) + + const tabs = useMemo( + () => + resources.map((resource) => ({ + id: resource.id, + title: nameLookup.get(`${resource.type}:${resource.id}`) ?? resource.title, + icon: getResourceConfig(resource.type).renderTabIcon(resource, 'size-[16px] shrink-0'), + active: activeId === resource.id, + selected: selectedIds.size > 1 && selectedIds.has(resource.id), + attention: activityIds?.has(resource.id) ?? false, + })), + [resources, nameLookup, activeId, selectedIds, activityIds] + ) const handleAdd = useCallback( (resource: MothershipResource) => { @@ -405,13 +286,14 @@ export function ResourceTabs({ [desktopScopeId, selectResource] ) - const handleTabClick = useCallback( - (e: React.MouseEvent, idx: number) => { + const handleSelect = useCallback( + (id: string, _source?: TabStripSelectionSource, e?: ReactMouseEvent) => { + const idx = resources.findIndex((r) => r.id === id) const resource = resources[idx] if (!resource) return // Shift+click: contiguous range from anchor - if (e.shiftKey) { + if (e?.shiftKey) { // Fall back to activeId when no explicit anchor exists (e.g. tab opened via sidebar) const anchorId = anchorIdRef.current ?? activeId const anchorIdx = anchorId ? resources.findIndex((r) => r.id === anchorId) : -1 @@ -427,7 +309,7 @@ export function ResourceTabs({ } // Cmd/Ctrl+click: toggle individual tab in/out of selection - if (e.metaKey || e.ctrlKey) { + if (e?.metaKey || e?.ctrlKey) { const wasSelected = selectedIds.has(resource.id) if (wasSelected) { const next = new Set(selectedIds) @@ -455,9 +337,10 @@ export function ResourceTabs({ [resources, selectResource, selectedIds, activeId] ) - const handleRemove = useCallback( - (e: React.SyntheticEvent, resource: MothershipResource) => { - e.stopPropagation() + const handleClose = useCallback( + (id: string) => { + const resource = resources.find((r) => r.id === id) + if (!resource) return const isMulti = selectedIds.has(resource.id) && selectedIds.size > 1 const targets = isMulti ? resources.filter((r) => selectedIds.has(r.id)) : [resource] // Update parent state immediately for all targets @@ -468,7 +351,7 @@ export function ResourceTabs({ const removedIds = new Set(targets.map((r) => r.id)) setSelectedIds((prev) => { const next = new Set(prev) - for (const id of removedIds) next.delete(id) + for (const removedId of removedIds) next.delete(removedId) return next }) if (anchorIdRef.current && removedIds.has(anchorIdRef.current)) { @@ -488,29 +371,34 @@ export function ResourceTabs({ [chatId, onRemoveResource, resources, selectedIds] ) - const handleDragStart = useCallback( - (e: React.DragEvent, idx: number) => { - const resource = resources[idx] + const handleTabDragStart = useCallback( + (e: ReactDragEvent, id: string, drag: TabStripDragContext) => { + const resource = resources.find((r) => r.id === id) if (!resource) return const selected = resources.filter((r) => selectedIds.has(r.id)) const isMultiDrag = selected.length > 1 && selectedIds.has(resource.id) if (isMultiDrag) { e.dataTransfer.effectAllowed = 'copy' e.dataTransfer.setData(SIM_RESOURCES_DRAG_TYPE, JSON.stringify(selected)) - const dragImage = buildMultiDragImage(scrollNodeRef.current, selected) + const dragImage = buildMultiDragImage(e.currentTarget.closest('[role="tablist"]'), selected) if (dragImage) { e.dataTransfer.setDragImage(dragImage, 16, 16) - setTimeout(() => dragImage.remove(), 0) + dragImageRef.current = dragImage + setTimeout(() => { + dragImage.remove() + if (dragImageRef.current === dragImage) dragImageRef.current = null + }, 0) } - // Skip dragStartIdx so internal reorder is disabled for multi-select drags - dragStartIdx.current = null - setDraggedIdx(null) + // This gesture carries the whole selection out to the chat, so it is not + // a reorder; the strip drops its drag tracking rather than showing a + // drop indicator for a move that will never happen. + drag.preventReorder() return } - dragStartIdx.current = idx - setDraggedIdx(idx) + // `copyMove` because the strip already set `move` for its own reordering, + // and a drop target asking for `copy` is refused outright unless copying + // is allowed too. e.dataTransfer.effectAllowed = 'copyMove' - e.dataTransfer.setData('text/plain', String(idx)) e.dataTransfer.setData( SIM_RESOURCE_DRAG_TYPE, JSON.stringify({ type: resource.type, id: resource.id, title: resource.title }) @@ -519,78 +407,13 @@ export function ResourceTabs({ [resources, selectedIds] ) - const stopAutoScroll = useCallback(() => { - if (autoScrollRaf.current) { - cancelAnimationFrame(autoScrollRaf.current) - autoScrollRaf.current = null - } - }, []) - - const startEdgeScroll = useCallback( - (clientX: number) => { - const container = scrollNodeRef.current - if (!container) return - const cRect = container.getBoundingClientRect() - if (autoScrollRaf.current) cancelAnimationFrame(autoScrollRaf.current) - if (clientX < cRect.left + EDGE_ZONE) { - const tick = () => { - container.scrollLeft -= SCROLL_SPEED - autoScrollRaf.current = requestAnimationFrame(tick) - } - autoScrollRaf.current = requestAnimationFrame(tick) - } else if (clientX > cRect.right - EDGE_ZONE) { - const tick = () => { - container.scrollLeft += SCROLL_SPEED - autoScrollRaf.current = requestAnimationFrame(tick) - } - autoScrollRaf.current = requestAnimationFrame(tick) - } else { - stopAutoScroll() - } - }, - [stopAutoScroll] - ) - - const handleDragOver = useCallback( - (e: React.DragEvent, idx: number) => { - e.preventDefault() - e.dataTransfer.dropEffect = 'move' - const rect = e.currentTarget.getBoundingClientRect() - const midpoint = rect.left + rect.width / 2 - const gap = e.clientX < midpoint ? idx : idx + 1 - setDropGapIdx(gap) - startEdgeScroll(e.clientX) - }, - [startEdgeScroll] - ) - - const handleDragLeave = useCallback(() => { - setDropGapIdx(null) - stopAutoScroll() - }, [stopAutoScroll]) - - const handleDrop = useCallback( - (e: React.DragEvent) => { - e.preventDefault() - stopAutoScroll() - const fromIdx = dragStartIdx.current - const gapIdx = dropGapIdx - if (fromIdx === null || gapIdx === null) { - setDraggedIdx(null) - setDropGapIdx(null) - dragStartIdx.current = null - return - } - const insertAt = gapIdx > fromIdx ? gapIdx - 1 : gapIdx - if (insertAt === fromIdx) { - setDraggedIdx(null) - setDropGapIdx(null) - dragStartIdx.current = null - return - } + const handleReorder = useCallback( + (id: string, targetIndex: number) => { + const fromIndex = resources.findIndex((r) => r.id === id) + if (fromIndex < 0 || fromIndex === targetIndex) return const reordered = [...resources] - const [moved] = reordered.splice(fromIdx, 1) - reordered.splice(insertAt, 0, moved) + const [moved] = reordered.splice(fromIndex, 1) + reordered.splice(targetIndex, 0, moved) onReorderResources(reordered) if (chatId) { const persistable = reordered.filter((r) => !isEphemeralResource(r)) @@ -598,128 +421,65 @@ export function ResourceTabs({ reorderResources.mutate({ chatId, resources: persistable }) } } - setDraggedIdx(null) - setDropGapIdx(null) - dragStartIdx.current = null }, // eslint-disable-next-line react-hooks/exhaustive-deps - [chatId, resources, onReorderResources, dropGapIdx, stopAutoScroll] + [chatId, resources, onReorderResources] ) - const handleDragEnd = useCallback(() => { - stopAutoScroll() - setDraggedIdx(null) - setDropGapIdx(null) - dragStartIdx.current = null - }, [stopAutoScroll]) - - const addResourceDropdown = ( - - ) + const previewToggle = + previewMode && onCyclePreviewMode ? ( + + + + + +

{PREVIEW_MODE_LABELS[previewMode]}

+
+
+ ) : null return ( -
-
-
{ - e.preventDefault() - startEdgeScroll(e.clientX) - }} - onDrop={handleDrop} - > - {resources.map((resource, idx) => { - const displayName = nameLookup.get(`${resource.type}:${resource.id}`) ?? resource.title - const isActive = activeId === resource.id - const isHovered = hoveredTabId === resource.id - const isDragging = draggedIdx === idx - const isSelected = selectedIds.has(resource.id) && selectedIds.size > 1 - const showGapBefore = - dropGapIdx === idx && - draggedIdx !== null && - draggedIdx !== idx && - draggedIdx !== idx - 1 - const showGapAfter = - idx === resources.length - 1 && - dropGapIdx === resources.length && - draggedIdx !== null && - draggedIdx !== idx - - return ( - - ) - })} -
- {/* Offered before the chat exists too: a resource opened while composing - the first prompt is context for that prompt, and gating on a chat id - meant the panel could be opened but not filled. */} -
- {addResourceDropdown} -
-
- {(actions || (previewMode && onCyclePreviewMode)) && ( -
- {actions} - {previewMode && onCyclePreviewMode && ( - - - - - -

{PREVIEW_MODE_LABELS[previewMode]}

-
-
- )} + +
- )} -
+ } + // A bare fragment is always truthy, so the empty case has to be `null` or + // the strip renders an empty trailing cluster. + endActions={ + actions || previewToggle ? ( + <> + {actions} + {previewToggle} + + ) : null + } + /> ) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts index 73eb6eff658..2cc097a1be2 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts @@ -43,14 +43,24 @@ const PORTABLE_KIND_TO_ID_FIELD = { */ export type PortableKind = keyof typeof PORTABLE_KIND_TO_ID_FIELD +/** Serializes a portable chip link, escaping Markdown delimiters in its label. */ +export function serializePortableChipLink(kind: PortableKind, id: string, label: string): string { + const escapedLabel = label.replace(/[\\[\]]/g, '\\$&') + return `[${escapedLabel}](${CHIP_LINK_SCHEME}:${kind}/${id})` +} + +function parsePortableChipLabel(label: string): string { + return label.replace(/\\([\\[\]])/g, '$1') +} + /** * Matches a portable chip markdown link: `[label](sim:kind/id)`. - * - group 1: label (any non-`]` chars) + * - group 1: label (plain or backslash-escaped characters) * - group 2: kind (lowercase letters / underscores, e.g. `past_chat`) * - group 3: id (any non-`)` / non-whitespace chars) */ const CHIP_LINK_PATTERN = new RegExp( - `\\[([^\\]]+)\\]\\(${CHIP_LINK_SCHEME}:([a-z_]+)\\/([^)\\s]+)\\)`, + `\\[((?:\\\\.|[^\\]\\\\])+)\\]\\(${CHIP_LINK_SCHEME}:([a-z_]+)\\/([^)\\s]+)\\)`, 'g' ) @@ -96,7 +106,7 @@ function serializeChipContext(context: ChatContext): string | null { if (!isPortableKind(context.kind)) return null const id = getPortableId(context) if (!id) return null - return `[${context.label}](${CHIP_LINK_SCHEME}:${context.kind}/${id})` + return serializePortableChipLink(context.kind, id, context.label) } /** @@ -205,7 +215,7 @@ export function parseChipLinks(text: string): ParsedChipLink[] { links.push({ kind, id, - label, + label: parsePortableChipLabel(label), start: match.index, end: match.index + full.length, }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts index f24b1890ee7..6d1658abfc3 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts @@ -125,7 +125,12 @@ const RESOURCE_TO_CONTEXT: Record< folder: (r) => ({ kind: 'folder', folderId: r.id, label: r.title }), filefolder: (r) => ({ kind: 'filefolder', fileFolderId: r.id, label: r.title }), task: (r) => ({ kind: 'past_chat', chatId: r.id, label: r.title }), - log: (r) => ({ kind: 'logs', executionId: r.id, label: r.title }), + // Addressed by run, not by log row: `id` is the row's key, and the server + // resolves this context against `workflow_execution_logs.execution_id`. A + // picked resource carries the run id; one rebuilt from the wire (a restored + // or agent-opened tab) cannot, since the stored and streamed resource shapes + // are the identity triple — those keep the row id they have always sent. + log: (r) => ({ kind: 'logs', executionId: r.executionId ?? r.id, label: r.title }), integration: (r) => ({ kind: 'integration', blockType: r.id, label: r.title }), generic: (r) => ({ kind: 'docs', label: r.title }), } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx index a16be8f1c04..1bc821bd712 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx @@ -5,17 +5,24 @@ import { cn, DropdownMenu, DropdownMenuContent, + DropdownMenuLabel, DropdownMenuSearchInput, DropdownMenuTrigger, + dropdownMenuRowClass, } from '@sim/emcn' import { ResourceMenuSections, + resourceFromItem, useAvailableResources, useResourceTreeSections, } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown' -import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' +import { + getResourceConfig, + MENTION_PREVIEW_DEFAULT_LIMIT, +} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' import type { PlusMenuHandle } from '@/app/workspace/[workspaceId]/home/components/user-input/components/constants' import { + buildMentionPreview, resourceMentionMatches, withDesktopTabMentions, } from '@/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items' @@ -26,6 +33,14 @@ import type { import { useBrowserSessionStore } from '@/stores/browser-session/store' import { useCopilotTerminalStore } from '@/stores/copilot-terminal/store' +/** + * The `@` list is shorter than the emcn menu default (420px, sized for right-click + * action menus). This one floats directly over the chat input, so a menu tall enough + * to swallow the conversation behind it reads as a takeover rather than an + * autocomplete. ~10 rows is enough to show several families at once. + */ +const MENTION_MAX_HEIGHT_CLASS = 'max-h-[min(280px,var(--radix-popper-available-height,280px))]' + /** * Resource types that are only offered via `@`-mention autocomplete and hidden * from the `+` browse menu. Integrations are searchable inline (e.g. typing @@ -127,10 +142,12 @@ export const PlusMenuDropdown = React.memo( const filteredItems = useMemo(() => { const rawQuery = isMention ? (mentionQuery ?? '') : search const q = rawQuery.toLowerCase().trim() - // In mention mode always render a flat filtered list — empty query = show everything. if (!isMention && !q) return null if (isMention && !q) { - return visibleResources.flatMap(({ type, items }) => items.map((item) => ({ type, item }))) + return buildMentionPreview( + visibleResources, + (type) => getResourceConfig(type).mentionPreviewLimit ?? MENTION_PREVIEW_DEFAULT_LIMIT + ) } return visibleResources.flatMap(({ type, items }) => items.filter((item) => resourceMentionMatches(item, q)).map((item) => ({ type, item })) @@ -181,11 +198,7 @@ export const PlusMenuDropdown = React.memo( const items = filteredItemsRef.current const target = items?.length ? (items[activeIndexRef.current] ?? items[0]) : undefined if (!target) return isHydratingRef.current ? 'hydrating' : 'empty' - handleSelectRef.current({ - type: target.type, - id: target.item.id, - title: target.item.name, - }) + handleSelectRef.current(resourceFromItem(target.type, target.item)) return 'selected' }, }), @@ -224,7 +237,7 @@ export const PlusMenuDropdown = React.memo( } else if (e.key === 'Enter' || (e.key === 'Tab' && !e.shiftKey)) { e.preventDefault() const target = filteredItems[activeIndex] ?? filteredItems[0] - if (target) handleSelect({ type: target.type, id: target.item.id, title: target.item.name }) + if (target) handleSelect(resourceFromItem(target.type, target.item)) } } @@ -298,7 +311,7 @@ export const PlusMenuDropdown = React.memo( // Plus-click shows short fixed labels (Workflows, Tables, …) — let it size // to its content via the emcn DropdownMenuContent default max-w. // Mention mode renders resource names directly, so widen for breathing room. - isMention && 'max-w-[min(300px,calc(100vw-32px))]' + isMention && `max-w-[min(300px,calc(100vw-32px))] ${MENTION_MAX_HEIGHT_CLASS}` )} onCloseAutoFocus={handleCloseAutoFocus} onOpenAutoFocus={handleOpenAutoFocus} @@ -334,28 +347,36 @@ export const PlusMenuDropdown = React.memo( filteredItems.map(({ type, item }, index) => { const config = getResourceConfig(type) const isActive = index === activeIndex + /* Items arrive grouped by family (one group per type, ordered by + RESOURCE_MENU_ORDER), so a type change marks a section boundary. + Deriving the heading from the flat list keeps `activeIndex` — and + therefore every keyboard path — indexing exactly what it did. */ + const startsSection = index === 0 || filteredItems[index - 1]?.type !== type return ( - + + {startsSection && {config.label}} + + ) }) ) : ( -
+
No results
))} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items.test.ts index bed14025882..f99953e1681 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items.test.ts @@ -3,7 +3,9 @@ import { BROWSER_SESSION_RESOURCE_ID, TERMINAL_SESSION_RESOURCE_ID, } from '@/lib/copilot/resources/types' +import type { AvailableItem } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-folder-tree' import { + buildMentionPreview, resourceMentionMatches, withDesktopTabMentions, } from '@/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items' @@ -106,3 +108,53 @@ describe('withDesktopTabMentions', () => { expect(resourceMentionMatches(tab, 'terminal')).toBe(false) }) }) + +describe('buildMentionPreview', () => { + const item = (id: string): AvailableItem => ({ id, name: id }) + const many = (n: number) => Array.from({ length: n }, (_, i) => item(`i${i}`)) + + it('caps each family so a large one cannot bury the families after it', () => { + const preview = buildMentionPreview( + [ + { type: 'integration', items: many(300) }, + { type: 'workflow', items: [item('thermal-field')] }, + ], + () => 5 + ) + + expect(preview.filter((c) => c.type === 'integration')).toHaveLength(5) + expect(preview.map((c) => c.item.id)).toContain('thermal-field') + }) + + it('lets a family raise its own cap', () => { + const preview = buildMentionPreview( + [ + { type: 'integration', items: many(10) }, + { type: 'workflow', items: many(10) }, + ], + (type) => (type === 'workflow' ? 2 : 5) + ) + + expect(preview.filter((c) => c.type === 'integration')).toHaveLength(5) + expect(preview.filter((c) => c.type === 'workflow')).toHaveLength(2) + }) + + it('keeps families in the order they were given, so headings stay contiguous', () => { + const preview = buildMentionPreview( + [ + { type: 'integration', items: many(3) }, + { type: 'workflow', items: many(3) }, + ], + () => 5 + ) + + const boundaries = preview.filter((c, i) => i > 0 && preview[i - 1].type !== c.type) + expect(boundaries).toHaveLength(1) + expect(preview.at(-1)?.type).toBe('workflow') + }) + + it('keeps a family shorter than the cap intact', () => { + const preview = buildMentionPreview([{ type: 'workflow', items: many(2) }], () => 5) + expect(preview).toHaveLength(2) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items.ts index bbbe84ad29e..fb68fd2ce50 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items.ts @@ -92,3 +92,30 @@ export function withDesktopTabMentions( return group }) } + +/** One row of the `@` list: an item plus the family it came from. */ +export interface ResourceMentionCandidate { + type: MothershipResourceType + item: AvailableItem +} + +/** + * The rows an `@` list shows for an EMPTY query — a preview of what is mentionable, + * capped per family so no one family can bury the rest. + * + * `integration` carries 300+ near-identical rows and sorts FIRST, so while the cap + * defaulted to "uncapped" the preview was its entire catalog and no other family was + * reachable without scrolling past all of it. Capping is therefore the default and a + * family opts out by raising its own limit, not by omitting one. + * + * Only the empty-query preview is capped; {@link resourceMentionMatches} searches + * every family in full once the user types. + */ +export function buildMentionPreview( + groups: readonly ResourceMentionGroup[], + limitFor: (type: MothershipResourceType) => number +): ResourceMentionCandidate[] { + return groups.flatMap(({ type, items }) => + items.slice(0, limitFor(type)).map((item) => ({ type, item })) + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/skills-menu-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/skills-menu-dropdown.tsx index c350b1d6be9..1463cf83d73 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/skills-menu-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/skills-menu-dropdown.tsx @@ -1,7 +1,13 @@ 'use client' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { cn, DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@sim/emcn' +import { + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, + dropdownMenuRowClass, +} from '@sim/emcn' import { AgentSkillsIcon, McpIcon } from '@/components/icons' import type { McpServer } from '@/hooks/queries/mcp' import type { SkillDefinition } from '@/hooks/queries/skills' @@ -210,7 +216,8 @@ export const SkillsMenuDropdown = React.memo( onMouseEnter={() => setActiveIndex(index)} onClick={() => handleSelect(target)} className={cn( - 'relative flex w-full min-w-0 cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-1.5 text-left text-[var(--text-body)] text-caption outline-none transition-colors duration-0 [&>span]:min-w-0 [&>span]:truncate [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]', + dropdownMenuRowClass, + 'w-full text-left', /* `activeIndex` is the cursor, not a selection — hover surface. */ isActive && 'bg-[var(--surface-hover)]' )} @@ -221,7 +228,7 @@ export const SkillsMenuDropdown = React.memo( ) }) ) : ( -
+
No skills or MCP servers
)} diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index 66bd244c680..ddc31f59d36 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -652,6 +652,7 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps)
) : ( m.executeLocalFilesystemTool(toolCallId, toolName, toolArgs, options), + async (error) => { + logger.error('Failed to load local filesystem tool executor', { error }) + /** + * The recovery itself can reject (the helper chunks or the completion POST can + * fail for the same reason the executor chunk did). Contain it: an unhandled + * rejection here would settle nothing and surface as a console error, exactly + * like the executor's own report-failure path, which also degrades to a log. + */ + try { + const [{ reportClientToolCompletion }, { ASYNC_TOOL_CONFIRMATION_STATUS }] = + await Promise.all([ + import('@/lib/copilot/tools/client/completion'), + import('@/lib/copilot/async-runs/lifecycle'), + ]) + await reportClientToolCompletion( + toolCallId, + ASYNC_TOOL_CONFIRMATION_STATUS.error, + 'Local filesystem tool failed to load' + ) + } catch (reportError) { + logger.error('Failed to report local filesystem tool load failure', { + toolCallId, + error: reportError, + }) + } + } + ) }, [workspaceId] ) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-context-menu/chunk-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-context-menu/chunk-context-menu.tsx index 605dfa1f53a..e7488424da5 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-context-menu/chunk-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-context-menu/chunk-context-menu.tsx @@ -8,6 +8,10 @@ import { DropdownMenuTrigger, } from '@sim/emcn' import { Duplicate, Eye, Pencil, Plus, SquareArrowUpRight, Trash } from '@sim/emcn/icons' +import { + selectionActionLabel, + selectionToggleActionLabel, +} from '@/app/workspace/[workspaceId]/components/resource/selection-label' interface ChunkContextMenuProps { isOpen: boolean @@ -26,14 +30,14 @@ interface ChunkContextMenuProps { disableAddChunk?: boolean disableEdit?: boolean isConnectorDocument?: boolean - selectedCount?: number + selectedCount: number enabledCount?: number disabledCount?: number } /** * Context menu for chunks table. - * Shows chunk actions when right-clicking a row, or "Create chunk" when right-clicking empty space. + * Shows chunk actions when right-clicking a row, or "New chunk" when right-clicking empty space. * Supports batch operations when multiple chunks are selected. */ export function ChunkContextMenu({ @@ -53,24 +57,23 @@ export function ChunkContextMenu({ disableAddChunk = false, disableEdit = false, isConnectorDocument = false, - selectedCount = 1, + selectedCount, enabledCount = 0, disabledCount = 0, }: ChunkContextMenuProps) { const isMultiSelect = selectedCount > 1 - - const getToggleLabel = () => { - if (isMultiSelect) { - if (disabledCount > 0) return 'Enable' - return 'Disable' - } - return isChunkEnabled ? 'Disable' : 'Enable' - } + const toggleLabel = selectionToggleActionLabel({ + selectedCount, + enabledCount, + disabledCount, + isSelectedItemEnabled: isChunkEnabled, + }) const hasNavigationSection = !isMultiSelect && !!onOpenInNewTab const hasEditSection = !isMultiSelect && (!!onEdit || !!onCopyContent) const hasStateSection = !!onToggleEnabled const hasDestructiveSection = !!onDelete + const hasActionsAboveDestructive = hasNavigationSection || hasEditSection || hasStateSection return ( !open && onClose()} modal={false}> @@ -102,11 +105,6 @@ export function ChunkContextMenu({ Open in new tab )} - {hasNavigationSection && - (hasEditSection || hasStateSection || hasDestructiveSection) && ( - - )} - {!isMultiSelect && onEdit && ( @@ -119,22 +117,18 @@ export function ChunkContextMenu({ Copy content )} - {hasEditSection && (hasStateSection || hasDestructiveSection) && ( - - )} - {onToggleEnabled && ( - {getToggleLabel()} + {toggleLabel} )} - {hasStateSection && hasDestructiveSection && } + {hasActionsAboveDestructive && hasDestructiveSection && } {onDelete && ( - Delete + {selectionActionLabel('Delete', selectedCount)} )} @@ -142,7 +136,7 @@ export function ChunkContextMenu({ onAddChunk && ( - Create chunk + New chunk ) )} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-editor/chunk-editor.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-editor/chunk-editor.tsx index fd6e1f23667..db86183c216 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-editor/chunk-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-editor/chunk-editor.tsx @@ -6,7 +6,7 @@ import { isApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import { getKnowledgeChunkContract } from '@/lib/api/contracts/knowledge' import type { ChunkData, DocumentData } from '@/lib/knowledge/types' -import { getAccurateTokenCount, getTokenStrings } from '@/lib/tokenization/estimators' +import { getAccurateTokenCount, getTokenStrings } from '@/lib/tokenization/accurate' import { useCreateChunk, useUpdateChunk } from '@/hooks/queries/kb/knowledge' import { useAutosave } from '@/hooks/use-autosave' diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx index 3edc0720140..2c381a7cf1f 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -59,7 +59,6 @@ import { import { ActionBar } from '@/app/workspace/[workspaceId]/knowledge/[id]/components' import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' -import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' import { useDocument, useDocumentChunks, useKnowledgeBase } from '@/hooks/kb/use-knowledge' import { @@ -69,6 +68,7 @@ import { useUpdateChunk, useUpdateDocument, } from '@/hooks/queries/kb/knowledge' +import { useContextMenu } from '@/hooks/use-context-menu' import { useDebounce } from '@/hooks/use-debounce' import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' import { useInlineRename } from '@/hooks/use-inline-rename' diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index 95c2e69871d..7e5d1e45483 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -41,7 +41,12 @@ import { format } from 'date-fns' import { useParams, useRouter } from 'next/navigation' import { useQueryState, useQueryStates } from 'nuqs' import { usePostHog } from 'posthog-js/react' -import { ALL_TAG_SLOTS, type AllTagSlot, getFieldTypeForSlot } from '@/lib/knowledge/constants' +import { + ALL_TAG_SLOTS, + type AllTagSlot, + getFieldTypeForSlot, + KNOWLEDGE_DOCUMENT_PROCESSING_STALE_THRESHOLD_MS, +} from '@/lib/knowledge/constants' import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/filters/types' import type { DocumentData } from '@/lib/knowledge/types' @@ -53,7 +58,6 @@ import type { FilterTag, ResourceAction, ResourceCell, - ResourceColumn, ResourceRow, SelectableConfig, SortConfig, @@ -73,7 +77,13 @@ import { useFolderAncestors, } from '@/app/workspace/[workspaceId]/components/folders' import { DocumentsEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state' -import { DocumentTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components' +/** + * Deep import on purpose: the `[documentId]/components` barrel also exports `ChunkEditor`, + * which needs exact token counts and therefore `js-tiktoken` (~2.5 MB gzip of BPE rank + * tables). Importing the modal through the barrel shipped the tokenizer to the document + * LIST route, which never edits chunks. + */ +import { DocumentTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal' import { ActionBar, AddConnectorModal, @@ -83,6 +93,7 @@ import { DocumentContextMenu, RenameDocumentModal, } from '@/app/workspace/[workspaceId]/knowledge/[id]/components' +import { DOCUMENT_COLUMNS } from '@/app/workspace/[workspaceId]/knowledge/[id]/document-columns' import { addConnectorParam, documentFiltersParsers, @@ -92,14 +103,18 @@ import { import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' -import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { BrandIcon } from '@/blocks/brand-icon' import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' -import { useKnowledgeBase, useKnowledgeBaseDocuments } from '@/hooks/kb/use-knowledge' +import { + hasProcessingDocuments, + useKnowledgeBase, + useKnowledgeBaseDocuments, +} from '@/hooks/kb/use-knowledge' import { type TagDefinition, useKnowledgeBaseTagDefinitions, } from '@/hooks/kb/use-knowledge-base-tag-definitions' +import type { ConnectorData } from '@/hooks/queries/kb/connectors' import { isConnectorSyncingOrPending, useConnectorList } from '@/hooks/queries/kb/connectors' import type { DocumentTagFilter } from '@/hooks/queries/kb/knowledge' import { @@ -109,6 +124,7 @@ import { useUpdateDocument, useUpdateKnowledgeBase, } from '@/hooks/queries/kb/knowledge' +import { useContextMenu } from '@/hooks/use-context-menu' import { useDebounce } from '@/hooks/use-debounce' import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' import { useInlineRename } from '@/hooks/use-inline-rename' @@ -117,17 +133,27 @@ import { useUrlSort } from '@/hooks/use-url-sort' const logger = createLogger('KnowledgeBase') +/** + * Identifies one processing *run*, not one document. + * + * Keying on the attempt's start time makes the reported-set self-invalidating: + * a document that is retried gets a new `processingStartedAt`, so a later stall + * is reportable again without the set needing to be pruned. + */ +function deadProcessKey(doc: Pick) { + return `${doc.id}:${doc.processingStartedAt ?? ''}` +} + const DOCUMENTS_PER_PAGE = 50 -const DOCUMENT_COLUMNS: ResourceColumn[] = [ - { id: 'name', header: 'Name', widthMultiplier: 0.8 }, - { id: 'size', header: 'Size', widthMultiplier: 0.75 }, - { id: 'tokens', header: 'Tokens', widthMultiplier: 0.75 }, - { id: 'chunks', header: 'Chunks', widthMultiplier: 0.75 }, - { id: 'uploaded', header: 'Uploaded' }, - { id: 'status', header: 'Status', widthMultiplier: 0.75 }, - { id: 'tags', header: 'Tags' }, -] +/** Stable identity so an absent connector list does not re-fire list-dependent effects. */ +const EMPTY_CONNECTORS: ConnectorData[] = [] + +/** Cadence while a document is still indexing — its own status is what moves. */ +const PROCESSING_POLL_INTERVAL_MS = 3000 + +/** Slower cadence while only a connector sync is running: rows arrive in batches. */ +const CONNECTOR_SYNC_DOCUMENT_POLL_INTERVAL_MS = 5000 const STATUS_FILTER_OPTIONS: ChipDropdownOption[] = [ { value: 'all', label: 'All' }, @@ -407,7 +433,8 @@ export function KnowledgeBase({ refresh: refreshKnowledgeBase, } = useKnowledgeBase(id) - const { data: connectors = [], isLoading: isLoadingConnectors } = useConnectorList(id) + const { data: connectors = EMPTY_CONNECTORS, isLoading: isLoadingConnectors } = + useConnectorList(id) const hasSyncingConnectors = connectors.some(isConnectorSyncingOrPending) const hasSyncingConnectorsRef = useRef(hasSyncingConnectors) hasSyncingConnectorsRef.current = hasSyncingConnectors @@ -418,7 +445,6 @@ export function KnowledgeBase({ isLoading: isLoadingDocuments, isPlaceholderData: isPlaceholderDocuments, error: documentsError, - hasProcessingDocuments, updateDocument, refreshDocuments, } = useKnowledgeBaseDocuments(id, { @@ -429,11 +455,8 @@ export function KnowledgeBase({ sortOrder: sortDirection as SortOrder, refetchInterval: (data) => { if (isDeleting) return false - const hasPending = data?.documents?.some( - (doc) => doc.processingStatus === 'pending' || doc.processingStatus === 'processing' - ) - if (hasPending) return 3000 - if (hasSyncingConnectorsRef.current) return 5000 + if (hasProcessingDocuments(data?.documents ?? [])) return PROCESSING_POLL_INTERVAL_MS + if (hasSyncingConnectorsRef.current) return CONNECTOR_SYNC_DOCUMENT_POLL_INTERVAL_MS return false }, enabledFilter: enabledFilter, @@ -489,20 +512,27 @@ export function KnowledgeBase({ const totalPages = Math.ceil(pagination.total / pagination.limit) /** - * Checks for documents with stale processing states and marks them as failed + * Processing runs already reported as timed out. + * + * The list below polls every few seconds while anything is processing, and + * each poll hands this effect a new array. Without this the same stale + * document is re-reported on every tick until the server's new status comes + * back — one redundant write per poll, per open tab. */ + const reportedDeadProcessesRef = useRef | null>(null) + const checkForDeadProcesses = useCallback( (docsToCheck: DocumentData[]) => { - const now = new Date() - const DEAD_PROCESS_THRESHOLD_MS = 600 * 1000 // 10 minutes + const reported = (reportedDeadProcessesRef.current ??= new Set()) + const nowMs = Date.now() const staleDocuments = docsToCheck.filter((doc) => { - if (doc.processingStatus !== 'processing' || !doc.processingStartedAt) { - return false - } - - const processingDuration = now.getTime() - new Date(doc.processingStartedAt).getTime() - return processingDuration > DEAD_PROCESS_THRESHOLD_MS + if (doc.processingStatus !== 'processing' || !doc.processingStartedAt) return false + if (reported.has(deadProcessKey(doc))) return false + return ( + nowMs - new Date(doc.processingStartedAt).getTime() > + KNOWLEDGE_DOCUMENT_PROCESSING_STALE_THRESHOLD_MS + ) }) if (staleDocuments.length === 0) return @@ -510,6 +540,7 @@ export function KnowledgeBase({ logger.warn(`Found ${staleDocuments.length} documents with dead processes`) staleDocuments.forEach((doc) => { + reported.add(deadProcessKey(doc)) updateDocumentMutation( { knowledgeBaseId: id, @@ -522,6 +553,8 @@ export function KnowledgeBase({ `Successfully marked dead process as failed for document: ${doc.filename}` ) }, + /** Retried on the next poll rather than left silently unreported. */ + onError: () => reported.delete(deadProcessKey(doc)), } ) }) @@ -530,10 +563,8 @@ export function KnowledgeBase({ ) useEffect(() => { - if (hasProcessingDocuments) { - checkForDeadProcesses(documents) - } - }, [hasProcessingDocuments, documents, checkForDeadProcesses]) + checkForDeadProcesses(documents) + }, [documents, checkForDeadProcesses]) const handleToggleEnabled = (docId: string) => { const document = documents.find((doc) => doc.id === docId) @@ -662,6 +693,7 @@ export function KnowledgeBase({ * Handles selecting/deselecting a document */ const handleSelectDocument = (docId: string, checked: boolean) => { + setIsSelectAllMode(false) setSelectedDocuments((prev) => { const newSet = new Set(prev) if (checked) { @@ -883,6 +915,7 @@ export function KnowledgeBase({ ? 0 : pagination.total : selectedDocumentsList.filter((doc) => !doc.enabled).length + const selectedDocumentCount = isSelectAllMode ? pagination.total : selectedDocuments.size const handleDocumentContextMenu = useCallback( (e: React.MouseEvent, docId: string) => { @@ -892,6 +925,7 @@ export function KnowledgeBase({ const isCurrentlySelected = selectedDocuments.has(doc.id) if (!isCurrentlySelected) { + setIsSelectAllMode(false) setSelectedDocuments(new Set([doc.id])) } @@ -1083,6 +1117,7 @@ export function KnowledgeBase({ {connectors.map((connector) => { const def = CONNECTOR_META_REGISTRY[connector.connectorType] const ConnectorIcon = def?.icon + const syncInFlight = isConnectorSyncingOrPending(connector) return ( @@ -464,12 +424,7 @@ function ConnectorCard({ disabled={syncDisabled} onClick={() => onSync(false)} > - + @@ -498,9 +453,7 @@ function ConnectorCard({ onClick={onTogglePause} disabled={isUpdating} > - {isUpdating ? ( - - ) : connector.status === 'paused' || connector.status === 'disabled' ? ( + {connector.status === 'paused' || connector.status === 'disabled' ? ( ) : ( diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/document-context-menu/document-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/document-context-menu/document-context-menu.tsx index 7050da64725..c6d9075d0b1 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/document-context-menu/document-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/document-context-menu/document-context-menu.tsx @@ -8,6 +8,10 @@ import { DropdownMenuTrigger, } from '@sim/emcn' import { Eye, Pencil, Plus, SquareArrowUpRight, TagIcon, Trash } from '@sim/emcn/icons' +import { + selectionActionLabel, + selectionToggleActionLabel, +} from '@/app/workspace/[workspaceId]/components/resource/selection-label' interface DocumentContextMenuProps { isOpen: boolean @@ -26,9 +30,10 @@ interface DocumentContextMenuProps { disableToggleEnabled?: boolean disableDelete?: boolean disableAddDocument?: boolean - selectedCount?: number + selectedCount: number enabledCount?: number disabledCount?: number + hasExactToggleCount?: boolean } /** @@ -53,24 +58,25 @@ export function DocumentContextMenu({ disableToggleEnabled = false, disableDelete = false, disableAddDocument = false, - selectedCount = 1, + selectedCount, enabledCount = 0, disabledCount = 0, + hasExactToggleCount = true, }: DocumentContextMenuProps) { const isMultiSelect = selectedCount > 1 - - const getToggleLabel = () => { - if (isMultiSelect) { - if (disabledCount > 0) return 'Enable' - return 'Disable' - } - return isDocumentEnabled ? 'Disable' : 'Enable' - } + const toggleLabel = selectionToggleActionLabel({ + selectedCount, + enabledCount, + disabledCount, + isSelectedItemEnabled: isDocumentEnabled, + hasExactAffectedCount: hasExactToggleCount, + }) const hasNavigationSection = !isMultiSelect && (!!onOpenInNewTab || !!onOpenSource) const hasEditSection = !isMultiSelect && (!!onRename || !!onViewTags) const hasStateSection = !!onToggleEnabled const hasDestructiveSection = !!onDelete + const hasActionsAboveDestructive = hasNavigationSection || hasEditSection || hasStateSection return ( !open && onClose()} modal={false}> @@ -108,11 +114,6 @@ export function DocumentContextMenu({ Open source )} - {hasNavigationSection && - (hasEditSection || hasStateSection || hasDestructiveSection) && ( - - )} - {!isMultiSelect && onRename && ( @@ -125,22 +126,18 @@ export function DocumentContextMenu({ Tags )} - {hasEditSection && (hasStateSection || hasDestructiveSection) && ( - - )} - {onToggleEnabled && ( - {getToggleLabel()} + {toggleLabel} )} - {hasStateSection && hasDestructiveSection && } + {hasActionsAboveDestructive && hasDestructiveSection && } {onDelete && ( - Delete + {selectionActionLabel('Delete', selectedCount)} )} @@ -148,7 +145,7 @@ export function DocumentContextMenu({ onAddDocument && ( - Add document + New documents ) )} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/document-columns.test.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/document-columns.test.ts new file mode 100644 index 00000000000..41bab124d5f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/document-columns.test.ts @@ -0,0 +1,14 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { DOCUMENT_COLUMNS } from '@/app/workspace/[workspaceId]/knowledge/[id]/document-columns' + +describe('DOCUMENT_COLUMNS', () => { + it('keeps the status column at the default width immediately before tags', () => { + const statusIndex = DOCUMENT_COLUMNS.findIndex((column) => column.id === 'status') + + expect(DOCUMENT_COLUMNS[statusIndex]).toEqual({ id: 'status', header: 'Status' }) + expect(DOCUMENT_COLUMNS[statusIndex + 1]?.id).toBe('tags') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/document-columns.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/document-columns.ts new file mode 100644 index 00000000000..d2a3746c010 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/document-columns.ts @@ -0,0 +1,11 @@ +import type { ResourceColumn } from '@/app/workspace/[workspaceId]/components' + +export const DOCUMENT_COLUMNS: ResourceColumn[] = [ + { id: 'name', header: 'Name', widthMultiplier: 0.8 }, + { id: 'size', header: 'Size', widthMultiplier: 0.75 }, + { id: 'tokens', header: 'Tokens', widthMultiplier: 0.75 }, + { id: 'chunks', header: 'Chunks', widthMultiplier: 0.75 }, + { id: 'uploaded', header: 'Uploaded' }, + { id: 'status', header: 'Status' }, + { id: 'tags', header: 'Tags' }, +] diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/loading.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/loading.tsx index 640117df44b..51c192bfc45 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/loading.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/loading.tsx @@ -8,19 +8,10 @@ import { ResourceChromeFallback, } from '@/app/workspace/[workspaceId]/components' import { FOLDERED_RESOURCE_HEADERS } from '@/app/workspace/[workspaceId]/components/folders/foldered-resources' +import { DOCUMENT_COLUMNS } from '@/app/workspace/[workspaceId]/knowledge/[id]/document-columns' const KNOWLEDGE_HEADER = FOLDERED_RESOURCE_HEADERS.knowledge_base -const COLUMNS = [ - { id: 'name', header: 'Name', widthMultiplier: 0.8 }, - { id: 'size', header: 'Size', widthMultiplier: 0.75 }, - { id: 'tokens', header: 'Tokens', widthMultiplier: 0.75 }, - { id: 'chunks', header: 'Chunks', widthMultiplier: 0.75 }, - { id: 'uploaded', header: 'Uploaded' }, - { id: 'status', header: 'Status', widthMultiplier: 0.75 }, - { id: 'tags', header: 'Tags' }, -] - const ACTIONS: ChromeActionSpec[] = [ { text: 'New connector', icon: Plus }, { text: 'New documents', icon: Plus, variant: 'primary' }, @@ -36,7 +27,7 @@ export default function KnowledgeBaseLoading() { 1 + const hasNavigationSection = !isMultiSelect && showOpenInNewTab && !!onOpenInNewTab + const hasInfoSection = + !isMultiSelect && ((showViewTags && !!onViewTags) || !!onCopyId || !!onTogglePin) const hasMoveSection = !disableEdit && !!onMove && !!moveOptions && moveOptions.length > 0 - const hasEditSection = (showEdit && !!onEdit) || hasMoveSection + const hasEditSection = (!isMultiSelect && showEdit && !!onEdit) || hasMoveSection const hasDestructiveSection = showDelete && !!onDelete + const hasActionsAboveDestructive = hasNavigationSection || hasInfoSection || hasEditSection return ( !open && onClose()} modal={false}> @@ -104,31 +110,25 @@ export const KnowledgeBaseContextMenu = memo(function KnowledgeBaseContextMenu({ Open in new tab )} - {hasNavigationSection && (hasInfoSection || hasEditSection || hasDestructiveSection) && ( - - )} - - {showViewTags && onViewTags && ( + {!isMultiSelect && showViewTags && onViewTags && ( View tags )} - {onCopyId && ( + {!isMultiSelect && onCopyId && ( Copy ID )} - {onTogglePin && ( + {!isMultiSelect && onTogglePin && ( {pinned ? 'Unpin' : 'Pin'} )} - {hasInfoSection && (hasEditSection || hasDestructiveSection) && } - - {showEdit && onEdit && ( + {!isMultiSelect && showEdit && onEdit && ( Edit @@ -139,7 +139,7 @@ export const KnowledgeBaseContextMenu = memo(function KnowledgeBaseContextMenu({ - Move to + {selectionActionLabel('Move', selectedCount, 'Move to')} {renderMoveOptions(moveOptions!, onMove!)} @@ -147,11 +147,11 @@ export const KnowledgeBaseContextMenu = memo(function KnowledgeBaseContextMenu({ )} - {hasEditSection && hasDestructiveSection && } + {hasActionsAboveDestructive && hasDestructiveSection && } {showDelete && onDelete && ( - Delete + {selectionActionLabel('Delete', selectedCount)} )} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx index 6176f1abe9b..2c8258ad4f9 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx @@ -79,7 +79,6 @@ import { } from '@/app/workspace/[workspaceId]/knowledge/search-params' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' -import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { BrandIcon } from '@/blocks/brand-icon' import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge' @@ -92,6 +91,7 @@ import { } from '@/hooks/queries/kb/knowledge' import { usePinItem, usePinnedIds, useUnpinItem } from '@/hooks/queries/pinned-items' import { useWorkspaceMembersQuery, type WorkspaceMember } from '@/hooks/queries/workspace' +import { useContextMenu } from '@/hooks/use-context-menu' import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' import { useInlineRename } from '@/hooks/use-inline-rename' import { usePermissionConfig } from '@/hooks/use-permission-config' @@ -1462,6 +1462,7 @@ export function Knowledge() { showDelete disableEdit={!canEdit} disableDelete={!canEdit} + selectedCount={selectedRowIds.size} /> )} @@ -1479,6 +1480,7 @@ export function Knowledge() { onMove={handleMoveFolderFromMenu} moveOptions={activeFolderMoveOptions} canEdit={canEdit} + selectedCount={selectedRowIds.size} /> )} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/workflows-list/workflows-list.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/workflows-list/workflows-list.tsx index e46027442d5..3dae7eee182 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/workflows-list/workflows-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/workflows-list/workflows-list.tsx @@ -1,8 +1,8 @@ import { memo } from 'react' import { cn, handleKeyboardActivation } from '@sim/emcn' import { Workflow } from '@sim/emcn/icons' +import { DELETED_WORKFLOW_LABEL } from '@/lib/workflows/workflow-labels' import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components' -import { DELETED_WORKFLOW_LABEL } from '@/app/workspace/[workspaceId]/logs/utils' import { StatusBar, type StatusBarSegment } from '..' export interface WorkflowExecutionItem { diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/index.ts b/apps/sim/app/workspace/[workspaceId]/logs/components/index.ts index c8b8e357e15..b374e4f738c 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/index.ts @@ -1,6 +1,5 @@ export { Dashboard } from './dashboard' export { LogDetails, LogDetailsContent } from './log-details' -export { ExecutionSnapshot } from './log-details/components/execution-snapshot' export { FileCards } from './log-details/components/file-download' export { TraceView } from './log-details/components/trace-view' export { LogRowContextMenu } from './log-row-context-menu' diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/index.ts b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/index.ts deleted file mode 100644 index a80bf4e337d..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ExecutionSnapshot } from './execution-snapshot' diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/snapshot-boundary.test.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/snapshot-boundary.test.tsx new file mode 100644 index 00000000000..221aa5892e4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/snapshot-boundary.test.tsx @@ -0,0 +1,116 @@ +/** + * @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 { mockToastError } = vi.hoisted(() => ({ + mockToastError: vi.fn(), +})) + +vi.mock('@sim/emcn', () => ({ + Loader: () =>