diff --git a/.agents/skills/add-block/SKILL.md b/.agents/skills/add-block/SKILL.md index 24fd80608ce..7e1d2cc8054 100644 --- a/.agents/skills/add-block/SKILL.md +++ b/.agents/skills/add-block/SKILL.md @@ -19,6 +19,10 @@ When the user asks you to create a block: Blocks depend on tool outputs. If the underlying tool response schema is not documented or live-verified, you MUST tell the user instead of guessing block outputs. +When block work changes tool execution, same-process work must use a registered +`InternalToolConfig.operation`. Never add a Sim `/api/...` self-hop or the retired +`directExecution` property. + - Do NOT invent block outputs for undocumented tool responses - Do NOT describe unknown JSON shapes as if they were confirmed - Do NOT wire fields into the block just because they seem likely to exist diff --git a/.agents/skills/add-integration/SKILL.md b/.agents/skills/add-integration/SKILL.md index b8e69d488bb..eea2d054ccf 100644 --- a/.agents/skills/add-integration/SKILL.md +++ b/.agents/skills/add-integration/SKILL.md @@ -68,7 +68,7 @@ Choose the tool boundary before writing the declaration: - Use `ToolConfig.request` only for an absolute external HTTP(S) provider endpoint. Never point a tool at `/api/...`, construct an absolute URL back to Sim, declare -`request.internal`, or add an API route merely to reuse code, normalize files, or authorize +`request.internal`, add the retired `directExecution` property, or add an API route merely to reuse code, normalize files, or authorize resources. A real external/browser route and an in-process tool may share the same operation, but neither calls the other. Follow the full transport and handler rules in the `add-tools` skill. @@ -171,7 +171,7 @@ Hard rules: - Never substitute secret plaintext into source or serialize plaintext provenance. - Never hand-roll private provenance headers/envelopes; the shared `executeTool` boundary owns transport and strips private metadata from functional results. -- Never attach private provenance to an external URL or to `directExecution`. Project proven +- Never attach private provenance to an external URL. Project proven model-visible external fields with `request.modelInput`; otherwise preserve ordinary request semantics. Use a registered in-process operation when encrypted provenance must cross the boundary. @@ -606,8 +606,8 @@ If creating V2 versions (API-aligned outputs): - [ ] Created tool file for each operation - [ ] Chose exactly one boundary per tool: registered `InternalToolConfig.operation` or absolute external HTTP(S) `ToolConfig.request` -- [ ] No tool points to `/api/...`, constructs a URL back to Sim, declares `request.internal`, or - has an HTTP fallback for an in-process operation +- [ ] No tool points to `/api/...`, constructs a URL back to Sim, declares `request.internal` or the + retired `directExecution` property, or has an HTTP fallback for an in-process operation - [ ] All params have correct visibility - [ ] All nullable fields use `?? null` - [ ] All optional outputs have `optional: true` diff --git a/.agents/skills/add-tools/SKILL.md b/.agents/skills/add-tools/SKILL.md index 0b7a5cc1f6c..985073b2b5d 100644 --- a/.agents/skills/add-tools/SKILL.md +++ b/.agents/skills/add-tools/SKILL.md @@ -54,7 +54,7 @@ Every tool must use exactly one of these configurations: HTTP(S) provider endpoint. Never set a tool URL to `/api/...`, construct an absolute URL back to Sim, declare -`request.internal`, import a route module, or create an API route merely to normalize files, +`request.internal`, add the retired `directExecution` property, import a route module, or create an API route merely to normalize files, authorize access, or reuse server code. A real browser/API route may remain as a thin adapter, but the route and the tool must call the same operation directly. A true cross-process/capability boundary uses an explicit server client and is not disguised as a tool self-hop. @@ -524,6 +524,7 @@ All tool IDs MUST use `snake_case`: `{service}_{action}` (e.g., `x_create_tweet` HTTP(S) `ToolConfig.request` - [ ] No tool request points to `/api/...`, constructs a URL back to Sim, or declares `request.internal` +- [ ] No tool declares `directExecution`; in-process work uses a registered operation - [ ] All params have explicit `required: true` or `required: false` - [ ] All params have appropriate `visibility` - [ ] All nullable response fields use `?? null` diff --git a/.agents/skills/add-trigger/SKILL.md b/.agents/skills/add-trigger/SKILL.md index f3e776c848f..2bdfbc8e29f 100644 --- a/.agents/skills/add-trigger/SKILL.md +++ b/.agents/skills/add-trigger/SKILL.md @@ -508,7 +508,8 @@ Two rules the checks enforce: Webhook and polling routes are legitimate external ingress boundaries. They must not call this Sim app's own API routes to reuse provider or business logic. Extract the shared provider operation or authorized application use case and call it directly from the trigger handler and any other -server adapter. HTTP is reserved for an actual cross-process/capability boundary. +server adapter. HTTP is reserved for an actual cross-process/capability boundary. Tool work uses a +registered `InternalToolConfig.operation`; the retired `directExecution` property must not return. ### Trigger Definition - [ ] Created `utils.ts` with options, instructions, extra fields, and output builders diff --git a/.agents/skills/tool-registry-boundary/SKILL.md b/.agents/skills/tool-registry-boundary/SKILL.md index 6e1caaf0a64..ca22f8c856d 100644 --- a/.agents/skills/tool-registry-boundary/SKILL.md +++ b/.agents/skills/tool-registry-boundary/SKILL.md @@ -11,7 +11,12 @@ You keep the 4,300-tool executable registry out of module graphs that don't exec > Client-reachable code reads tool **metadata**. Only code that actually executes a tool imports the **registry**. -`@/tools/registry` is a ~9,000-line barrel importing every tool. Each `ToolConfig` mixes plain data (`params`, `outputs`, `name`) with closures — `request.url`, `request.headers`, `transformResponse`, `directExecution`, `postProcess`. Those closures reach the SDK clients, API helpers and parsers each integration needs, and that is what makes the barrel expensive: reaching it costs ~4,700 additional modules. +`@/tools/registry` is a ~9,000-line barrel importing every tool. External `ToolConfig` entries mix +plain data (`params`, `outputs`, `name`) with request/response closures, while +`InternalToolConfig` entries contain semantic input projection and load their server implementation +through `lib/internal/tool-operations/registry.server.ts`. Request closures can still reach SDK +clients, API helpers, and parsers, which is what makes the executable barrel expensive: reaching it +costs ~4,700 additional modules. `getTool()` returns the whole `ToolConfig`, so a single `getTool` import anywhere in a client-reachable file drags all of it in. @@ -95,4 +100,5 @@ The canvas route reached the registry through **four** redundant edges — `prov Ask what the caller does with the config. If it reads `params`, `outputs`, `name`, `description` or just checks existence, it belongs on `@/tools/metadata` — no exceptions, even on a path you believe is server-only today, because a future client import will silently re-attach the registry to the graph. -If it genuinely executes — builds a request, transforms a response, runs `directExecution` — use `getTool`, and keep that file off client-reachable paths. +If it genuinely executes — builds an external request, transforms a response, or dispatches a +registered internal operation — use `getTool`, and keep that file off client-reachable paths. diff --git a/.agents/skills/validate-integration/SKILL.md b/.agents/skills/validate-integration/SKILL.md index abf1740647d..a009e51d1ac 100644 --- a/.agents/skills/validate-integration/SKILL.md +++ b/.agents/skills/validate-integration/SKILL.md @@ -159,8 +159,9 @@ search, extraction, or "AI-powered" marketing terminology. - [ ] Sim-owned durable writes and internal execution handoffs that can enter workflows/models use field-scoped `request.secretProvenance`; authenticated receivers validate the exact selection and scope, strip private metadata, and persist, import, or propagate it at the owning boundary -- [ ] Private provenance is never attached to external URLs or `directExecution`; proven - model-visible external fields use projection, while other external inputs remain unchanged +- [ ] Private provenance is never attached to external URLs; registered in-process operations + preserve it through `operation.modelInput` / `operation.secretProvenance`, while proven + model-visible external fields use request projection and other external inputs remain unchanged - [ ] No tool performs raw secret plaintext/source substitution or serializes plaintext provenance - [ ] No `transformResponse` or tool-local helper blanket-sanitizes ordinary third-party results; only execution-scoped, activated Sim provenance is projected at shared model/log boundaries diff --git a/.claude/rules/emcn-components.md b/.claude/rules/emcn-components.md index 2d5babc15df..f4a88a40314 100644 --- a/.claude/rules/emcn-components.md +++ b/.claude/rules/emcn-components.md @@ -32,7 +32,7 @@ The menu surface intentionally diverges from the pill: `dropdown-menu.tsx` items - **`ChipDatePicker`** — chip-styled date field. - **`ChipTimePicker`** — minute-granular time sibling of `ChipDatePicker`, a `ChipInput` that leniently parses typed input (`9:47`, `947`, `2:05pm`, `14:30`), commits on Enter/blur, and re-renders the canonical `9:47 AM` label. - **`DropdownMenu`** — the canonical context/action menu (Radix-backed). Not a chip, but the standard menu for command/action lists; reach for it instead of a hand-rolled popover. Its surface intentionally diverges from the chip pill (`text-small`, `gap-2`) — keep them distinct. For a pill that opens a value picker, use `ChipDropdown`/`ChipSelect` instead. -- **`OverflowText`** — the canonical single-line overflow treatment for read-only human labels and titles. It owns `min-w-0`, single-line clipping, the conditional 18px edge fade, and the full-value floating tooltip; consumers pass only layout/typography through `className`. Never combine the fade with `truncate`, which paints an ellipsis beneath the mask. Keep ordinary `truncate` for editable or mirrored input values, code/log/path content, dense or virtualized grids, and composite rows where masking the container would also fade icons or actions. Multiline copy uses an intentional `line-clamp-*` treatment instead. A non-editable `Combobox` visual overlay passes its plain value through `overlayLabel`; render its visible `OverflowText` as a constrained block with `tooltipEnabled={false}` so the interactive trigger owns the single accessible tooltip. +- **`OverflowText`** — the canonical single-line overflow treatment for read-only human labels and titles. It owns `min-w-0`, fade-only clipping (never an ellipsis), the conditional 18px edge mask, and the full-value floating tooltip; consumers pass only layout/typography through `className`. `overflowTextClipClass` and `overflowTextFadeClass` are the complete base/faded treatments for the rare component that must own measurement itself; never pair either with `truncate`, `text-ellipsis`, or hover-time mask removal. Use `DropdownMenuItemLabel` for a menu label beside icons, checks, or actions. A non-editable `Combobox` passes the full visual value through `overlayLabel`; the combobox owns the visual overlay's fade and keeps its one accessible tooltip on the interactive layer. Keep ordinary `truncate` only for editable values, code/log/path content, dense or virtualized grids, and rich composite content that cannot supply a plain tooltip label. Multiline copy uses an intentional `line-clamp-*` treatment instead. ## Modal keyboard defaults diff --git a/.claude/rules/sim-styling.md b/.claude/rules/sim-styling.md index 188fc2b1810..3fd18753f46 100644 --- a/.claude/rules/sim-styling.md +++ b/.claude/rules/sim-styling.md @@ -52,9 +52,11 @@ Icons default `size-[14px]`. Equal h/w → `size-*` (`size-[14px]`, `size-4`), n ## Text Overflow -Use `OverflowText` from `@sim/emcn` for a constrained, single-line, read-only human label or title. It owns `min-w-0`, single-line clipping, the conditional edge fade, and the full-value floating tooltip; pass only layout and typography through `className`. Never combine a fade or hand-written `mask-image` with `truncate`, which leaves an ellipsis beneath the mask. Pass the full label to this component instead of shortening it in JavaScript first. +Use `OverflowText` from `@sim/emcn` for a constrained, single-line, read-only human label or title. It owns `min-w-0`, fade-only clipping, the conditional edge mask, and the full-value floating tooltip; pass only layout and typography through `className`. Never combine a fade or hand-written `mask-image` with `truncate`/`text-ellipsis`, and never remove the mask on hover to reveal an ellipsis. Pass the full label instead of shortening it in JavaScript first. Components that must measure a label externally use the complete `overflowTextClipClass` + conditional `overflowTextFadeClass` pair. -For a non-editable `Combobox` visual overlay, pass the same plain value as `overlayLabel` and render the visible `OverflowText` with `block w-full` (or `block flex-1` beside an icon) plus `tooltipEnabled={false}`. The transparent interactive layer then owns the one reachable full-value tooltip while the visual layer owns the measured fade. +For a non-editable `Combobox` visual overlay, pass the same full plain value as `overlayLabel`. The combobox owns the visible overlay's fade and keeps the one reachable full-value tooltip on its interactive layer; consumers provide only the overlay's decorated content. + +Use `DropdownMenuItemLabel` for a human label beside menu icons, checks, shortcuts, or actions. Bare string children are wrapped automatically; a direct rich `` is only a hard-clipped escape hatch and must not be used for an ordinary text label. Do not apply the fade universally to editable or mirrored input values, code, logs, paths, filenames that use intentional middle truncation, dense or virtualized grids, or a composite container that also holds icons/actions. Those keep their purpose-built overflow behavior. Multiline copy uses an intentional `line-clamp-*` treatment. @@ -92,7 +94,7 @@ Draw a line with a real `border-*` utility. Never hand-roll one as `shadow-[inse ### What className MAY carry -Layout/sizing ONLY: `flex-1`, `w-full`, `w-[Npx]`, `min-w-0`, `max-w-*`, margins, `truncate`. Example: `` (`app/workspace/[workspaceId]/integrations/integrations.tsx:257`). NEVER re-specify canonical chrome — the component already applies it. +Layout/sizing ONLY: `flex-1`, `w-full`, `w-[Npx]`, `min-w-0`, `max-w-*`, margins. `truncate` is allowed only for the explicit overflow exceptions above, never as a general layout class. Example: `` (`app/workspace/[workspaceId]/integrations/integrations.tsx:257`). NEVER re-specify canonical chrome — the component already applies it. ### Form / chip-modal layout rhythm diff --git a/.cursor/rules/sim-styling.mdc b/.cursor/rules/sim-styling.mdc index d79cc9fd04e..8b407a204b7 100644 --- a/.cursor/rules/sim-styling.mdc +++ b/.cursor/rules/sim-styling.mdc @@ -46,9 +46,11 @@ Icons default `size-[14px]`. Equal h/w → `size-*` (`size-[14px]`, `size-4`), n ## Text Overflow -Use `OverflowText` from `@sim/emcn` for a constrained, single-line, read-only human label or title. It owns `min-w-0`, single-line clipping, the conditional edge fade, and the full-value floating tooltip; pass only layout and typography through `className`. Never combine a fade or hand-written `mask-image` with `truncate`, which leaves an ellipsis beneath the mask. Pass the full label to this component instead of shortening it in JavaScript first. +Use `OverflowText` from `@sim/emcn` for a constrained, single-line, read-only human label or title. It owns `min-w-0`, fade-only clipping, the conditional edge mask, and the full-value floating tooltip; pass only layout and typography through `className`. Never combine a fade or hand-written `mask-image` with `truncate`/`text-ellipsis`, and never remove the mask on hover to reveal an ellipsis. Pass the full label instead of shortening it in JavaScript first. Components that must measure a label externally use the complete `overflowTextClipClass` + conditional `overflowTextFadeClass` pair. -For a non-editable `Combobox` visual overlay, pass the same plain value as `overlayLabel` and render the visible `OverflowText` with `block w-full` (or `block flex-1` beside an icon) plus `tooltipEnabled={false}`. The transparent interactive layer then owns the one reachable full-value tooltip while the visual layer owns the measured fade. +For a non-editable `Combobox` visual overlay, pass the same full plain value as `overlayLabel`. The combobox owns the visible overlay's fade and keeps the one reachable full-value tooltip on its interactive layer; consumers provide only the overlay's decorated content. + +Use `DropdownMenuItemLabel` for a human label beside menu icons, checks, shortcuts, or actions. Bare string children are wrapped automatically; a direct rich `` is only a hard-clipped escape hatch and must not be used for an ordinary text label. Do not apply the fade universally to editable or mirrored input values, code, logs, paths, filenames that use intentional middle truncation, dense or virtualized grids, or a composite container that also holds icons/actions. Those keep their purpose-built overflow behavior. Multiline copy uses an intentional `line-clamp-*` treatment. @@ -70,7 +72,7 @@ Value text `--text-body`; muted/placeholder/labels `--text-muted`; icons `--text ### What className MAY carry -Layout/sizing ONLY: `flex-1`, `w-full`, `w-[Npx]`, `min-w-0`, `max-w-*`, margins, `truncate`. Example: `` (`app/workspace/[workspaceId]/integrations/integrations.tsx:257`). NEVER re-specify canonical chrome — the component already applies it. +Layout/sizing ONLY: `flex-1`, `w-full`, `w-[Npx]`, `min-w-0`, `max-w-*`, margins. `truncate` is allowed only for the explicit overflow exceptions above, never as a general layout class. Example: `` (`app/workspace/[workspaceId]/integrations/integrations.tsx:257`). NEVER re-specify canonical chrome — the component already applies it. ### Form / chip-modal layout rhythm diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index f614010e580..5c80c285664 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -6843,6 +6843,32 @@ export function CloudFormationIcon(props: SVGProps) { ) } +export function LambdaIcon(props: SVGProps) { + return ( + + + + + + ) +} + export function AthenaIcon(props: SVGProps) { return ( = { kalshi_v2: KalshiIcon, ketch: KetchIcon, knowledge: PackageSearchIcon, + lambda: LambdaIcon, langsmith: LangsmithIcon, latex: LatexIcon, launchdarkly: LaunchDarklyIcon, @@ -446,6 +448,7 @@ export const blockTypeToIconMap: Record = { memory: BrainIcon, microsoft_ad: AzureIcon, microsoft_dataverse: MicrosoftDataverseIcon, + microsoft_dynamics_365: MicrosoftDataverseIcon, microsoft_excel: MicrosoftExcelIcon, microsoft_excel_v2: MicrosoftExcelIcon, microsoft_planner: MicrosoftPlannerIcon, diff --git a/apps/docs/content/docs/de/introduction/index.mdx b/apps/docs/content/docs/de/introduction/index.mdx index 38ad402c634..78df63b0d84 100644 --- a/apps/docs/content/docs/de/introduction/index.mdx +++ b/apps/docs/content/docs/de/introduction/index.mdx @@ -12,9 +12,9 @@ Sim ist ein Open-Source-Tool zur visuellen Workflow-Erstellung für die Entwickl
Sim visuelle Workflow-Leinwand
diff --git a/apps/docs/content/docs/en/integrations/lambda.mdx b/apps/docs/content/docs/en/integrations/lambda.mdx new file mode 100644 index 00000000000..dc4e529cac9 --- /dev/null +++ b/apps/docs/content/docs/en/integrations/lambda.mdx @@ -0,0 +1,1221 @@ +--- +title: Lambda +description: Invoke, deploy, and manage AWS Lambda functions +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[AWS Lambda](https://aws.amazon.com/lambda/) is a serverless compute service that runs your code in response to events and scales automatically, with no servers to manage. You package code as a .zip archive or a container image, give it an execution role, and Lambda handles provisioning, scaling, and logging. + +With the Lambda integration, you can: + +- **Invoke Function**: Run a function synchronously and read back its parsed response payload, queue it asynchronously, or dry-run it to verify permissions — with the decoded execution log tail when something fails +- **Manage functions**: Create, read, update, and delete functions, including runtime, handler, memory, timeout, ephemeral storage, environment variables, VPC attachment, layers, X-Ray tracing, SnapStart, and CloudWatch log settings +- **Version and alias**: Publish immutable versions, then point aliases such as `prod` at them — including weighted routing to shift a percentage of traffic to a new version for canary releases +- **Wire up event sources**: Create and tune event source mappings for SQS, Kinesis, DynamoDB Streams, Amazon MQ, DocumentDB, Amazon MSK, and self-managed Kafka — with batch size, batching window, filter patterns, retry limits, success/failure destinations, broker authentication, and consumer group IDs +- **Control concurrency**: Reserve a share of account concurrency for a function, allocate provisioned concurrency to a version or alias to eliminate cold starts, and read account-level limits and usage +- **Expose function URLs**: Create dedicated HTTPS endpoints with `AWS_IAM` or public auth, buffered or streamed responses, and full CORS configuration +- **Configure async behavior**: Set retry attempts, maximum event age, and on-success/on-failure destinations for asynchronous invocations +- **Audit access**: Read a function's resource-based policy, add and remove permission statements for AWS services or accounts, and list function URL configurations to find publicly reachable endpoints +- **Work with layers and tags**: List layers and their versions, fetch a layer version's download location, and list, add, or remove function tags + +### Credentials and permissions + +The block authenticates with an AWS access key ID and secret access key scoped to a region. Grant the IAM principal only the Lambda actions the operations you use require — for example `lambda:InvokeFunction` for invocation, `lambda:GetFunction` and `lambda:ListFunctions` for read-only inventory, or `lambda:UpdateFunctionCode` and `lambda:PublishVersion` for deployments. + +### Deployment packages + +Function code is supplied from Amazon S3 (bucket, key, and optional object version) or from a container image URI in Amazon ECR. Uploading a .zip archive inline is not supported — publish the archive to S3 first, in the same region as the function, then point **Create Function** or **Update Function Code** at it. + +In Sim, the Lambda integration lets your agents run existing serverless code as a step in a workflow, ship and roll back deployments with alias traffic shifting, and continuously audit functions for deprecated runtimes, over-permissive policies, and publicly exposed URLs. It pairs naturally with CloudWatch for metrics and logs, S3 for deployment artifacts, and SQS for event sources. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Integrate AWS Lambda into workflows. Invoke functions and read their response payload, create and update functions from Amazon S3 packages or container images, publish versions and aliases, wire up event source mappings, manage concurrency, function URLs, layers, permissions, and tags. Requires an AWS access key and secret access key. + + + +## Actions + +### Lambda Invoke Function + +Invoke a Lambda function synchronously or asynchronously and return its response + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `functionName` | string | Yes | Function name, ARN, or partial ARN \(e.g. my-function, or arn:aws:lambda:us-east-1:123456789012:function:my-function\) | +| `payload` | json | No | JSON event payload passed to the function handler | +| `invocationType` | string | No | RequestResponse waits for the result, Event queues the invocation, DryRun only validates permissions | +| `logType` | string | No | Set to Tail to return the last 4 KB of the execution log | +| `clientContext` | string | No | Base64-encoded JSON passed to the function in the client context object \(max 3,583 bytes\) | +| `qualifier` | string | No | Version number or alias name to act on. Omit to target the function itself | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `statusCode` | number | HTTP status of the invocation \(200 for RequestResponse, 202 for Event, 204 for DryRun\) | +| `payload` | json | The response returned by the function, parsed as JSON when possible | +| `functionError` | string | Set to Handled or Unhandled when the function itself returned an error | +| `logResult` | string | Decoded execution log tail, present only when logType is Tail | +| `executedVersion` | string | The function version that was executed | + +### Lambda List Functions + +List Lambda functions with the version-specific configuration of each + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `functionVersion` | string | No | Set to ALL to include every published version of each function | +| `masterRegion` | string | No | For Lambda@Edge functions, the region of the master function. Requires functionVersion ALL | +| `marker` | string | No | Pagination token returned by a previous request | +| `maxItems` | number | No | Maximum number of items to return \(1-10000\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `functions` | array | Lambda functions with their runtime, handler, memory, and state | +| `nextMarker` | string | Pagination token to pass as marker on the next request | + +### Lambda Get Function + +Get a function's configuration, code location, tags, and reserved concurrency + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `functionName` | string | Yes | Function name, ARN, or partial ARN \(e.g. my-function, or arn:aws:lambda:us-east-1:123456789012:function:my-function\) | +| `qualifier` | string | No | Version number or alias name to act on. Omit to target the function itself | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `configuration` | json | The function's configuration \(ARN, runtime, handler, memory, state, layers, VPC, and logging settings\) | +| `tagsError` | json | Why the tags could not be read, when a partial tag-read failure occurred | +| `code` | json | Presigned download URL for the deployment package, or the container image URI | +| `tags` | json | The function's tags | +| `reservedConcurrentExecutions` | number | Concurrency reserved for this function, if any | + +### Lambda Get Function Configuration + +Get a function's version-specific configuration + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `functionName` | string | Yes | Function name, ARN, or partial ARN \(e.g. my-function, or arn:aws:lambda:us-east-1:123456789012:function:my-function\) | +| `qualifier` | string | No | Version number or alias name to act on. Omit to target the function itself | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `configuration` | json | The function's configuration \(ARN, runtime, handler, memory, state, layers, VPC, and logging settings\) | + +### Lambda Create Function + +Create a Lambda function from a deployment package in Amazon S3 or a container image + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `functionName` | string | Yes | Function name, ARN, or partial ARN \(e.g. my-function, or arn:aws:lambda:us-east-1:123456789012:function:my-function\) | +| `role` | string | Yes | ARN of the function's execution role | +| `runtime` | string | No | Runtime identifier such as nodejs22.x or python3.13. Required for .zip packages, omit for container images | +| `handler` | string | No | Entry point in your code, such as index.handler. Required for .zip packages | +| `packageType` | string | No | Zip for a .zip file archive \(default\) or Image for a container image | +| `s3Bucket` | string | No | Amazon S3 bucket holding the deployment package, in the same region as the function | +| `s3Key` | string | No | Amazon S3 key of the .zip package | +| `s3ObjectVersion` | string | No | Version of the Amazon S3 object to use | +| `imageUri` | string | No | Amazon ECR URI of the container image to deploy | +| `sourceKmsKeyArn` | string | No | ARN of the KMS customer managed key that encrypts the function's .zip deployment package | +| `description` | string | No | Description of the function | +| `functionTimeout` | number | No | Seconds Lambda allows the function to run before stopping it \(1-900\). Named functionTimeout because the shared tool executor reserves `timeout` for its own request deadline | +| `memorySize` | number | No | Memory available to the function at runtime in MB \(128-32768\) | +| `ephemeralStorageSize` | number | No | Size of the /tmp directory in MB \(512-10240\) | +| `publish` | boolean | No | Publish the first version of the function atomically with creation | +| `environment` | json | No | Environment variables as a flat key/value JSON object | +| `tags` | json | No | Tags to apply to the function, as a flat key/value JSON object | +| `architectures` | array | No | Instruction set architecture: exactly one of x86_64 or arm64 | +| `layers` | array | No | ARNs of layer versions to add to the function execution environment Pass \[\] to remove all of them on an update. | +| `vpcSubnetIds` | array | No | VPC subnet IDs the function should attach to Pass \[\] to remove all of them on an update. | +| `vpcSecurityGroupIds` | array | No | VPC security group IDs the function should use Pass \[\] to remove all of them on an update. | +| `tracingMode` | string | No | X-Ray tracing mode: Active samples and traces requests, PassThrough only traces sampled requests | +| `deadLetterTargetArn` | string | No | ARN of an SQS queue or SNS topic that receives failed asynchronous invocations | +| `kmsKeyArn` | string | No | ARN of the KMS customer managed key used to encrypt environment variables and snapshots | +| `snapStartApplyOn` | string | No | Set to PublishedVersions to snapshot the initialized environment when a version is published | +| `logFormat` | string | No | Format the function sends CloudWatch logs in | +| `logGroup` | string | No | CloudWatch log group the function sends logs to | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `configuration` | json | The function's configuration \(ARN, runtime, handler, memory, state, layers, VPC, and logging settings\) | + +### Lambda Update Function Code + +Update a function's deployment package from Amazon S3 or a container image + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `functionName` | string | Yes | Function name, ARN, or partial ARN \(e.g. my-function, or arn:aws:lambda:us-east-1:123456789012:function:my-function\) | +| `s3Bucket` | string | No | Amazon S3 bucket holding the new deployment package, in the same region as the function | +| `s3Key` | string | No | Amazon S3 key of the .zip package | +| `s3ObjectVersion` | string | No | Version of the Amazon S3 object to use | +| `imageUri` | string | No | Amazon ECR URI of the container image to deploy | +| `sourceKmsKeyArn` | string | No | ARN of the KMS customer managed key that encrypts the function's .zip deployment package | +| `architectures` | array | No | Instruction set architecture: exactly one of x86_64 or arm64 | +| `publish` | boolean | No | Publish a new version after updating the code | +| `dryRun` | boolean | No | Validate the request without updating the function | +| `revisionId` | string | No | Update the resource only if its current revision ID matches this value | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `configuration` | json | The function's configuration \(ARN, runtime, handler, memory, state, layers, VPC, and logging settings\) | + +### Lambda Update Function Configuration + +Update a function's settings such as memory, timeout, role, and environment variables + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `functionName` | string | Yes | Function name, ARN, or partial ARN \(e.g. my-function, or arn:aws:lambda:us-east-1:123456789012:function:my-function\) | +| `role` | string | No | ARN of the function's execution role | +| `runtime` | string | No | Runtime identifier such as nodejs22.x or python3.13 | +| `handler` | string | No | Entry point in your code, such as index.handler | +| `description` | string | No | Description of the function | +| `functionTimeout` | number | No | Seconds Lambda allows the function to run before stopping it \(1-900\). Named functionTimeout because the shared tool executor reserves `timeout` for its own request deadline | +| `memorySize` | number | No | Memory available to the function at runtime in MB \(128-32768\) | +| `ephemeralStorageSize` | number | No | Size of the /tmp directory in MB \(512-10240\) | +| `environment` | json | No | Environment variables as a flat key/value JSON object. Replaces the existing set | +| `layers` | array | No | ARNs of layer versions to add to the function execution environment Pass \[\] to remove all of them on an update. | +| `vpcSubnetIds` | array | No | VPC subnet IDs the function should attach to Pass \[\] to remove all of them on an update. | +| `vpcSecurityGroupIds` | array | No | VPC security group IDs the function should use Pass \[\] to remove all of them on an update. | +| `tracingMode` | string | No | X-Ray tracing mode: Active samples and traces requests, PassThrough only traces sampled requests | +| `deadLetterTargetArn` | string | No | ARN of an SQS queue or SNS topic that receives failed asynchronous invocations | +| `kmsKeyArn` | string | No | ARN of the KMS customer managed key used to encrypt environment variables and snapshots | +| `snapStartApplyOn` | string | No | Set to PublishedVersions to snapshot the initialized environment when a version is published | +| `logFormat` | string | No | Format the function sends CloudWatch logs in | +| `logGroup` | string | No | CloudWatch log group the function sends logs to | +| `revisionId` | string | No | Update the resource only if its current revision ID matches this value | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `configuration` | json | The function's configuration \(ARN, runtime, handler, memory, state, layers, VPC, and logging settings\) | + +### Lambda Delete Function + +Delete a Lambda function, or a single published version of it + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `functionName` | string | Yes | Function name, ARN, or partial ARN \(e.g. my-function, or arn:aws:lambda:us-east-1:123456789012:function:my-function\) | +| `qualifier` | string | No | Version number to delete. Omit to delete the whole function including all versions and aliases | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | + +### Lambda Publish Version + +Publish an immutable version from the current code and configuration of a function + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `functionName` | string | Yes | Function name, ARN, or partial ARN \(e.g. my-function, or arn:aws:lambda:us-east-1:123456789012:function:my-function\) | +| `codeSha256` | string | No | Publish only if the SHA256 hash of the deployment package matches this value | +| `description` | string | No | Description of the version | +| `revisionId` | string | No | Update the resource only if its current revision ID matches this value | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `configuration` | json | The function's configuration \(ARN, runtime, handler, memory, state, layers, VPC, and logging settings\) | + +### Lambda List Function Versions + +List the published versions of a Lambda function, plus $LATEST + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `functionName` | string | Yes | Function name, ARN, or partial ARN \(e.g. my-function, or arn:aws:lambda:us-east-1:123456789012:function:my-function\) | +| `marker` | string | No | Pagination token returned by a previous request | +| `maxItems` | number | No | Maximum number of items to return \(1-10000\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `versions` | array | Published versions of the function, plus the unpublished $LATEST version | +| `nextMarker` | string | Pagination token to pass as marker on the next request | + +### Lambda Create Alias + +Create an alias that points to a published function version + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `functionName` | string | Yes | Function name, ARN, or partial ARN \(e.g. my-function, or arn:aws:lambda:us-east-1:123456789012:function:my-function\) | +| `aliasName` | string | Yes | Name of the alias, such as prod or staging | +| `aliasFunctionVersion` | string | Yes | Function version the alias points to | +| `description` | string | No | Description of the alias | +| `additionalVersionWeights` | json | No | Weighted routing as a JSON object mapping a second version to the fraction of traffic it receives, e.g. \{"2": 0.1\} | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `alias` | json | The alias with its ARN, target version, and routing configuration | + +### Lambda Get Alias + +Get details about a Lambda function alias + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `functionName` | string | Yes | Function name, ARN, or partial ARN \(e.g. my-function, or arn:aws:lambda:us-east-1:123456789012:function:my-function\) | +| `aliasName` | string | Yes | Name of the alias | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `alias` | json | The alias with its ARN, target version, and routing configuration | + +### Lambda Update Alias + +Update the target version, description, or traffic weights of an alias + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `functionName` | string | Yes | Function name, ARN, or partial ARN \(e.g. my-function, or arn:aws:lambda:us-east-1:123456789012:function:my-function\) | +| `aliasName` | string | Yes | Name of the alias | +| `aliasFunctionVersion` | string | No | Function version the alias should point to | +| `description` | string | No | Description of the alias | +| `additionalVersionWeights` | json | No | Weighted routing as a JSON object mapping a second version to the fraction of traffic it receives, e.g. \{"2": 0.1\} | +| `revisionId` | string | No | Update the resource only if its current revision ID matches this value | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `alias` | json | The alias with its ARN, target version, and routing configuration | + +### Lambda Delete Alias + +Delete a Lambda function alias + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `functionName` | string | Yes | Function name, ARN, or partial ARN \(e.g. my-function, or arn:aws:lambda:us-east-1:123456789012:function:my-function\) | +| `aliasName` | string | Yes | Name of the alias | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | + +### Lambda List Aliases + +List the aliases of a Lambda function + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `functionName` | string | Yes | Function name, ARN, or partial ARN \(e.g. my-function, or arn:aws:lambda:us-east-1:123456789012:function:my-function\) | +| `aliasFunctionVersion` | string | No | Return only aliases that point to this function version | +| `marker` | string | No | Pagination token returned by a previous request | +| `maxItems` | number | No | Maximum number of items to return \(1-10000\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `aliases` | array | Aliases with their ARNs, target versions, and routing configuration | +| `nextMarker` | string | Pagination token to pass as marker on the next request | + +### Lambda Add Permission + +Grant an AWS service, account, or organization permission to use a function + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `functionName` | string | Yes | Function name, ARN, or partial ARN \(e.g. my-function, or arn:aws:lambda:us-east-1:123456789012:function:my-function\) | +| `statementId` | string | Yes | Unique identifier for the policy statement \(letters, numbers, hyphens, and underscores\) | +| `action` | string | Yes | Action the principal is granted, such as lambda:InvokeFunction | +| `principal` | string | Yes | AWS service principal or account ID granted the permission, such as s3.amazonaws.com | +| `sourceArn` | string | No | ARN of the AWS resource allowed to invoke the function | +| `sourceAccount` | string | No | ID of the AWS account that owns the source resource | +| `principalOrgId` | string | No | AWS Organizations ID to grant permission to every account in the organization | +| `eventSourceToken` | string | No | Token that must be supplied by the invoker \(Alexa Smart Home functions only\) | +| `functionUrlAuthType` | string | No | Auth type of the function URL this permission applies to | +| `qualifier` | string | No | Version number or alias name to act on. Omit to target the function itself | +| `revisionId` | string | No | Update the resource only if its current revision ID matches this value | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `statement` | string | The permission statement that was added, as a JSON document string | + +### Lambda Remove Permission + +Remove a statement from a function's resource-based policy + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `functionName` | string | Yes | Function name, ARN, or partial ARN \(e.g. my-function, or arn:aws:lambda:us-east-1:123456789012:function:my-function\) | +| `statementId` | string | Yes | Identifier of the policy statement to remove | +| `qualifier` | string | No | Version number or alias name to act on. Omit to target the function itself | +| `revisionId` | string | No | Update the resource only if its current revision ID matches this value | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | + +### Lambda Get Policy + +Get the resource-based IAM policy attached to a function, version, or alias + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `functionName` | string | Yes | Function name, ARN, or partial ARN \(e.g. my-function, or arn:aws:lambda:us-east-1:123456789012:function:my-function\) | +| `qualifier` | string | No | Version number or alias name to act on. Omit to target the function itself | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `policy` | string | The resource-based policy, as a JSON document string | +| `revisionId` | string | Current revision ID of the policy | + +### Lambda Create Event Source Mapping + +Map an event source such as SQS, Kinesis, DynamoDB Streams, or Kafka to a function + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `functionName` | string | Yes | Function name, ARN, or partial ARN \(e.g. my-function, or arn:aws:lambda:us-east-1:123456789012:function:my-function\) | +| `eventSourceArn` | string | No | ARN of the event source. Omit only for self-managed Kafka | +| `enabled` | boolean | No | Whether the mapping is active | +| `batchSize` | number | No | Maximum records sent to the function in a single batch | +| `maximumBatchingWindowInSeconds` | number | No | Seconds to gather records before invoking the function \(0-300\) | +| `startingPosition` | string | No | Position in the stream to start reading from. Required for Kinesis, DynamoDB Streams, and Kafka | +| `startingPositionTimestamp` | string | No | ISO 8601 timestamp to start reading from, when startingPosition is AT_TIMESTAMP | +| `parallelizationFactor` | number | No | Number of concurrent batches to process from each shard \(1-10\) | +| `maximumRecordAgeInSeconds` | number | No | Discard records older than this. Use -1 for infinite | +| `maximumRetryAttempts` | number | No | Retries before a record is discarded. Use -1 for infinite | +| `bisectBatchOnFunctionError` | boolean | No | Split a failing batch in two and retry each half | +| `tumblingWindowInSeconds` | number | No | Duration of a processing window for stream aggregation \(0-900\) | +| `maximumConcurrency` | number | No | Maximum concurrent function invocations from an SQS event source \(2-1000\) | +| `topics` | array | No | Kafka topic names to consume | +| `queues` | array | No | Amazon MQ broker destination queue names | +| `functionResponseTypes` | array | No | Set to ReportBatchItemFailures to enable partial batch reporting Pass \[\] to remove all of them on an update. | +| `filterPatterns` | array | No | Event filter patterns, each a JSON string, that decide which records reach the function. Pass \[\] to remove all filters on an update. | +| `onSuccessDestination` | string | No | ARN of the destination that receives successfully processed records | +| `onFailureDestination` | string | No | ARN of the destination that receives discarded records | +| `kmsKeyArn` | string | No | ARN of the KMS customer managed key used to encrypt filter criteria | +| `tags` | json | No | Tags to apply to the event source mapping, as a flat key/value JSON object | +| `sourceAccessConfigurations` | json | No | Authentication for an Amazon MQ or self-managed Kafka source, as a JSON array of objects with "type" \(e.g. BASIC_AUTH, SASL_SCRAM_512_AUTH, VPC_SUBNET\) and "uri" \(the Secrets Manager or VPC resource ARN\). Pass \[\] to remove all of them on an update. | +| `documentDbDatabaseName` | string | No | DocumentDB database to consume the change stream from | +| `documentDbCollectionName` | string | No | DocumentDB collection to consume. Omit to consume the whole database | +| `documentDbFullDocument` | string | No | UpdateLookup sends the full document on update, Default sends only the change delta | +| `amazonManagedKafkaConsumerGroupId` | string | No | Consumer group ID to join on an Amazon MSK cluster | +| `selfManagedKafkaConsumerGroupId` | string | No | Consumer group ID to join on a self-managed Kafka cluster | +| `selfManagedKafkaBootstrapServers` | array | No | Bootstrap servers of a self-managed Kafka cluster \(host:port\). Required instead of eventSourceArn for self-managed Kafka | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventSourceMapping` | json | The event source mapping with its UUID, state, batching, and filter settings | + +### Lambda Get Event Source Mapping + +Get details about an event source mapping + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `uuid` | string | Yes | Identifier of the event source mapping | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventSourceMapping` | json | The event source mapping with its UUID, state, batching, and filter settings | + +### Lambda Update Event Source Mapping + +Update the batching, retry, filtering, or enabled state of an event source mapping + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `uuid` | string | Yes | Identifier of the event source mapping | +| `functionName` | string | No | Function the mapping should invoke | +| `enabled` | boolean | No | Whether the mapping is active | +| `batchSize` | number | No | Maximum records sent to the function in a single batch | +| `maximumBatchingWindowInSeconds` | number | No | Seconds to gather records before invoking the function \(0-300\) | +| `parallelizationFactor` | number | No | Number of concurrent batches to process from each shard \(1-10\) | +| `maximumRecordAgeInSeconds` | number | No | Discard records older than this. Use -1 for infinite | +| `maximumRetryAttempts` | number | No | Retries before a record is discarded. Use -1 for infinite | +| `bisectBatchOnFunctionError` | boolean | No | Split a failing batch in two and retry each half | +| `tumblingWindowInSeconds` | number | No | Duration of a processing window for stream aggregation \(0-900\) | +| `maximumConcurrency` | number | No | Maximum concurrent function invocations from an SQS event source \(2-1000\) | +| `functionResponseTypes` | array | No | Set to ReportBatchItemFailures to enable partial batch reporting Pass \[\] to remove all of them on an update. | +| `filterPatterns` | array | No | Event filter patterns, each a JSON string, that decide which records reach the function. Pass \[\] to remove all filters on an update. | +| `onSuccessDestination` | string | No | ARN of the destination that receives successfully processed records | +| `onFailureDestination` | string | No | ARN of the destination that receives discarded records | +| `kmsKeyArn` | string | No | ARN of the KMS customer managed key used to encrypt filter criteria | +| `sourceAccessConfigurations` | json | No | Authentication for an Amazon MQ or self-managed Kafka source, as a JSON array of objects with "type" \(e.g. BASIC_AUTH, SASL_SCRAM_512_AUTH, VPC_SUBNET\) and "uri" \(the Secrets Manager or VPC resource ARN\). Pass \[\] to remove all of them on an update. | +| `documentDbDatabaseName` | string | No | DocumentDB database to consume the change stream from | +| `documentDbCollectionName` | string | No | DocumentDB collection to consume. Omit to consume the whole database | +| `documentDbFullDocument` | string | No | UpdateLookup sends the full document on update, Default sends only the change delta | +| `amazonManagedKafkaConsumerGroupId` | string | No | Consumer group ID to join on an Amazon MSK cluster | +| `selfManagedKafkaConsumerGroupId` | string | No | Consumer group ID to join on a self-managed Kafka cluster | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventSourceMapping` | json | The event source mapping with its UUID, state, batching, and filter settings | + +### Lambda Delete Event Source Mapping + +Delete an event source mapping + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `uuid` | string | Yes | Identifier of the event source mapping | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventSourceMapping` | json | The deleted event source mapping, whose state transitions to Deleting | + +### Lambda List Event Source Mappings + +List event source mappings, optionally filtered by function or event source + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `functionName` | string | No | Return only mappings that invoke this function | +| `eventSourceArn` | string | No | Return only mappings for this event source ARN | +| `marker` | string | No | Pagination token returned by a previous request | +| `maxItems` | number | No | Maximum number of items to return \(1-10000\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventSourceMappings` | array | Event source mappings with their UUIDs, state, and batching settings | +| `nextMarker` | string | Pagination token to pass as marker on the next request | + +### Lambda Get Function Concurrency + +Get the reserved concurrency configured for a function + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `functionName` | string | Yes | Function name, ARN, or partial ARN \(e.g. my-function, or arn:aws:lambda:us-east-1:123456789012:function:my-function\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `reservedConcurrentExecutions` | number | Concurrency reserved for this function, or null when none is reserved | + +### Lambda Set Function Concurrency + +Reserve a share of the account concurrency limit for a function + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `functionName` | string | Yes | Function name, ARN, or partial ARN \(e.g. my-function, or arn:aws:lambda:us-east-1:123456789012:function:my-function\) | +| `reservedConcurrentExecutions` | number | Yes | Number of simultaneous executions to reserve for this function | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `reservedConcurrentExecutions` | number | Concurrency now reserved for this function | + +### Lambda Delete Function Concurrency + +Remove the reserved concurrency configuration from a function + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `functionName` | string | Yes | Function name, ARN, or partial ARN \(e.g. my-function, or arn:aws:lambda:us-east-1:123456789012:function:my-function\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | + +### Lambda Get Provisioned Concurrency + +Get the provisioned concurrency configuration of a function version or alias + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `functionName` | string | Yes | Function name, ARN, or partial ARN \(e.g. my-function, or arn:aws:lambda:us-east-1:123456789012:function:my-function\) | +| `qualifier` | string | Yes | Version number or alias name the configuration applies to | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `provisionedConcurrency` | json | Requested, available, and allocated provisioned concurrency with its status | + +### Lambda Set Provisioned Concurrency + +Allocate provisioned concurrency to a function version or alias + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `functionName` | string | Yes | Function name, ARN, or partial ARN \(e.g. my-function, or arn:aws:lambda:us-east-1:123456789012:function:my-function\) | +| `qualifier` | string | Yes | Version number or alias name the configuration applies to | +| `provisionedConcurrentExecutions` | number | Yes | Number of pre-initialized execution environments to allocate | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `provisionedConcurrency` | json | Requested, available, and allocated provisioned concurrency with its status | + +### Lambda Delete Provisioned Concurrency + +Remove the provisioned concurrency configuration from a function version or alias + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `functionName` | string | Yes | Function name, ARN, or partial ARN \(e.g. my-function, or arn:aws:lambda:us-east-1:123456789012:function:my-function\) | +| `qualifier` | string | Yes | Version number or alias name the configuration applies to | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | + +### Lambda List Provisioned Concurrency + +List the provisioned concurrency configurations of a function + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `functionName` | string | Yes | Function name, ARN, or partial ARN \(e.g. my-function, or arn:aws:lambda:us-east-1:123456789012:function:my-function\) | +| `marker` | string | No | Pagination token returned by a previous request | +| `maxItems` | number | No | Maximum number of items to return \(1-50\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `provisionedConcurrencyConfigs` | array | Provisioned concurrency configurations with their allocation status | +| `nextMarker` | string | Pagination token to pass as marker on the next request | + +### Lambda Create Function URL + +Create a dedicated HTTPS endpoint for a function + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `functionName` | string | Yes | Function name, ARN, or partial ARN \(e.g. my-function, or arn:aws:lambda:us-east-1:123456789012:function:my-function\) | +| `authType` | string | Yes | AWS_IAM requires signed requests, NONE allows public unauthenticated access | +| `qualifier` | string | No | Version number or alias name to act on. Omit to target the function itself | +| `invokeMode` | string | No | BUFFERED returns the whole response at once, RESPONSE_STREAM streams it | +| `corsAllowCredentials` | boolean | No | Whether the function URL sends the Access-Control-Allow-Credentials header | +| `corsAllowOrigins` | array | No | Origins allowed to call the function URL, or * for any | +| `corsAllowMethods` | array | No | HTTP methods allowed when calling the function URL, or * for any | +| `corsAllowHeaders` | array | No | Headers browsers may send in a cross-origin request | +| `corsExposeHeaders` | array | No | Response headers browsers may access from the response | +| `corsMaxAge` | number | No | Seconds a browser may cache the CORS preflight result \(0-86400\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `functionUrlConfig` | json | The function URL with its auth type, invoke mode, and CORS settings | + +### Lambda Get Function URL + +Get details about a function URL + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `functionName` | string | Yes | Function name, ARN, or partial ARN \(e.g. my-function, or arn:aws:lambda:us-east-1:123456789012:function:my-function\) | +| `qualifier` | string | No | Version number or alias name to act on. Omit to target the function itself | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `functionUrlConfig` | json | The function URL with its auth type, invoke mode, and CORS settings | + +### Lambda Update Function URL + +Update the auth type, invoke mode, or CORS settings of a function URL + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `functionName` | string | Yes | Function name, ARN, or partial ARN \(e.g. my-function, or arn:aws:lambda:us-east-1:123456789012:function:my-function\) | +| `authType` | string | No | AWS_IAM requires signed requests, NONE allows public unauthenticated access | +| `qualifier` | string | No | Version number or alias name to act on. Omit to target the function itself | +| `invokeMode` | string | No | BUFFERED returns the whole response at once, RESPONSE_STREAM streams it | +| `corsAllowCredentials` | boolean | No | Whether the function URL sends the Access-Control-Allow-Credentials header | +| `corsAllowOrigins` | array | No | Origins allowed to call the function URL, or * for any | +| `corsAllowMethods` | array | No | HTTP methods allowed when calling the function URL, or * for any | +| `corsAllowHeaders` | array | No | Headers browsers may send in a cross-origin request | +| `corsExposeHeaders` | array | No | Response headers browsers may access from the response | +| `corsMaxAge` | number | No | Seconds a browser may cache the CORS preflight result \(0-86400\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `functionUrlConfig` | json | The function URL with its auth type, invoke mode, and CORS settings | + +### Lambda Delete Function URL + +Delete the URL configuration of a function + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `functionName` | string | Yes | Function name, ARN, or partial ARN \(e.g. my-function, or arn:aws:lambda:us-east-1:123456789012:function:my-function\) | +| `qualifier` | string | No | Version number or alias name to act on. Omit to target the function itself | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | + +### Lambda List Function URLs + +List the URL configurations of a function + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `functionName` | string | Yes | Function name, ARN, or partial ARN \(e.g. my-function, or arn:aws:lambda:us-east-1:123456789012:function:my-function\) | +| `marker` | string | No | Pagination token returned by a previous request | +| `maxItems` | number | No | Maximum number of items to return \(1-50\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `functionUrlConfigs` | array | Function URLs with their auth types, invoke modes, and CORS settings | +| `nextMarker` | string | Pagination token to pass as marker on the next request | + +### Lambda Get Async Invoke Config + +Get the asynchronous invocation retry and destination settings of a function + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `functionName` | string | Yes | Function name, ARN, or partial ARN \(e.g. my-function, or arn:aws:lambda:us-east-1:123456789012:function:my-function\) | +| `qualifier` | string | No | Version number or alias name to act on. Omit to target the function itself | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventInvokeConfig` | json | Asynchronous invocation retry limits and success/failure destinations | + +### Lambda Set Async Invoke Config + +Configure retry limits and destinations for asynchronous invocations of a function + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `functionName` | string | Yes | Function name, ARN, or partial ARN \(e.g. my-function, or arn:aws:lambda:us-east-1:123456789012:function:my-function\) | +| `qualifier` | string | No | Version number or alias name to act on. Omit to target the function itself | +| `maximumRetryAttempts` | number | No | Times Lambda retries a failed asynchronous invocation \(0-2\) | +| `maximumEventAgeInSeconds` | number | No | Maximum age of an event Lambda will still process \(60-21600\) | +| `onSuccessDestination` | string | No | ARN of the destination that receives successful invocation records | +| `onFailureDestination` | string | No | ARN of the destination that receives failed invocation records | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventInvokeConfig` | json | Asynchronous invocation retry limits and success/failure destinations | + +### Lambda Delete Async Invoke Config + +Remove the asynchronous invocation configuration of a function + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `functionName` | string | Yes | Function name, ARN, or partial ARN \(e.g. my-function, or arn:aws:lambda:us-east-1:123456789012:function:my-function\) | +| `qualifier` | string | No | Version number or alias name to act on. Omit to target the function itself | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | + +### Lambda List Async Invoke Configs + +List the asynchronous invocation configurations of a function + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `functionName` | string | Yes | Function name, ARN, or partial ARN \(e.g. my-function, or arn:aws:lambda:us-east-1:123456789012:function:my-function\) | +| `marker` | string | No | Pagination token returned by a previous request | +| `maxItems` | number | No | Maximum number of items to return \(1-50\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventInvokeConfigs` | array | Asynchronous invocation configurations for the function versions and aliases | +| `nextMarker` | string | Pagination token to pass as marker on the next request | + +### Lambda List Layers + +List Lambda layers and the latest version of each + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `compatibleRuntime` | string | No | Return only layers compatible with this runtime, such as python3.13 | +| `compatibleArchitecture` | string | No | Return only layers compatible with this instruction set architecture | +| `marker` | string | No | Pagination token returned by a previous request | +| `maxItems` | number | No | Maximum number of items to return \(1-50\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `layers` | array | Layers with their ARNs and latest matching version | +| `nextMarker` | string | Pagination token to pass as marker on the next request | + +### Lambda List Layer Versions + +List the versions of a Lambda layer + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `layerName` | string | Yes | The name or ARN of the layer | +| `compatibleRuntime` | string | No | Return only versions compatible with this runtime, such as python3.13 | +| `compatibleArchitecture` | string | No | Return only versions compatible with this instruction set architecture | +| `marker` | string | No | Pagination token returned by a previous request | +| `maxItems` | number | No | Maximum number of items to return \(1-50\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `layerVersions` | array | Layer versions with their ARNs, compatible runtimes, and license info | +| `nextMarker` | string | Pagination token to pass as marker on the next request | + +### Lambda Get Layer Version + +Get details and a download link for a specific layer version + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `layerName` | string | Yes | The name or ARN of the layer | +| `versionNumber` | number | Yes | Version number of the layer | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `layerVersion` | json | The layer version with its ARN, compatible runtimes, and a presigned content download URL | + +### Lambda List Tags + +List the tags applied to a Lambda function + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `resourceArn` | string | Yes | The function's Amazon Resource Name \(ARN\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tags` | json | The resource's tags as a key/value object | + +### Lambda Tag Resource + +Add tags to a Lambda function + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `resourceArn` | string | Yes | The function's Amazon Resource Name \(ARN\) | +| `tags` | json | Yes | Tags to apply, as a flat key/value JSON object | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | + +### Lambda Untag Resource + +Remove tags from a Lambda function + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `resourceArn` | string | Yes | The function's Amazon Resource Name \(ARN\) | +| `tagKeys` | array | Yes | Tag keys to remove | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | + +### Lambda Get Account Settings + +Get the Lambda limits and usage of the current AWS account and region + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `accountLimit` | json | Account-level storage and concurrency limits | +| `accountUsage` | json | Current code storage used and number of functions deployed | + +### Lambda Get Recursion Config + +Get the recursive loop detection setting of a function + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `functionName` | string | Yes | Function name, ARN, or partial ARN \(e.g. my-function, or arn:aws:lambda:us-east-1:123456789012:function:my-function\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recursiveLoop` | string | Terminate stops the function after 16 recursive invocations, Allow permits recursion | + +### Lambda Set Recursion Config + +Set whether Lambda stops a function that invokes itself recursively + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `functionName` | string | Yes | Function name, ARN, or partial ARN \(e.g. my-function, or arn:aws:lambda:us-east-1:123456789012:function:my-function\) | +| `recursiveLoop` | string | Yes | Terminate stops the function after 16 recursive invocations, Allow permits recursion | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recursiveLoop` | string | The recursion setting now in effect for the function | + +### Lambda Get Runtime Management Config + +Get the runtime update policy of a function version + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `functionName` | string | Yes | Function name, ARN, or partial ARN \(e.g. my-function, or arn:aws:lambda:us-east-1:123456789012:function:my-function\) | +| `qualifier` | string | No | Version number or alias name to act on. Omit to target the function itself | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `updateRuntimeOn` | string | Auto, FunctionUpdate, or Manual runtime update policy | +| `runtimeVersionArn` | string | ARN of the pinned runtime version, when the policy is Manual | +| `functionArn` | string | ARN of the function the policy applies to | + +### Lambda Set Runtime Management Config + +Set how and when Lambda applies runtime updates to a function version + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `functionName` | string | Yes | Function name, ARN, or partial ARN \(e.g. my-function, or arn:aws:lambda:us-east-1:123456789012:function:my-function\) | +| `updateRuntimeOn` | string | Yes | Auto applies updates automatically, FunctionUpdate applies them on the next function update, Manual pins a runtime version | +| `runtimeVersionArn` | string | No | ARN of the runtime version to pin to. Required when updateRuntimeOn is Manual | +| `qualifier` | string | No | Version number or alias name to act on. Omit to target the function itself | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `updateRuntimeOn` | string | The runtime update policy now in effect | +| `runtimeVersionArn` | string | ARN of the pinned runtime version, when the policy is Manual | +| `functionArn` | string | ARN of the function the policy applies to | + + diff --git a/apps/docs/content/docs/en/integrations/meta.json b/apps/docs/content/docs/en/integrations/meta.json index 2c0e2c9eed0..ae31c81a3c8 100644 --- a/apps/docs/content/docs/en/integrations/meta.json +++ b/apps/docs/content/docs/en/integrations/meta.json @@ -138,6 +138,7 @@ "kalshi", "ketch", "knowledge", + "lambda", "langsmith", "latex", "launchdarkly", @@ -160,6 +161,7 @@ "memory", "microsoft_ad", "microsoft_dataverse", + "microsoft_dynamics_365", "microsoft_excel", "microsoft_planner", "microsoft_teams", diff --git a/apps/docs/content/docs/en/integrations/microsoft_dynamics_365.mdx b/apps/docs/content/docs/en/integrations/microsoft_dynamics_365.mdx new file mode 100644 index 00000000000..2d370cbe787 --- /dev/null +++ b/apps/docs/content/docs/en/integrations/microsoft_dynamics_365.mdx @@ -0,0 +1,234 @@ +--- +title: Microsoft Dynamics 365 CRM +description: Manage customers, sales pipelines, and support cases in Dynamics 365 CRM +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[Microsoft Dynamics 365 CRM](https://www.microsoft.com/en-us/dynamics-365) stores its sales and customer-service data in Microsoft Dataverse. This integration presents the standard CRM tables and lifecycle actions while keeping the generic Microsoft Dataverse integration available for custom tables and advanced operations. + +Two block operations intentionally reuse the new CRM tools under a simpler CRM label: + +- **List Owners** lists active users or owner-capable teams. The results are candidates; Dynamics security roles and table privileges still determine whether a particular user or team can own a record. +- **Assign Record** updates the selected record's `ownerid@odata.bind` to an explicitly chosen user or team. + +Connect one credential per Dynamics environment. The credential is bound to that environment during Microsoft OAuth, and CRM requests refuse a different environment. This release supports public-cloud Dataverse hosts only. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Manage standard Microsoft Dynamics 365 CRM records through the Dataverse Web API. List, search, create, retrieve, and update accounts, contacts, leads, opportunities, and cases; assign records to users or teams; qualify leads; close opportunities; and resolve cases. Connect a separate Microsoft credential for each environment from this Dynamics integration page or from its workflow block; existing generic Dataverse credentials remain unchanged and are not automatically rebound. This version supports public-cloud Dynamics environments; national clouds require separate OAuth authorities. Dataverse Search must be enabled for search, and lifecycle actions require the corresponding Dynamics 365 app, security role, and record privileges. + + + +## Actions + +### List Microsoft Dynamics 365 CRM Records + +Query supported standard Microsoft Dynamics 365 CRM records. Supports OData filtering, column selection, ordering, and one bounded page at a time. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `environmentUrl` | string | Yes | Dataverse environment URL \(e.g., https://myorg.crm.dynamics.com\) | +| `entitySetName` | string | Yes | Entity set name \(plural table name, e.g., accounts, contacts\) | +| `select` | string | No | Comma-separated list of columns to return \(OData $select\) | +| `filter` | string | No | OData $filter expression \(e.g., statecode eq 0\) | +| `orderBy` | string | No | OData $orderby expression \(e.g., name asc, createdon desc\) | +| `pageSize` | number | No | Maximum records in this page \(default and maximum: 100\) | +| `expand` | string | No | Navigation properties to expand \(OData $expand\) | +| `count` | string | No | Set to "true" to include total record count in response \(OData $count\) | +| `nextLink` | string | No | Exact nextLink returned by a previous page of this operation | +| `nextPageSize` | number | No | Exact nextPageSize returned alongside nextLink by the previous page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `records` | array | Array of Dataverse records. Each record has dynamic columns based on the table schema. | +| `count` | number | Number of records returned in the current page | +| `totalCount` | number | Provider-reported matching-record count, which Dataverse may cap \(requires $count=true\) | +| `totalCountLimitExceeded` | boolean | Whether Dataverse capped the provider-reported matching-record count | +| `nextLink` | string | URL for the next page of results | +| `nextPageSize` | number | Page size that must accompany nextLink on the continuation request | +| `success` | boolean | Operation success status | + +### Get Microsoft Dynamics 365 CRM Record + +Retrieve one supported standard Microsoft Dynamics 365 CRM record by its ID. Supports $select and $expand OData query options. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `environmentUrl` | string | Yes | Dataverse environment URL \(e.g., https://myorg.crm.dynamics.com\) | +| `entitySetName` | string | Yes | Entity set name \(plural table name, e.g., accounts, contacts\) | +| `recordId` | string | Yes | The unique identifier \(GUID\) of the record to retrieve | +| `select` | string | No | Comma-separated list of columns to return \(OData $select\) | +| `expand` | string | No | Navigation properties to expand \(OData $expand\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `record` | object | Dataverse record object. Contains dynamic columns based on the queried table, plus OData metadata fields. | +| `recordId` | string | The requested record ID | +| `success` | boolean | Whether the record was retrieved successfully | + +### Create Microsoft Dynamics 365 CRM Record + +Create a new supported standard Microsoft Dynamics 365 CRM record using Dataverse logical column names. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `environmentUrl` | string | Yes | Dataverse environment URL \(e.g., https://myorg.crm.dynamics.com\) | +| `entitySetName` | string | Yes | Entity set name \(plural table name, e.g., accounts, contacts\) | +| `data` | object | Yes | Record data as a JSON object with column names as keys | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `record` | object | Dataverse record object. Contains dynamic columns based on the queried table, plus OData metadata fields. | +| `recordId` | string | The ID of the created record | +| `success` | boolean | Whether the record was created successfully | + +### Update Microsoft Dynamics 365 CRM Record + +Update an existing supported standard Microsoft Dynamics 365 CRM record. Only send the columns you want to change. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `environmentUrl` | string | Yes | Dataverse environment URL \(e.g., https://myorg.crm.dynamics.com\) | +| `entitySetName` | string | Yes | Entity set name \(plural table name, e.g., accounts, contacts\) | +| `recordId` | string | Yes | The unique identifier \(GUID\) of the record to update | +| `data` | object | Yes | Record data to update as a JSON object with column names as keys | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordId` | string | The ID of the updated record | +| `success` | boolean | Operation success status | + +### Search Microsoft Dynamics 365 CRM Records + +Perform a full-text relevance search across Microsoft Dataverse tables. Requires Dataverse Search to be enabled on the environment. Supports simple and Lucene query syntax. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `environmentUrl` | string | Yes | Dataverse environment URL \(e.g., https://myorg.crm.dynamics.com\) | +| `searchTerm` | string | Yes | Search text \(1-100 chars\). Supports simple syntax: + \(AND\), \| \(OR\), - \(NOT\), * \(wildcard\), "exact phrase" | +| `entities` | string | No | JSON array of search entity configs. Each object: \{"name":"account","selectColumns":\["name"\],"searchColumns":\["name"\],"filter":"statecode eq 0"\} | +| `filter` | string | No | Global OData filter applied across all entities \(e.g., "createdon gt 2024-01-01"\) | +| `facets` | string | No | JSON array of facet specifications \(e.g., \["entityname,count:100","ownerid,count:100"\]\) | +| `top` | number | No | Maximum number of results \(default: 50, max: 100\) | +| `skip` | number | No | Number of results to skip for pagination | +| `orderBy` | string | No | JSON array of sort expressions \(e.g., \["createdon desc"\]\) | +| `searchMode` | string | No | Search mode: "any" \(default, match any term\) or "all" \(match all terms\) | +| `searchType` | string | No | Query type: "simple" \(default\) or "lucene" \(enables regex, fuzzy, proximity, boosting\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `results` | array | Array of search result objects | +| ↳ `Id` | string | Record GUID | +| ↳ `EntityName` | string | Table logical name \(e.g., account, contact\) | +| ↳ `ObjectTypeCode` | number | Entity type code | +| ↳ `Attributes` | object | Record attributes matching the search. Keys are column logical names. | +| ↳ `Highlights` | object | Highlighted search matches. Keys are column names, values are arrays of strings with \{crmhit\}/\{/crmhit\} markers. | +| ↳ `Score` | number | Relevance score for this result | +| `totalCount` | number | Total number of matching records across all tables | +| `count` | number | Number of results returned in this page | +| `facets` | object | Facet results when facets were requested. Keys are facet names, values are arrays of facet value objects with count and value properties. | +| `success` | boolean | Operation success status | + +### Qualify Microsoft Dynamics 365 Lead + +Qualify a Dynamics 365 Sales lead and optionally create linked account, contact, and opportunity records. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `environmentUrl` | string | Yes | Dynamics 365 environment URL \(e.g., https://myorg.crm.dynamics.com\) | +| `leadId` | string | Yes | GUID of the lead to qualify | +| `createAccount` | boolean | Yes | Whether to create an account from the lead | +| `createContact` | boolean | Yes | Whether to create a contact from the lead | +| `createOpportunity` | boolean | Yes | Whether to create an opportunity from the lead | +| `statusReason` | number | No | Qualified lead status-reason value \(default: 3\) | +| `opportunityCurrencyId` | string | No | Optional transaction currency GUID for the created opportunity | +| `opportunityCustomerId` | string | No | Optional account or contact GUID for the created opportunity customer | +| `opportunityCustomerType` | string | No | Customer table type for opportunityCustomerId: account or contact | +| `sourceCampaignId` | string | No | Optional source campaign GUID for the created opportunity | +| `processInstanceId` | string | No | Optional business process flow instance GUID for the created opportunity | +| `processInstanceEntityType` | string | No | Logical table name for the business process flow instance | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `createdEntities` | array | Entity references returned by Dataverse for records created while qualifying the lead | +| `success` | boolean | Whether the lead was qualified successfully | + +### Close Microsoft Dynamics 365 Opportunity + +Close a Dynamics 365 Sales opportunity as won or lost. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `environmentUrl` | string | Yes | Dynamics 365 environment URL \(e.g., https://myorg.crm.dynamics.com\) | +| `opportunityId` | string | Yes | GUID of the opportunity to close | +| `outcome` | string | Yes | Opportunity outcome: won or lost | +| `subject` | string | No | Optional subject for the opportunity-close activity \(maximum 200 characters\) | +| `description` | string | No | Optional description for the opportunity-close activity \(maximum 2,000 characters\) | +| `statusReason` | number | No | Opportunity status-reason value \(defaults to 3 for won or 4 for lost\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `opportunityId` | string | GUID of the closed opportunity | +| `outcome` | string | The applied opportunity outcome: won or lost | +| `success` | boolean | Whether the opportunity was closed successfully | + +### Close Microsoft Dynamics 365 Case + +Resolve and close a Dynamics 365 Customer Service case. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `environmentUrl` | string | Yes | Dynamics 365 environment URL \(e.g., https://myorg.crm.dynamics.com\) | +| `caseId` | string | Yes | GUID of the case to close | +| `subject` | string | Yes | Subject for the case-resolution activity \(maximum 200 characters\) | +| `description` | string | No | Optional description for the case-resolution activity \(maximum 100,000 characters\) | +| `timeSpent` | number | No | Optional nonnegative number of minutes spent resolving the case | +| `statusReason` | number | No | Resolved case status-reason value \(default: 5\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `caseId` | string | GUID of the closed case | +| `success` | boolean | Whether the case was closed successfully | + + diff --git a/apps/docs/content/docs/en/introduction/index.mdx b/apps/docs/content/docs/en/introduction/index.mdx index 898c4423ec9..0a2144cd0b0 100644 --- a/apps/docs/content/docs/en/introduction/index.mdx +++ b/apps/docs/content/docs/en/introduction/index.mdx @@ -14,9 +14,9 @@ Sim is the open-source AI workspace where teams build, deploy, and manage AI age
A focused Sim workflow builder view with connected blocks, the Editor panel, and run output
@@ -95,7 +95,7 @@ For anything not built in, [MCP support](/agents/mcp) connects any external serv Usage is recorded independently of execution logs, so it outlives them: logs expire under your workspace's retention setting, while the record of who touched a credential does not. It records what a run resolved, subject to the recognition limits under [Execution log protection](#execution-log-protection) — a read Sim cannot attribute is left out rather than guessed at, so treat an empty trail as "nothing recognized," not proof a secret was never used. @@ -157,7 +158,7 @@ Usage is recorded independently of execution logs, so it outlives them: logs exp | | Workspace | Personal | |---|---|---| | **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 | +| **Who sees the value** | Workspace admins and that secret's Credential Admins; Credential Members when **Show value in logs and Chat** is enabled | 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 and that secret's Credential Admins | Only you | diff --git a/apps/docs/content/docs/es/introduction/index.mdx b/apps/docs/content/docs/es/introduction/index.mdx index 1f62e284dd8..18b7b0c1218 100644 --- a/apps/docs/content/docs/es/introduction/index.mdx +++ b/apps/docs/content/docs/es/introduction/index.mdx @@ -12,9 +12,9 @@ Sim es un constructor de flujos de trabajo visuales de código abierto para crea
Lienzo visual de flujos de trabajo de Sim
@@ -114,4 +114,4 @@ Implementa en tu propia infraestructura usando Docker Compose o Kubernetes. Mant Configura roles y permisos del espacio de trabajo - \ No newline at end of file + diff --git a/apps/docs/content/docs/fr/introduction/index.mdx b/apps/docs/content/docs/fr/introduction/index.mdx index d49b0dd4468..35f2404d98a 100644 --- a/apps/docs/content/docs/fr/introduction/index.mdx +++ b/apps/docs/content/docs/fr/introduction/index.mdx @@ -12,9 +12,9 @@ Sim est un constructeur de flux de travail visuel open-source pour créer et dé
Canevas de flux de travail visuel Sim
diff --git a/apps/docs/content/docs/ja/introduction/index.mdx b/apps/docs/content/docs/ja/introduction/index.mdx index 6b21f1e8a11..377e6794260 100644 --- a/apps/docs/content/docs/ja/introduction/index.mdx +++ b/apps/docs/content/docs/ja/introduction/index.mdx @@ -12,9 +12,9 @@ Simはオープンソースのビジュアルワークフロービルダーで
Simビジュアルワークフローキャンバス
diff --git a/apps/docs/content/docs/zh/introduction/index.mdx b/apps/docs/content/docs/zh/introduction/index.mdx index c9842a54953..6b9c00b90d9 100644 --- a/apps/docs/content/docs/zh/introduction/index.mdx +++ b/apps/docs/content/docs/zh/introduction/index.mdx @@ -12,9 +12,9 @@ Sim 是一个开源的可视化工作流构建器,用于构建和部署 AI 代
Sim 可视化工作流画布
diff --git a/apps/docs/public/static/introduction.png b/apps/docs/public/static/introduction.png index 99f3c97e1bb..8465abf568b 100644 Binary files a/apps/docs/public/static/introduction.png and b/apps/docs/public/static/introduction.png differ diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts index f59ae8b4dc6..ab527378a22 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts @@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({ getBaseUrl: vi.fn(), requireClient: vi.fn(), createConnection: vi.fn(), + getPerRequestScopes: vi.fn(), launchConnection: vi.fn(), })) @@ -46,6 +47,9 @@ vi.mock('@/lib/credentials/application/launch-credential-connection', () => ({ execute: mocks.launchConnection, }, })) +vi.mock('@/lib/oauth/utils', () => ({ + getPerRequestOAuthLinkScopes: mocks.getPerRequestScopes, +})) import { GET } from '@/app/api/auth/oauth2/authorize/route' @@ -89,6 +93,7 @@ describe('OAuth2 authorize route', () => { }, }) mocks.linkAccount.mockResolvedValue(linkResponse()) + mocks.getPerRequestScopes.mockReturnValue(undefined) }) it('creates a canonical application draft for a legacy connect URL', async () => { @@ -123,6 +128,29 @@ describe('OAuth2 authorize route', () => { expect(mocks.createConnection).not.toHaveBeenCalled() }) + it('passes per-request scopes to providers that cannot inherit static connector scopes', async () => { + const scopes = ['openid', 'https://dynamics.microsoft.com/user_impersonation'] + mocks.getPerRequestScopes.mockReturnValue(scopes) + mocks.createConnection.mockResolvedValue({ + providerId: 'microsoft-dataverse', + workspaceId: WORKSPACE_ID, + draftId: 'draft-1', + expiresAt: new Date(), + authorizationUrl: '', + }) + + await GET(request({ providerId: 'microsoft-dataverse', workspaceId: WORKSPACE_ID })) + + expect(mocks.linkAccount).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + providerId: 'microsoft-dataverse', + scopes, + }), + }) + ) + }) + it('launches an exact draft without creating another one', async () => { const response = await GET(request({ draftId: 'draft-1' })) diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.ts b/apps/sim/app/api/auth/oauth2/authorize/route.ts index 3a1f4beb350..e15d0ad6074 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.ts @@ -12,6 +12,7 @@ import { CredentialConnectionProviderMismatchError } from '@/lib/credentials/app import { createCredentialConnection } from '@/lib/credentials/application/create-credential-connection' import { launchCredentialConnection } from '@/lib/credentials/application/launch-credential-connection' import { OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM } from '@/lib/credentials/draft-constants' +import { getPerRequestOAuthLinkScopes } from '@/lib/oauth/utils' const logger = createLogger('OAuth2Authorize') @@ -124,11 +125,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const stateCallbackUrl = new URL(callbackURL) stateCallbackUrl.searchParams.set(OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM, connectionDraftId) + const scopes = getPerRequestOAuthLinkScopes(providerId) const linkResponse = await auth.api.oAuth2LinkAccount({ body: { providerId, callbackURL: stateCallbackUrl.toString(), + ...(scopes && { scopes }), ...(fromConnectionDraft ? { errorCallbackURL: `${baseUrl}/oauth/credential-connected?result=failed` } : {}), diff --git a/apps/sim/app/api/enterprise-owner-claims/[id]/accept/route.ts b/apps/sim/app/api/enterprise-owner-claims/[id]/accept/route.ts index e9ade0386a5..d88de154171 100644 --- a/apps/sim/app/api/enterprise-owner-claims/[id]/accept/route.ts +++ b/apps/sim/app/api/enterprise-owner-claims/[id]/accept/route.ts @@ -14,15 +14,6 @@ export const POST = withRouteHandler( if (!session?.user?.id || !session.user.email) { return NextResponse.json({ error: 'unauthorized' }, { status: 401 }) } - if (!session.user.emailVerified) { - return NextResponse.json( - { - error: 'email-unverified', - message: 'Verify the invited email before accepting Enterprise ownership.', - }, - { status: 403 } - ) - } const parsed = await parseRequest(acceptEnterpriseOwnerClaimContract, request, context) if (!parsed.success) return parsed.response const result = await acceptEnterpriseOwnerClaim({ diff --git a/apps/sim/app/api/enterprise-owner-claims/[id]/route.test.ts b/apps/sim/app/api/enterprise-owner-claims/[id]/route.test.ts new file mode 100644 index 00000000000..65e2fe04550 --- /dev/null +++ b/apps/sim/app/api/enterprise-owner-claims/[id]/route.test.ts @@ -0,0 +1,115 @@ +/** + * @vitest-environment node + */ +import { authMockFns, createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + acceptClaim: vi.fn(), + getClaimDetails: vi.fn(), +})) + +vi.mock('@/lib/billing/enterprise-owner-claim', () => ({ + acceptEnterpriseOwnerClaim: mocks.acceptClaim, + getEnterpriseOwnerClaimDetails: mocks.getClaimDetails, + EnterpriseOwnerClaimEmailMismatchError: class EnterpriseOwnerClaimEmailMismatchError extends Error {}, + EnterpriseOwnerClaimWorkspaceLimitError: class EnterpriseOwnerClaimWorkspaceLimitError extends Error {}, +})) + +vi.mock('@/lib/billing/enterprise-provisioning', () => ({ + EnterpriseProvisioningError: class EnterpriseProvisioningError extends Error {}, +})) + +import { POST } from '@/app/api/enterprise-owner-claims/[id]/accept/route' +import { GET } from '@/app/api/enterprise-owner-claims/[id]/route' + +const claim = { + id: 'claim-1', + ownerEmail: 'owner@example.com', + organizationName: 'Acme', + organizationId: null, + provisioningOperationId: null, + stage: 'owner_acceptance' as const, + status: 'awaiting_owner' as const, + error: null, + expiresAt: '2026-09-04T00:00:00.000Z', + createdAt: '2026-08-28T00:00:00.000Z', + updatedAt: '2026-08-28T00:00:00.000Z', +} + +describe('Enterprise owner claim routes', () => { + beforeEach(() => { + vi.clearAllMocks() + authMockFns.mockGetSession.mockResolvedValue({ + user: { + id: 'owner-1', + name: 'Owner', + email: 'owner@example.com', + emailVerified: false, + }, + }) + }) + + it('lets the invited account review the mailed claim before email verification', async () => { + mocks.getClaimDetails.mockResolvedValue({ + ...claim, + invoiceAmountUsd: 10_000, + billingInterval: 'year', + seats: 10, + invitations: 0, + workspacePreview: { workspacesToMove: [], createsDefaultWorkspace: true }, + acceptanceReview: { canAccept: true, reason: null, requiredSeats: 1 }, + }) + + const response = await GET( + createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost/api/enterprise-owner-claims/claim-1?token=secure-token' + ), + { params: Promise.resolve({ id: 'claim-1' }) } + ) + + expect(response.status).toBe(200) + expect(mocks.getClaimDetails).toHaveBeenCalledWith({ + claimId: 'claim-1', + token: 'secure-token', + userId: 'owner-1', + userEmail: 'owner@example.com', + }) + }) + + it('lets the acceptance transaction verify an unverified invited account', async () => { + mocks.acceptClaim.mockResolvedValue({ + success: true, + claim, + redirectPath: '/workspace', + }) + + const response = await POST( + createMockRequest( + 'POST', + { + token: 'secure-token', + disclosedWorkspaceIds: [], + disclosedCreatesDefaultWorkspace: true, + }, + {}, + 'http://localhost/api/enterprise-owner-claims/claim-1/accept' + ), + { params: Promise.resolve({ id: 'claim-1' }) } + ) + + expect(response.status).toBe(200) + expect(mocks.acceptClaim).toHaveBeenCalledWith({ + claimId: 'claim-1', + token: 'secure-token', + userId: 'owner-1', + userEmail: 'owner@example.com', + userName: 'Owner', + disclosedWorkspaceIds: [], + disclosedCreatesDefaultWorkspace: true, + }) + }) +}) diff --git a/apps/sim/app/api/enterprise-owner-claims/[id]/route.ts b/apps/sim/app/api/enterprise-owner-claims/[id]/route.ts index 36f1c4be80e..2bb2d47b943 100644 --- a/apps/sim/app/api/enterprise-owner-claims/[id]/route.ts +++ b/apps/sim/app/api/enterprise-owner-claims/[id]/route.ts @@ -19,15 +19,6 @@ export const GET = withRouteHandler( if (!session?.user?.id || !session.user.email) { return NextResponse.json({ error: 'unauthorized' }, { status: 401 }) } - if (!session.user.emailVerified) { - return NextResponse.json( - { - error: 'email-unverified', - message: 'Verify the invited email before reviewing Enterprise ownership.', - }, - { status: 403 } - ) - } const parsed = await parseRequest(getEnterpriseOwnerClaimContract, request, context) if (!parsed.success) return parsed.response try { diff --git a/apps/sim/app/api/guardrails/pii/validate/route.test.ts b/apps/sim/app/api/guardrails/pii/validate/route.test.ts new file mode 100644 index 00000000000..8e5fd8dfc5d --- /dev/null +++ b/apps/sim/app/api/guardrails/pii/validate/route.test.ts @@ -0,0 +1,67 @@ +/** + * @vitest-environment node + */ +import { createMockRequest, hybridAuthMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockValidatePII } = vi.hoisted(() => ({ + mockValidatePII: vi.fn(), +})) + +vi.mock('@/lib/guardrails/validate_pii', () => ({ + validatePII: mockValidatePII, +})) + +import { POST } from '@/app/api/guardrails/pii/validate/route' + +describe('POST /api/guardrails/pii/validate', () => { + beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({ success: true }) + mockValidatePII.mockResolvedValue({ passed: true, detectedEntities: [] }) + }) + + it('authenticates before validating the request body', async () => { + hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({ + success: false, + error: 'Internal authentication required', + }) + + const response = await POST(createMockRequest('POST', { text: 42 })) + + expect(response.status).toBe(401) + expect(mockValidatePII).not.toHaveBeenCalled() + }) + + it('runs Presidio validation inside the app boundary', async () => { + const request = createMockRequest('POST', { + text: 'email a@b.com', + entityTypes: ['EMAIL_ADDRESS'], + mode: 'mask', + language: 'en', + }) + + const response = await POST(request) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ passed: true, detectedEntities: [] }) + expect(mockValidatePII).toHaveBeenCalledWith({ + text: 'email a@b.com', + entityTypes: ['EMAIL_ADDRESS'], + mode: 'mask', + language: 'en', + customPatterns: undefined, + requestId: 'mock-request-id', + abortSignal: request.signal, + }) + }) + + it('rejects malformed input before calling Presidio', async () => { + const response = await POST( + createMockRequest('POST', { text: 'claim', entityTypes: [], mode: 'invalid' }) + ) + + expect(response.status).toBe(400) + expect(mockValidatePII).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/guardrails/pii/validate/route.ts b/apps/sim/app/api/guardrails/pii/validate/route.ts new file mode 100644 index 00000000000..a8120ac9f4f --- /dev/null +++ b/apps/sim/app/api/guardrails/pii/validate/route.ts @@ -0,0 +1,35 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { guardrailsPiiValidateContract } from '@/lib/api/contracts' +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 { validatePII } from '@/lib/guardrails/validate_pii' + +/** + * App-container capability boundary for single-text PII validation. Presidio is + * intentionally ECS-internal, so remote workflow runtimes authenticate here + * instead of importing its client and attempting to reach `PII_URL` directly. + */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(guardrailsPiiValidateContract, request, {}) + if (!parsed.success) return parsed.response + + const { text, entityTypes, mode, language, customPatterns } = parsed.data.body + const result = await validatePII({ + text, + entityTypes, + mode, + language, + customPatterns, + requestId: generateRequestId(), + abortSignal: request.signal, + }) + + return NextResponse.json(guardrailsPiiValidateContract.response.schema.parse(result)) +}) diff --git a/apps/sim/app/api/organizations/[id]/usage/summary/route.ts b/apps/sim/app/api/organizations/[id]/usage/summary/route.ts index ba7abb7372a..4d1111e1962 100644 --- a/apps/sim/app/api/organizations/[id]/usage/summary/route.ts +++ b/apps/sim/app/api/organizations/[id]/usage/summary/route.ts @@ -26,6 +26,7 @@ export const GET = defineInternalJsonRoute({ errorPolicy: organizationUsageErrorPolicy, mapInput: ({ params, query }) => ({ organizationId: params.id, + workspaceId: query.workspaceId, preset: query.preset, startDate: query.startDate ? new Date(query.startDate) : undefined, endDate: query.endDate ? new Date(query.endDate) : undefined, diff --git a/apps/sim/app/api/tools/netsuite/objects/route.test.ts b/apps/sim/app/api/tools/netsuite/objects/route.test.ts index a709cf52da0..9d6d9658262 100644 --- a/apps/sim/app/api/tools/netsuite/objects/route.test.ts +++ b/apps/sim/app/api/tools/netsuite/objects/route.test.ts @@ -31,11 +31,11 @@ vi.mock('@/lib/oauth/credential-service', () => ({ resolveCredentialAccessToken: mockResolveCredentialAccessToken, resolveOAuthAccountId: mockResolveOAuthAccountId, })) -vi.mock('@/tools/netsuite/get_async_status', () => ({ - netsuiteGetAsyncStatusTool: { directExecution: mockGetAsyncStatus }, +vi.mock('@/lib/internal/netsuite/operations/get-async-status', () => ({ + executeNetsuiteGetAsyncStatusOperation: mockGetAsyncStatus, })) -vi.mock('@/tools/netsuite/list_record_types', () => ({ - netsuiteListRecordTypesTool: { directExecution: mockListRecordTypes }, +vi.mock('@/lib/internal/netsuite/operations/list-record-types', () => ({ + executeNetsuiteListRecordTypesOperation: mockListRecordTypes, })) import { POST } from '@/app/api/tools/netsuite/objects/route' diff --git a/apps/sim/app/api/tools/netsuite/objects/route.ts b/apps/sim/app/api/tools/netsuite/objects/route.ts index b6183e13a28..e7a2baba959 100644 --- a/apps/sim/app/api/tools/netsuite/objects/route.ts +++ b/apps/sim/app/api/tools/netsuite/objects/route.ts @@ -12,9 +12,9 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/credentials/client-credential-accounts/descriptors' import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' +import { executeNetsuiteGetAsyncStatusOperation } from '@/lib/internal/netsuite/operations/get-async-status' +import { executeNetsuiteListRecordTypesOperation } from '@/lib/internal/netsuite/operations/list-record-types' import { resolveCredentialAccessToken, resolveOAuthAccountId } from '@/lib/oauth/credential-service' -import { netsuiteGetAsyncStatusTool } from '@/tools/netsuite/get_async_status' -import { netsuiteListRecordTypesTool } from '@/tools/netsuite/list_record_types' import type { NetSuiteAuthParams } from '@/tools/netsuite/types' import { normalizeSuiteTalkUrl } from '@/tools/netsuite/utils' import type { ToolResponse } from '@/tools/types' @@ -180,14 +180,13 @@ async function executeDiscoveryTool( throwIfAborted(signal) switch (body.kind) { case 'record_types': { - const execute = netsuiteListRecordTypesTool.directExecution - if (!execute) throw new Error('NetSuite record-type tool is not executable') - return execute(auth, signal) + return executeNetsuiteListRecordTypesOperation(auth, signal) } case 'async_tasks': { - const execute = netsuiteGetAsyncStatusTool.directExecution - if (!execute) throw new Error('NetSuite asynchronous-status tool is not executable') - return execute({ ...auth, jobId: body.jobId, view: 'tasks' }, signal) + return executeNetsuiteGetAsyncStatusOperation( + { ...auth, jobId: body.jobId, view: 'tasks' }, + signal + ) } } } diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/route.test.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/route.test.ts index 2bbb4e1b378..6259951e256 100644 --- a/apps/sim/app/api/workspaces/[id]/credential-groups/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/route.test.ts @@ -47,7 +47,7 @@ describe('credential groups collection route', () => { user: { id: 'user-1' }, session: { id: 'session-1' }, }) - mocks.list.mockResolvedValue({ credentialGroups: [] }) + mocks.list.mockResolvedValue({ credentialGroups: [], availableProviders: ['gmail'] }) }) it('authenticates before parsing the request body', async () => { @@ -64,7 +64,7 @@ describe('credential groups collection route', () => { const response = await GET(request, context) expect(response.status).toBe(200) - expect(await response.json()).toEqual({ credentialGroups: [] }) + expect(await response.json()).toEqual({ credentialGroups: [], availableProviders: ['gmail'] }) expect(mocks.list).toHaveBeenCalledWith({ principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, input: { workspaceId: WORKSPACE_ID }, diff --git a/apps/sim/app/api/workspaces/[id]/environment/route.test.ts b/apps/sim/app/api/workspaces/[id]/environment/route.test.ts index 7f0f121d319..15aee95d526 100644 --- a/apps/sim/app/api/workspaces/[id]/environment/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/environment/route.test.ts @@ -56,6 +56,7 @@ describe('GET /api/workspaces/[id]/environment', () => { personalDecrypted: { PERSONAL: 'personal-secret', SHARED_PERSONAL: 'shared-secret' }, personalOwners: { PERSONAL: 'u-1', SHARED_PERSONAL: 'owner-2' }, conflicts: [], + workspaceUnredactedKeys: [], }) mockGetPersonalEnvKeyRawAccess.mockResolvedValue({ ownedKeys: new Set(['PERSONAL']), @@ -101,6 +102,26 @@ describe('GET /api/workspaces/[id]/environment', () => { expect(body.data.workspace.DATABASE_URL).toBe('') }) + it('reveals an unredacted workspace value to a read-only credential member', async () => { + mockGetUserEntityPermissions.mockResolvedValue('read') + mockGetWorkspaceEnvKeyAdminAccess.mockResolvedValue({ + adminKeys: new Set(), + knownKeys: new Set(['OPENAI_API_KEY', 'DATABASE_URL']), + }) + mockGetPersonalAndWorkspaceEnv.mockResolvedValue({ + workspaceDecrypted: { OPENAI_API_KEY: 'sk-secret', DATABASE_URL: 'postgres://secret' }, + personalDecrypted: {}, + personalOwners: {}, + conflicts: [], + workspaceUnredactedKeys: ['OPENAI_API_KEY'], + }) + + const { body } = await callGet() + + expect(body.data.workspace.OPENAI_API_KEY).toBe('sk-secret') + expect(body.data.workspace.DATABASE_URL).toBe('') + }) + it('reveals legacy keys (no per-secret ACL) only to workspace admins', async () => { mockGetUserEntityPermissions.mockResolvedValue('admin') mockGetWorkspaceEnvKeyAdminAccess.mockResolvedValue({ diff --git a/apps/sim/app/api/workspaces/[id]/environment/route.ts b/apps/sim/app/api/workspaces/[id]/environment/route.ts index 4673fa1e47b..9edf6a73889 100644 --- a/apps/sim/app/api/workspaces/[id]/environment/route.ts +++ b/apps/sim/app/api/workspaces/[id]/environment/route.ts @@ -36,25 +36,26 @@ import { const logger = createLogger('WorkspaceEnvironmentAPI') /** - * Restricts decrypted workspace env values to administrators. Members (including - * read-only) receive the variable names with empty values so editor autocomplete - * and conflict detection keep working without leaking secret values. A value is - * revealed when the caller is a workspace admin (which includes organization - * admins) or a per-secret credential admin of that key. Mirrors the per-key edit - * gating in PUT/DELETE: if you can administer a secret, you can read it. + * Reveals a workspace secret only to a workspace administrator, that secret's + * credential administrator, or a caller allowed to use a secret explicitly + * marked visible. The environment snapshot has already limited + * `workspaceUnredactedKeys` to secrets the caller may use. */ async function maskWorkspaceEnvForViewer({ workspaceDecrypted, workspaceId, userId, permission, + workspaceUnredactedKeys, }: { workspaceDecrypted: Record workspaceId: string userId: string permission: PermissionType + workspaceUnredactedKeys: readonly string[] }): Promise> { const workspaceKeys = Object.keys(workspaceDecrypted) + const unredactedKeys = new Set(workspaceUnredactedKeys) const { adminKeys } = await getWorkspaceEnvKeyAdminAccess({ workspaceId, envKeys: workspaceKeys, @@ -63,7 +64,7 @@ async function maskWorkspaceEnvForViewer({ const masked: Record = {} for (const key of workspaceKeys) { - const canViewValue = permission === 'admin' || adminKeys.has(key) + const canViewValue = permission === 'admin' || adminKeys.has(key) || unredactedKeys.has(key) masked[key] = canViewValue ? workspaceDecrypted[key] : '' } return masked @@ -119,14 +120,20 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { workspaceDecrypted, personalDecrypted, personalOwners, conflicts } = - await getPersonalAndWorkspaceEnv(userId, workspaceId) + const { + workspaceDecrypted, + personalDecrypted, + personalOwners, + conflicts, + workspaceUnredactedKeys, + } = await getPersonalAndWorkspaceEnv(userId, workspaceId) const workspace = await maskWorkspaceEnvForViewer({ workspaceDecrypted, workspaceId, userId, permission, + workspaceUnredactedKeys, }) const personal = await maskPersonalEnvForViewer({ personalDecrypted, diff --git a/apps/sim/app/desktop/connect/connect-launcher.tsx b/apps/sim/app/desktop/connect/connect-launcher.tsx index fdb6276aea3..165c5930a2d 100644 --- a/apps/sim/app/desktop/connect/connect-launcher.tsx +++ b/apps/sim/app/desktop/connect/connect-launcher.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { Chip } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' import { client } from '@/lib/auth/auth-client' +import { getPerRequestOAuthLinkScopes } from '@/lib/oauth/utils' import { DesktopHandoffShell } from '@/app/desktop/components/desktop-handoff-shell' interface ConnectLauncherProps { @@ -31,9 +32,11 @@ export function ConnectLauncher({ providerId, completeUrl }: ConnectLauncherProp const start = useCallback(async () => { setError(null) try { + const scopes = getPerRequestOAuthLinkScopes(providerId) await client.oauth2.link({ providerId, callbackURL: completeUrl, + ...(scopes && { scopes }), // 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 diff --git a/apps/sim/app/enterprise/claim/[id]/enterprise-owner-claim.tsx b/apps/sim/app/enterprise/claim/[id]/enterprise-owner-claim.tsx index 5b807d39103..c5a0d690700 100644 --- a/apps/sim/app/enterprise/claim/[id]/enterprise-owner-claim.tsx +++ b/apps/sim/app/enterprise/claim/[id]/enterprise-owner-claim.tsx @@ -184,9 +184,7 @@ export default function EnterpriseOwnerClaim({ registrationDisabled }: Enterpris apiErrorMessage(detailsQuery.error) ?? (queryErrorCode === 'email-mismatch' ? 'This invitation was sent to a different email address.' - : queryErrorCode === 'email-unverified' - ? 'Verify the invited email, then return to this owner invitation.' - : 'This Enterprise invitation is invalid or unavailable.'), + : 'This Enterprise invitation is invalid or unavailable.'), } : null) if (error) { diff --git a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.test.tsx index 31a82cd10ce..e5103ad57a0 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.test.tsx @@ -95,6 +95,13 @@ vi.mock('@/hooks/queries/oauth/oauth-connections', () => ({ }), })) +vi.mock('@/hooks/queries/oauth/microsoft-dataverse-connections', () => ({ + useConnectMicrosoftDataverseOAuthService: () => ({ + mutateAsync: vi.fn(), + isPending: false, + }), +})) + import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal' let container: HTMLDivElement diff --git a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx index 552637626c8..9b76a0425c5 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx @@ -26,8 +26,16 @@ import { parseProvider, } from '@/lib/oauth' import { getScopeDescription, getServiceConfigByProviderId } from '@/lib/oauth/utils' +import { + MicrosoftDataverseEnvironmentField, + useMicrosoftDataverseEnvironmentForm, +} from '@/app/workspace/[workspaceId]/components/connect-oauth-modal/microsoft-dataverse-environment' import { withBrandIcon } from '@/blocks/brand-icon' import { useCreateCredentialDraft, useWorkspaceCredentials } from '@/hooks/queries/credentials' +import { + assertMicrosoftDataverseWebOAuthAvailable, + useConnectMicrosoftDataverseOAuthService, +} from '@/hooks/queries/oauth/microsoft-dataverse-connections' import { useConnectOAuthService } from '@/hooks/queries/oauth/oauth-connections' const logger = createLogger('ConnectOAuthModal') @@ -85,6 +93,10 @@ interface ConnectOAuthModalBaseProps { /** Used to resolve display metadata and the provider id when not supplied directly. */ provider?: OAuthProvider serviceId?: string + /** Enables the environment-bound Dynamics 365 OAuth flow. Legacy Dataverse callers omit it. */ + requireDataverseEnvironment?: boolean + /** Locks an environment-bound connection to the workflow or credential's selected environment. */ + dataverseEnvironmentUrl?: string } /** @@ -162,6 +174,7 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) { const [selectedProviderId, setSelectedProviderId] = useState(null) const providerId = selectedProviderId ?? declaredProviderId + const requiredScopes = props.requiredScopes ?? EMPTY_SCOPES const [displayName, setDisplayName] = useState('') const [description, setDescription] = useState('') @@ -186,6 +199,14 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) { }) const createDraft = useCreateCredentialDraft() const connectOAuthService = useConnectOAuthService() + const connectMicrosoftDataverseOAuthService = useConnectMicrosoftDataverseOAuthService() + const dataverseEnvironmentForm = useMicrosoftDataverseEnvironmentForm({ + fallbackScopes: requiredScopes, + lockedEnvironmentUrl: props.dataverseEnvironmentUrl, + open, + providerId, + required: props.requireDataverseEnvironment === true, + }) /** * Lowercased set of OAuth credential names already in the workspace. Drives @@ -201,25 +222,22 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) { [credentials] ) - const requiredScopes = props.requiredScopes ?? EMPTY_SCOPES const newScopes = !isConnect ? (props.newScopes ?? EMPTY_SCOPES) : EMPTY_SCOPES - const newScopesSet = useMemo( - () => new Set([...newScopes].filter((scope) => !isHiddenScope(scope))), - [newScopes] + const newScopesSet = new Set(newScopes.filter((scope) => !isHiddenScope(scope))) + const displayScopes = [...dataverseEnvironmentForm.effectiveScopes].filter( + (scope) => !isHiddenScope(scope) ) - const displayScopes = useMemo(() => { - const filtered = [...requiredScopes].filter((scope) => !isHiddenScope(scope)) - if (isConnect) return filtered - return filtered.sort((a, b) => { + if (!isConnect) { + displayScopes.sort((a, b) => { const aIsNew = newScopesSet.has(a) const bIsNew = newScopesSet.has(b) if (aIsNew && !bIsNew) return -1 if (!aIsNew && bIsNew) return 1 return 0 }) - }, [isConnect, requiredScopes, newScopesSet]) + } /** * Initialize the connect form once per open session, after credentials have @@ -261,6 +279,10 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) { setValidationError(null) setSubmitError(null) try { + const environmentUrl = dataverseEnvironmentForm.validate() + if (dataverseEnvironmentForm.enabled && !environmentUrl) return + if (environmentUrl) assertMicrosoftDataverseWebOAuthAvailable() + let connectorType: string | undefined let draftId: string | undefined @@ -343,11 +365,19 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) { callbackURL.searchParams.set(ADD_CONNECTOR_SEARCH_PARAM, connectorType) } - await connectOAuthService.mutateAsync({ - providerId, - callbackURL: callbackURL.toString(), - draftId, - }) + if (environmentUrl) { + await connectMicrosoftDataverseOAuthService.mutateAsync({ + callbackURL: callbackURL.toString(), + draftId, + environmentUrl, + }) + } else { + await connectOAuthService.mutateAsync({ + providerId, + callbackURL: callbackURL.toString(), + draftId, + }) + } handleClose() } catch (err: unknown) { const message = getErrorMessage(err, 'Failed to start OAuth connection') @@ -357,10 +387,16 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) { } const createsDraft = isConnect || (!isConnect && Boolean(props.reconnectTarget)) - const isPending = (createsDraft && createDraft.isPending) || connectOAuthService.isPending + const isPending = + (createsDraft && createDraft.isPending) || + connectOAuthService.isPending || + connectMicrosoftDataverseOAuthService.isPending const isDisabled = isConnect - ? !displayName.trim() || isPending || Boolean(existingCredential) - : isPending + ? !displayName.trim() || + !dataverseEnvironmentForm.isComplete || + isPending || + Boolean(existingCredential) + : !dataverseEnvironmentForm.isComplete || isPending const displayNameError = validationError ?? @@ -413,6 +449,8 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) { /> )} + + {isConnect && ( void + validate: () => string | undefined + value: string +} + +export function useMicrosoftDataverseEnvironmentForm({ + fallbackScopes, + lockedEnvironmentUrl, + open, + providerId, + required, +}: UseMicrosoftDataverseEnvironmentFormProps): MicrosoftDataverseEnvironmentForm { + const enabled = required && providerId === MICROSOFT_DATAVERSE_PROVIDER_ID + const initialValue = enabled ? (lockedEnvironmentUrl ?? '') : '' + const [value, setEnvironmentValue] = useState(initialValue) + const [error, setError] = useState(null) + const sessionKey = `${open}:${enabled}:${lockedEnvironmentUrl ?? ''}` + const [previousSessionKey, setPreviousSessionKey] = useState(sessionKey) + + if (previousSessionKey !== sessionKey) { + setPreviousSessionKey(sessionKey) + setEnvironmentValue(open && enabled ? (lockedEnvironmentUrl ?? '') : '') + setError(null) + } + + const setValue = (nextValue: string) => { + setEnvironmentValue(nextValue) + setError(null) + } + + const validate = () => { + if (!enabled) return undefined + try { + const environmentUrl = normalizeMicrosoftDataverseEnvironmentUrl(value) + setError(null) + return environmentUrl + } catch (validationError) { + setError( + getErrorMessage(validationError, 'Enter a valid public-cloud Dataverse environment URL.') + ) + return undefined + } + } + + const effectiveScopes = (() => { + if (!enabled) return fallbackScopes + if (!value.trim()) return getMicrosoftDataverseIdentityScopes(fallbackScopes) + try { + return getMicrosoftDataverseOAuthScopes(value) + } catch { + return fallbackScopes + } + })() + + return { + effectiveScopes, + enabled, + error, + isComplete: !enabled || value.trim().length > 0, + isLocked: enabled && Boolean(lockedEnvironmentUrl), + setValue, + validate, + value, + } +} + +interface MicrosoftDataverseEnvironmentFieldProps { + form: MicrosoftDataverseEnvironmentForm +} + +export function MicrosoftDataverseEnvironmentField({ + form, +}: MicrosoftDataverseEnvironmentFieldProps) { + if (!form.enabled) return null + + if (form.isLocked) { + return ( + + ) + } + + return ( + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx index 5c94470b581..6424128e163 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx @@ -25,6 +25,7 @@ import { DropdownMenuTrigger, FloatingTooltip, OverflowText, + overflowTextClipClass, overflowTextFadeClass, POPOVER_ANIMATION_CLASSES, Popover, @@ -543,9 +544,11 @@ function BreadcrumbLocationPopover({
{rootBreadcrumb?.label && ( - - {rootBreadcrumb.label} - + )} @@ -703,10 +706,9 @@ const BreadcrumbLabel = memo( {label} diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index dc7d32ec991..73e9dd4710f 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -11,7 +11,6 @@ import { Folder, FolderPlus, Loader, - OverflowText, Pencil, Plus, Trash, @@ -1960,13 +1959,7 @@ export function Files() { multiSelectValues={typeFilter} onMultiSelectChange={setTypeFilter} overlayLabel={typeDisplayLabel} - overlayContent={ - - } + overlayContent={typeDisplayLabel} showAllOption allOptionLabel='All' className='w-full' @@ -1984,13 +1977,7 @@ export function Files() { multiSelectValues={sizeFilter} onMultiSelectChange={setSizeFilter} overlayLabel={sizeDisplayLabel} - overlayContent={ - - } + overlayContent={sizeDisplayLabel} showAllOption allOptionLabel='All' className='w-full' @@ -2004,11 +1991,8 @@ export function Files() { multiSelect multiSelectValues={uploadedByFilter} onMultiSelectChange={setUploadedByFilter} - overlayContent={ - - {uploadedByDisplayLabel} - - } + overlayLabel={uploadedByDisplayLabel} + overlayContent={uploadedByDisplayLabel} searchable searchPlaceholder='Search members...' showAllOption diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx index cf418d5818c..38bf0ac757a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx @@ -7,6 +7,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuItemLabel, DropdownMenuSearchInput, DropdownMenuSub, DropdownMenuSubContent, @@ -387,7 +388,7 @@ export function ResourceFolderTreeItems({ - {node.name} + {folderType && ( @@ -395,7 +396,7 @@ export function ResourceFolderTreeItems({ onClick={() => onSelect({ type: folderType, id: node.id, title: node.name })} > - {node.name} + )} onSelect(resourceFromItem(type, item))}> - {config.label} + ) } @@ -544,7 +545,7 @@ export function ResourceMenuSections({ - {config.label} + {section ? ( diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx index f8fbafb5e47..075c690f70f 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx @@ -215,6 +215,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration requiredScopes={oauthService.requiredScopes} serviceName={oauthService.serviceName} serviceIcon={oauthService.serviceIcon} + requireDataverseEnvironment={integration.type === 'microsoft_dynamics_365'} /> )} {hasServiceAccount && serviceAccountTarget && ( diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx index f4da0081688..914fe2d366b 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx @@ -44,10 +44,16 @@ import { useWorkspaceCredentials, type WorkspaceCredential, } from '@/hooks/queries/credentials' +import { + assertMicrosoftDataverseReconnectAvailable, + useConnectMicrosoftDataverseOAuthService, + useMicrosoftDataverseCredentialBinding, +} from '@/hooks/queries/oauth/microsoft-dataverse-connections' import { useConnectOAuthService, useOAuthConnections, } from '@/hooks/queries/oauth/oauth-connections' +import { useOAuthCredentialDetail } from '@/hooks/queries/oauth/oauth-credentials' import { useOAuthReturnRouter } from '@/hooks/use-oauth-return' const logger = createLogger('ConnectedCredentialDetail') @@ -80,7 +86,19 @@ export function ConnectedCredentialDetail({ () => credentials.find((c) => c.id === credentialId) ?? null, [credentials, credentialId] ) - + const isDataverseCredential = + credential?.type === 'oauth' && credential.providerId === 'microsoft-dataverse' + const dataverseCredentialQuery = useOAuthCredentialDetail( + isDataverseCredential ? credentialId : undefined, + undefined, + isDataverseCredential + ) + const dataverseBinding = useMicrosoftDataverseCredentialBinding({ + isPending: dataverseCredentialQuery.isPending, + providerId: credential?.type === 'oauth' ? (credential.providerId ?? undefined) : undefined, + scopes: dataverseCredentialQuery.data?.[0]?.scopes, + }) + const connectMicrosoftDataverseOAuthService = useConnectMicrosoftDataverseOAuthService() const isAdmin = credential?.role === 'admin' const [showDeleteConfirmDialog, setShowDeleteConfirmDialog] = useState(false) @@ -111,6 +129,13 @@ export function ConnectedCredentialDetail({ const handleReconnectOAuth = async () => { if (!credential || credential.type !== 'oauth' || !credential.providerId || !workspaceId) return try { + if (isDataverseCredential) { + assertMicrosoftDataverseReconnectAvailable({ + bindingState: dataverseBinding.state, + credentialQueryFailed: dataverseCredentialQuery.isError, + }) + } + const draft = await createDraft.mutateAsync({ workspaceId, providerId: credential.providerId, @@ -132,11 +157,19 @@ export function ConnectedCredentialDetail({ requestedAt: Date.now(), }) - await connectOAuthService.mutateAsync({ - providerId: credential.providerId, - callbackURL: window.location.href, - draftId: draft.draftId, - }) + if (dataverseBinding.state === 'bound' && dataverseBinding.environmentUrl) { + await connectMicrosoftDataverseOAuthService.mutateAsync({ + callbackURL: window.location.href, + draftId: draft.draftId, + environmentUrl: dataverseBinding.environmentUrl, + }) + } else { + await connectOAuthService.mutateAsync({ + providerId: credential.providerId, + callbackURL: window.location.href, + draftId: draft.draftId, + }) + } } catch (error: unknown) { toast.error("Couldn't start reconnect", { description: getErrorMessage(error, 'Please try again in a moment.'), @@ -180,7 +213,11 @@ export function ConnectedCredentialDetail({ ? () => setReconnectOpen(true) : handleReconnectOAuth } - disabled={connectOAuthService.isPending} + disabled={ + connectOAuthService.isPending || + connectMicrosoftDataverseOAuthService.isPending || + dataverseBinding.isPending + } leftIcon={display?.icon ?? undefined} > Reconnect 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 e253143adf0..6978ecf4f94 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -1,14 +1,7 @@ 'use client' import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react' -import { - Badge, - ChipCombobox, - ChipConfirmModal, - chipContentLabelClass, - cn, - OverflowText, -} from '@sim/emcn' +import { Badge, ChipCombobox, ChipConfirmModal, chipContentLabelClass, cn } from '@sim/emcn' import { ChevronDown, ChevronUp, @@ -754,13 +747,7 @@ export function Document({ setSelectedChunks(new Set()) }} overlayLabel={enabledDisplayLabel} - overlayContent={ - - } + overlayContent={enabledDisplayLabel} showAllOption allOptionLabel='All' className='w-full' diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 8be63a40c5c..7cdc892da44 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -1228,8 +1228,8 @@ export default function Logs() { filterTags={filterTags} /> {isDashboardView ? ( -
-
+
+
)} - + {statusDisplayLabel} } showAllOption @@ -1500,11 +1496,7 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr {selectedWorkflow && ( )} - + {workflowDisplayLabel} } searchable @@ -1524,13 +1516,7 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr onMultiSelectChange={setFolderIds} placeholder='All folders' overlayLabel={folderDisplayLabel} - overlayContent={ - - } + overlayContent={folderDisplayLabel} searchable searchPlaceholder='Search folders...' showAllOption @@ -1548,13 +1534,7 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr onMultiSelectChange={setTriggers} placeholder='All triggers' overlayLabel={triggerDisplayLabel} - overlayContent={ - - } + overlayContent={triggerDisplayLabel} searchable searchPlaceholder='Search triggers...' showAllOption @@ -1572,13 +1552,7 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr onChange={handleTimeRangeChange} placeholder='All time' overlayLabel={timeDisplayLabel} - overlayContent={ - - } + overlayContent={timeDisplayLabel} className='w-full' maxHeight={320} /> diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.test.ts index f6ad312fa57..0f31daaed49 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.test.ts @@ -74,7 +74,10 @@ describe('credential-groups prefetch', () => { createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', } - mockExecute.mockResolvedValue({ credentialGroups: [{ ...credentialGroup, internal: true }] }) + mockExecute.mockResolvedValue({ + credentialGroups: [{ ...credentialGroup, internal: true }], + availableProviders: ['gmail'], + }) const queryClient = new QueryClient() await SECTION_PREFETCHERS['credential-groups']?.(queryClient, { @@ -86,7 +89,15 @@ describe('credential-groups prefetch', () => { principal: { kind: 'session', userId: 'u1', sessionId: 's1' }, input: { workspaceId: 'w1' }, }) - expect(queryClient.getQueryData(credentialGroupKeys.list('w1'))).toEqual([credentialGroup]) + /** + * The whole response envelope, not just the groups array: this key is shared with + * `fetchCredentialGroupSettings`, and seeding it with a narrower shape would leave every + * consumer reading an empty list for as long as the hydrated value stayed fresh. + */ + expect(queryClient.getQueryData(credentialGroupKeys.list('w1'))).toEqual({ + credentialGroups: [credentialGroup], + availableProviders: ['gmail'], + }) }) it('leaves the cache empty when the use case denies the viewer', async () => { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts index 91c58850cc2..596cb24a1ac 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts @@ -39,7 +39,13 @@ async function prefetchCredentialGroups( principal, input: { workspaceId }, }) - return listCredentialGroupsContract.response.schema.parse(result).credentialGroups + /** + * Hydrates the whole response envelope, matching what `fetchCredentialGroupSettings` caches + * under this key. Narrowing to the groups array here would seed the shared entry with a + * shape its consumers do not read, so every one of them would see an empty list until the + * first refetch replaced it. + */ + return listCredentialGroupsContract.response.schema.parse(result) }, staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME, }) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts index c95046d925f..7695deefb6c 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts @@ -116,6 +116,22 @@ export const credentialGroupTabUrlKeys = { clearOnDefault: true, } as const +/** + * Filters the account types offered inside a credential group's detail view. Separate from the + * settings-wide search so filtering the picker does not follow the user back out to the list of + * groups, where the same term would usually match nothing. + */ +export const credentialGroupProviderSearchParam = { + key: 'credential-group-provider', + parser: parseAsString.withDefault(''), +} as const + +/** A transient picker filter: no back-stack entry, and absent from the URL when empty. */ +export const credentialGroupProviderSearchUrlKeys = { + history: 'replace', + clearOnDefault: true, +} as const + /** * `group-tab` is the active tab inside the deep-linked permission-group detail * view, so a shared `group-id` link can land on the same tab (mirrors diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field/secret-value-field.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field/secret-value-field.test.tsx new file mode 100644 index 00000000000..f84f35f685f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field/secret-value-field.test.tsx @@ -0,0 +1,86 @@ +/** + * @vitest-environment jsdom + */ +import { act, type ComponentProps } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@sim/emcn', () => ({ + ChipInput: ({ + inputClassName, + ...props + }: ComponentProps<'input'> & { inputClassName?: string }) => ( + + ), +})) + +import { SecretValueField } from '@/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field/secret-value-field' + +let container: HTMLDivElement +let root: Root + +function input(): HTMLInputElement { + const field = container.querySelector('input') + if (!field) throw new Error('Secret value field did not render') + return field +} + +beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +describe('SecretValueField', () => { + it('preserves the caret position when revealing an editable value', () => { + const value = 'editable-secret-value' + act(() => root.render()) + + expect(input().value).toBe(value) + expect(input().className).toContain('[-webkit-text-security:disc]') + + input().setSelectionRange(15, 15) + act(() => input().focus()) + + expect(input().value).toBe(value) + expect(input().selectionStart).toBe(15) + expect(input().readOnly).toBe(false) + expect(input().className).not.toContain('[-webkit-text-security:disc]') + + act(() => input().blur()) + expect(input().value).toBe(value) + expect(input().className).toContain('[-webkit-text-security:disc]') + }) + + it('lets a read-only viewer reveal an allowed value without making it editable', () => { + act(() => root.render()) + + expect(input().readOnly).toBe(true) + expect(input().value).toBe('•'.repeat(10)) + + act(() => input().focus()) + + expect(input().value).toBe('visible-secret') + expect(input().readOnly).toBe(true) + }) + + it('never places a withheld value in the field', () => { + act(() => root.render()) + + expect(input().value).toBe('•'.repeat(10)) + act(() => input().focus()) + expect(input().value).toBe('•'.repeat(10)) + }) + + it('keeps an empty editable value empty while unfocused', () => { + act(() => root.render()) + + expect(input().value).toBe('') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field/secret-value-field.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field/secret-value-field.tsx index 9c5e61c8df1..0e8dcfdacb2 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field/secret-value-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field/secret-value-field.tsx @@ -1,16 +1,12 @@ 'use client' -import type { ComponentProps, CSSProperties } from 'react' +import type { ComponentProps } from 'react' import { useState } from 'react' import { ChipInput } from '@sim/emcn' const BULLET = '\u2022' -/** - * Viewers always see this many bullets regardless of the real value, which the - * server withholds (empty string) for non-admins. A fixed length also avoids - * leaking the secret's length. - */ +/** Fixed-length masks avoid disclosing the secret's length. */ const VIEWER_MASK_LENGTH = 10 type SecretValueFieldProps = Omit< @@ -20,11 +16,11 @@ type SecretValueFieldProps = Omit< value: string onChange?: (value: string) => void /** - * Whether the caller may reveal (on focus) and edit the value. When `false` - * the real value is never shown — only a fixed-length mask — and the field is - * read-only (e.g. a non-admin viewer). + * Whether the caller may edit the value. Editors can always reveal it. */ canEdit?: boolean + /** Whether a read-only caller may reveal the value on focus. */ + canReveal?: boolean /** Render the real value without masking, e.g. an overridden/conflicted field. */ unmasked?: boolean /** Force read-only even when {@link canEdit} is true (e.g. a conflicted field). */ @@ -33,9 +29,9 @@ type SecretValueFieldProps = Omit< /** * The single source of truth for displaying an environment-variable value: - * masks the value with bullets while unfocused, reveals it on focus for editors, - * and keeps the field read-only (masked) for viewers who can't edit. Shared by - * the secrets list and the secret detail page so masking never diverges. + * masks revealable values while unfocused, reveals them on focus, and grants + * editing independently. Callers without reveal access receive a fixed-length + * mask. Shared by the secrets list and secret detail page. * * Rendered as a {@link ChipInput}; the chip chrome carries the canonical 30px * chip-field height, and the caller's `className` only positions it (e.g. @@ -46,6 +42,7 @@ export function SecretValueField({ value, onChange, canEdit = true, + canReveal = false, unmasked = false, readOnly = false, onFocus, @@ -56,12 +53,13 @@ export function SecretValueField({ }: SecretValueFieldProps) { const [focused, setFocused] = useState(false) const editable = canEdit && !readOnly - const maskActive = canEdit && !unmasked && !focused - const displayValue = canEdit ? value : BULLET.repeat(VIEWER_MASK_LENGTH) - - const mergedStyle: CSSProperties | undefined = maskActive - ? ({ ...style, WebkitTextSecurity: 'disc' } as CSSProperties) - : style + const revealable = canEdit || canReveal + const maskActive = revealable && !unmasked && !focused + const visuallyMaskEditableValue = editable && maskActive + const displayValue = + !revealable || (!editable && maskActive && value.length > 0) + ? BULLET.repeat(VIEWER_MASK_LENGTH) + : value return ( { if (editable) onChange?.(event.target.value) }} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx index 6877f068ded..b9911611414 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx @@ -194,6 +194,7 @@ interface WorkspaceVariableRowProps { pendingKeyValue: string hasCredential: boolean canEdit: boolean + canReveal: boolean /** Renaming creates a new key + deletes the old, so it also needs create access. */ canRename: boolean onRenameStart: (key: string) => void @@ -211,6 +212,7 @@ function WorkspaceVariableRow({ pendingKeyValue, hasCredential, canEdit, + canReveal, canRename, onRenameStart, onPendingKeyChange, @@ -252,6 +254,7 @@ function WorkspaceVariableRow({ value={value} onChange={(next) => onValueChange(envKey, next)} canEdit={canEdit} + canReveal={canReveal} name={`workspace_env_value_${envKey}_${autofillSalt}`} /> { const cred = workspaceEnvKeyToCredential.get(key) const canEditRow = canCreateWorkspaceSecret && cred?.role === 'admin' + const canRevealRow = + isWorkspaceAdmin || cred?.role === 'admin' || Boolean(cred?.unredacted) return ( ) })} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx index 3827a740825..65e271083cb 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx @@ -248,6 +248,7 @@ export function SecretDetail({ workspaceId, credentialId }: SecretDetailProps) { value={valueField.value} onChange={valueField.setValue} canEdit={valueField.canEdit} + canReveal={!isPersonal && credential.unredacted} unmasked={valueField.isConflicted} readOnly={valueField.isConflicted} placeholder='Enter value' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/columns-menu/columns-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/columns-menu/columns-menu.tsx index a7bbb0f9f95..d157ea04665 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/columns-menu/columns-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/columns-menu/columns-menu.tsx @@ -7,6 +7,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuItemLabel, DropdownMenuTrigger, } from '@sim/emcn' import { Columns3, Eye, EyeOff } from '@sim/emcn/icons' @@ -166,11 +167,10 @@ function ColumnToggleRow({ label, visible, partial, indented, onToggle }: Column )} /> - - {label} - + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.tsx index 8bb600b66d4..a0527fd593e 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.tsx @@ -119,7 +119,11 @@ export const ViewsMenu = memo(function ViewsMenu({ onMouseLeave={scheduleClose} className={cn(chipVariants(), 'max-w-[220px]')} > - {label} + diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index 3c30e8850d6..ec59b45f973 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ComboboxOption } from '@sim/emcn' -import { ChipCombobox, ChipConfirmModal, OverflowText, Plus, toast, Upload } from '@sim/emcn' +import { ChipCombobox, ChipConfirmModal, Plus, toast, Upload } from '@sim/emcn' import { Columns3, FolderPlus, Pencil, Rows3, Table as TableIcon, Trash } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' @@ -693,13 +693,7 @@ export function Tables() { multiSelectValues={rowCountFilter} onMultiSelectChange={setRowCountFilter} overlayLabel={rowCountDisplayLabel} - overlayContent={ - - } + overlayContent={rowCountDisplayLabel} showAllOption allOptionLabel='All' className='w-full' @@ -714,13 +708,7 @@ export function Tables() { multiSelectValues={ownerFilter} onMultiSelectChange={setOwnerFilter} overlayLabel={ownerDisplayLabel} - overlayContent={ - - } + overlayContent={ownerDisplayLabel} searchable searchPlaceholder='Search members...' showAllOption diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/components/comparison-table/comparison-data.ts b/apps/sim/app/workspace/[workspaceId]/upgrade/components/comparison-table/comparison-data.ts index 2f3581eb9b8..0eb4e368a5e 100644 --- a/apps/sim/app/workspace/[workspaceId]/upgrade/components/comparison-table/comparison-data.ts +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/components/comparison-table/comparison-data.ts @@ -76,11 +76,11 @@ export const COMPARISON_SECTIONS: ComparisonSection[] = [ title: 'Credits & pricing', rows: [ { - label: 'Monthly credits', + label: 'Included credits', values: [ - formatCredits(DEFAULT_FREE_CREDITS * CREDITS_PER_DOLLAR), - formatCredits(PRO_TIER.credits), - formatCredits(MAX_TIER.credits), + `${formatCredits(DEFAULT_FREE_CREDITS * CREDITS_PER_DOLLAR)} one-time`, + `${formatCredits(PRO_TIER.credits)}/month`, + `${formatCredits(MAX_TIER.credits)}/month`, 'Custom', ], }, @@ -88,8 +88,8 @@ export const COMPARISON_SECTIONS: ComparisonSection[] = [ label: 'Weekly refresh', values: [ false, - `+${formatCredits(PRO_TIER.weeklyRefreshCredits)}`, - `+${formatCredits(MAX_TIER.weeklyRefreshCredits)}`, + `+${formatCredits(PRO_TIER.weeklyRefreshCredits)}/week`, + `+${formatCredits(MAX_TIER.weeklyRefreshCredits)}/week`, 'Custom', ], }, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx index ab8d0f9f7d1..27ff29ba633 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx @@ -1,7 +1,7 @@ 'use client' import { useMemo } from 'react' -import { ChipCombobox, Combobox, type ComboboxOptionGroup, cn, OverflowText } from '@sim/emcn' +import { ChipCombobox, Combobox, type ComboboxOptionGroup, cn } from '@sim/emcn' import { useShallow } from 'zustand/react/shallow' import { type FlattenOutputsBlockInput, @@ -270,13 +270,7 @@ export function OutputSelect({ onMultiSelectChange={onOutputSelect} placeholder={selectedDisplayText} overlayLabel={selectedDisplayText} - overlayContent={ - - } + overlayContent={selectedDisplayText} disabled={disabled || workflowOutputs.length === 0} align={align} maxHeight={maxHeight} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx index bb7bd1adcbb..d261b012e45 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx @@ -1,7 +1,7 @@ 'use client' import { useCallback, useEffect, useMemo, useState } from 'react' -import { Button, Combobox, type ComboboxOptionGroup } from '@sim/emcn' +import { Chip, Combobox, type ComboboxOptionGroup } from '@sim/emcn' import { Key, SquareArrowUpRight } from '@sim/emcn/icons' import { useParams } from 'next/navigation' import { consumeOAuthReturnContext, writeOAuthReturnContext } from '@/lib/credentials/client-state' @@ -19,6 +19,7 @@ import { type ServiceAccountProviderId, useServiceAccountConnectTarget, } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal' +import { resolveMicrosoftDataverseCredentialPolicy } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/microsoft-dataverse-policy' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight' import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' @@ -59,12 +60,12 @@ export function CredentialSelector({ const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId) const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id) - const requiredScopes = subBlock.requiredScopes || [] const label = subBlock.placeholder || 'Select credential' const serviceId = subBlock.serviceId || '' const isAllCredentials = !serviceId + const effectiveProviderId = getProviderIdFromServiceId(serviceId) as OAuthProvider - const { depsSatisfied, dependsOn } = useDependsOnGate(blockId, subBlock, { + const { depsSatisfied, dependsOn, dependencyValues } = useDependsOnGate(blockId, subBlock, { disabled, isPreview, previewContextValues, @@ -76,10 +77,6 @@ export function CredentialSelector({ const effectiveValue = isPreview && previewValue !== undefined ? previewValue : storeValue const selectedId = typeof effectiveValue === 'string' ? effectiveValue : '' - const effectiveProviderId = useMemo( - () => getProviderIdFromServiceId(serviceId) as OAuthProvider, - [serviceId] - ) const provider = effectiveProviderId const isTriggerMode = subBlock.mode === 'trigger' || subBlock.mode === 'trigger-advanced' @@ -182,6 +179,17 @@ export function CredentialSelector({ const displayValue = isEditing ? editingValue : resolvedLabel + const dataversePolicy = resolveMicrosoftDataverseCredentialPolicy({ + dependsOn, + environmentUrl: dependencyValues.environmentUrl, + hasSelectedCredential: Boolean(selectedCredential), + providerId: effectiveProviderId, + selectedCredentialScopes: selectedCredential?.scopes, + }) + const requiredScopes = dataversePolicy.applies + ? dataversePolicy.requiredScopes + : (subBlock.requiredScopes ?? []) + const refetch = useCallback( () => (isAllCredentials ? refetchAllCredentials() : refetchCredentials()), [isAllCredentials, refetchAllCredentials, refetchCredentials] @@ -200,11 +208,11 @@ export function CredentialSelector({ const missingRequiredScopes = hasOAuthSelection ? getMissingRequiredScopes(selectedCredential!, requiredScopes || []) : [] - const needsUpdate = - hasOAuthSelection && !isServiceAccount && - missingRequiredScopes.length > 0 && + (dataversePolicy.hasInvalidEnvironment || + (hasOAuthSelection && + (missingRequiredScopes.length > 0 || dataversePolicy.requiresSeparateCredential))) && !effectiveDisabled && !isPreview && !credentialsLoading @@ -465,27 +473,33 @@ export function CredentialSelector({
- Additional permissions required + {dataversePolicy.message}
- + {!dataversePolicy.hasInvalidEnvironment && ( + { + if (dataversePolicy.requiresSeparateCredential) { + setShowConnectModal(true) + return + } + writeOAuthReturnContext({ + origin: 'workflow', + workflowId: activeWorkflowId || '', + displayName: selectedCredential?.name ?? getProviderName(provider), + providerId: effectiveProviderId, + preCount: credentials.filter((c) => c.type !== 'service_account').length, + workspaceId, + reconnect: true, + requestedAt: Date.now(), + }) + setShowOAuthModal(true) + }} + > + {dataversePolicy.actionLabel} + + )}
)} @@ -498,9 +512,15 @@ export function CredentialSelector({ provider={provider} serviceId={serviceId} providerId={effectiveProviderId} - requiredScopes={getCanonicalScopesForProvider(effectiveProviderId)} + requiredScopes={ + dataversePolicy.applies + ? requiredScopes + : getCanonicalScopesForProvider(effectiveProviderId) + } workspaceId={workspaceId} workflowId={activeWorkflowId || ''} + requireDataverseEnvironment={dataversePolicy.applies} + dataverseEnvironmentUrl={dataversePolicy.environmentUrl} /> )} @@ -516,7 +536,11 @@ export function CredentialSelector({ }} provider={provider} toolName={getProviderName(provider)} - requiredScopes={getCanonicalScopesForProvider(effectiveProviderId)} + requiredScopes={ + dataversePolicy.applies + ? requiredScopes + : getCanonicalScopesForProvider(effectiveProviderId) + } newScopes={missingRequiredScopes} serviceId={serviceId} // A reauthorize must return to the authorization server that issued @@ -528,6 +552,8 @@ export function CredentialSelector({ credentialId: selectedCredential.id, displayName: selectedCredential.name, }} + requireDataverseEnvironment={dataversePolicy.applies} + dataverseEnvironmentUrl={dataversePolicy.environmentUrl} /> )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/microsoft-dataverse-policy.test.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/microsoft-dataverse-policy.test.ts new file mode 100644 index 00000000000..9f0d6aa8e92 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/microsoft-dataverse-policy.test.ts @@ -0,0 +1,108 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { getMicrosoftDataverseRequiredScope } from '@/lib/oauth/microsoft-dataverse' +import { resolveMicrosoftDataverseCredentialPolicy } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/microsoft-dataverse-policy' + +const ENVIRONMENT = 'https://contoso.crm.dynamics.com' +const CANONICAL_ENVIRONMENT = 'https://contoso.api.crm.dynamics.com' + +function resolve(scopes?: string[], environmentUrl: unknown = ENVIRONMENT) { + return resolveMicrosoftDataverseCredentialPolicy({ + dependsOn: ['environmentUrl'], + environmentUrl, + hasSelectedCredential: scopes !== undefined, + providerId: 'microsoft-dataverse', + selectedCredentialScopes: scopes, + }) +} + +describe('resolveMicrosoftDataverseCredentialPolicy', () => { + it('does not apply to ordinary providers or the released Dataverse block', () => { + expect( + resolveMicrosoftDataverseCredentialPolicy({ + dependsOn: [], + environmentUrl: ENVIRONMENT, + hasSelectedCredential: true, + providerId: 'microsoft-dataverse', + selectedCredentialScopes: [], + }) + ).toMatchObject({ applies: false, requiresSeparateCredential: false }) + expect( + resolveMicrosoftDataverseCredentialPolicy({ + dependsOn: ['environmentUrl'], + environmentUrl: ENVIRONMENT, + hasSelectedCredential: true, + providerId: 'salesforce', + selectedCredentialScopes: [], + }) + ).toMatchObject({ applies: false, requiresSeparateCredential: false }) + }) + + it('accepts a credential bound to the selected environment', () => { + expect(resolve([getMicrosoftDataverseRequiredScope(ENVIRONMENT)])).toMatchObject({ + applies: true, + bindingState: 'matching', + environmentUrl: CANONICAL_ENVIRONMENT, + requiredScopes: [getMicrosoftDataverseRequiredScope(ENVIRONMENT)], + requiresSeparateCredential: false, + }) + }) + + it('matches a credential across documented environment and Web API host aliases', () => { + expect( + resolve([getMicrosoftDataverseRequiredScope(CANONICAL_ENVIRONMENT)], ENVIRONMENT) + ).toMatchObject({ + bindingState: 'matching', + environmentUrl: CANONICAL_ENVIRONMENT, + requiresSeparateCredential: false, + }) + }) + + it.each([ + ['legacy', ['https://dynamics.microsoft.com/user_impersonation'], 'unbound'], + [ + 'different environment', + [getMicrosoftDataverseRequiredScope('https://other.crm.dynamics.com')], + 'different', + ], + [ + 'ambiguous', + [ + getMicrosoftDataverseRequiredScope(ENVIRONMENT), + getMicrosoftDataverseRequiredScope('https://other.crm.dynamics.com'), + ], + 'invalid', + ], + ])('requires a separate credential for a %s binding', (_label, scopes, bindingState) => { + expect(resolve(scopes)).toMatchObject({ + actionLabel: 'Connect matching account', + bindingState, + requiresSeparateCredential: true, + }) + }) + + it('fails closed on an invalid requested environment without a credential', () => { + const policy = resolve(undefined, 'https://evil.example') + expect(policy).toMatchObject({ + applies: true, + bindingState: null, + hasInvalidEnvironment: true, + requiredScopes: [], + requiresSeparateCredential: false, + }) + expect(policy.environmentUrl).toBeUndefined() + }) + + it('surfaces an invalid requested environment when a credential is already selected', () => { + expect(resolve([], 'https://evil.example')).toMatchObject({ + applies: true, + bindingState: 'invalid', + hasInvalidEnvironment: true, + message: 'Enter a valid Dynamics environment before selecting a credential', + requiredScopes: [], + requiresSeparateCredential: false, + }) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/microsoft-dataverse-policy.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/microsoft-dataverse-policy.ts new file mode 100644 index 00000000000..7ef2718bfb6 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/microsoft-dataverse-policy.ts @@ -0,0 +1,84 @@ +import { + classifyMicrosoftDataverseCredentialEnvironment, + getMicrosoftDataverseRequiredScope, + MICROSOFT_DATAVERSE_PROVIDER_ID, + type MicrosoftDataverseCredentialEnvironmentState, + normalizeMicrosoftDataverseEnvironmentUrl, +} from '@/lib/oauth/microsoft-dataverse' + +interface ResolveMicrosoftDataverseCredentialPolicyParams { + dependsOn: readonly string[] + environmentUrl: unknown + hasSelectedCredential: boolean + providerId: string + selectedCredentialScopes?: readonly string[] +} + +export interface MicrosoftDataverseCredentialPolicy { + actionLabel: string + applies: boolean + bindingState: MicrosoftDataverseCredentialEnvironmentState | null + environmentUrl?: string + hasInvalidEnvironment: boolean + message: string + requiredScopes: string[] + requiresSeparateCredential: boolean +} + +const DEFAULT_POLICY: MicrosoftDataverseCredentialPolicy = { + actionLabel: 'Update access', + applies: false, + bindingState: null, + hasInvalidEnvironment: false, + message: 'Additional permissions required', + requiredScopes: [], + requiresSeparateCredential: false, +} + +export function resolveMicrosoftDataverseCredentialPolicy({ + dependsOn, + environmentUrl, + hasSelectedCredential, + providerId, + selectedCredentialScopes, +}: ResolveMicrosoftDataverseCredentialPolicyParams): MicrosoftDataverseCredentialPolicy { + const applies = + providerId === MICROSOFT_DATAVERSE_PROVIDER_ID && dependsOn.includes('environmentUrl') + if (!applies) return DEFAULT_POLICY + + let normalizedEnvironmentUrl: string + try { + normalizedEnvironmentUrl = normalizeMicrosoftDataverseEnvironmentUrl(environmentUrl) + } catch { + return { + ...DEFAULT_POLICY, + applies: true, + bindingState: hasSelectedCredential ? 'invalid' : null, + hasInvalidEnvironment: true, + message: 'Enter a valid Dynamics environment before selecting a credential', + } + } + + const requiredScopes = [getMicrosoftDataverseRequiredScope(normalizedEnvironmentUrl)] + const bindingState = hasSelectedCredential + ? classifyMicrosoftDataverseCredentialEnvironment( + selectedCredentialScopes, + normalizedEnvironmentUrl + ) + : null + const requiresSeparateCredential = + bindingState === 'unbound' || bindingState === 'different' || bindingState === 'invalid' + + return { + actionLabel: requiresSeparateCredential ? 'Connect matching account' : 'Update access', + applies: true, + bindingState, + environmentUrl: normalizedEnvironmentUrl, + hasInvalidEnvironment: false, + message: requiresSeparateCredential + ? 'This credential is not connected to this Dynamics environment' + : 'Additional permissions required', + requiredScopes, + requiresSeparateCredential, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx index a16ea864787..57cfcebffce 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx @@ -11,6 +11,7 @@ import { handleKeyboardActivation, Input, Label, + OverflowText, Tooltip, } from '@sim/emcn' import { @@ -375,14 +376,11 @@ function ConnectionsSection({ }} > - - {connection.blockName} - + {hasFields && ( V
- - Variables - + E
- - Secrets - + - - {subflowName} - + {onClose && (
diff --git a/packages/emcn/src/components/combobox/combobox.dom.test.tsx b/packages/emcn/src/components/combobox/combobox.dom.test.tsx index ac9d87b9114..84a68376a3b 100644 --- a/packages/emcn/src/components/combobox/combobox.dom.test.tsx +++ b/packages/emcn/src/components/combobox/combobox.dom.test.tsx @@ -66,8 +66,13 @@ describe('Combobox onOpenChange', () => { /> ) - const overflowLabel = trigger().querySelector('[data-overflow-text]') - expect(overflowLabel?.textContent).toBe('2 selected') + const overflowLabels = trigger().querySelectorAll('[data-overflow-text]') + expect(overflowLabels).toHaveLength(2) + expect([...overflowLabels].map(({ textContent }) => textContent)).toEqual([ + '2 selected', + '2 selected', + ]) + expect([...overflowLabels].every(({ className }) => !className.includes('truncate'))).toBe(true) }) it('reports the open a trigger click causes', () => { diff --git a/packages/emcn/src/components/combobox/combobox.tsx b/packages/emcn/src/components/combobox/combobox.tsx index a2298f187b9..196adff522a 100644 --- a/packages/emcn/src/components/combobox/combobox.tsx +++ b/packages/emcn/src/components/combobox/combobox.tsx @@ -638,6 +638,8 @@ const Combobox = memo( }, [effectiveHighlightedIndex]) const SelectedIcon = selectedOption?.icon + const visualLabel = + overlayLabel ?? multiSelectLabel ?? (selectedOption ? selectedOption.label : placeholder) return ( @@ -722,11 +724,7 @@ const Combobox = memo( onKeyDown={handleKeyDown} > {overlayContent && (
-
{overlayContent}
+ + {overlayContent} +
)}
diff --git a/packages/emcn/src/components/dropdown-menu/dropdown-menu.test.tsx b/packages/emcn/src/components/dropdown-menu/dropdown-menu.test.tsx index f683ddf5141..94145633802 100644 --- a/packages/emcn/src/components/dropdown-menu/dropdown-menu.test.tsx +++ b/packages/emcn/src/components/dropdown-menu/dropdown-menu.test.tsx @@ -16,6 +16,7 @@ import { DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, + DropdownMenuItemLabel, DropdownMenuTrigger, } from './dropdown-menu' @@ -102,7 +103,7 @@ describe('menu row labels', () => { expect(link.querySelector('span')).toBeNull() }) - it('leaves a label the consumer already wrapped as a single box', () => { + it('hard-clips a consumer-provided rich span without adding an ellipsis', () => { openMenu( Add 2 rows to Chat @@ -110,6 +111,21 @@ describe('menu row labels', () => { ) expect(row().querySelectorAll('span')).toHaveLength(1) - expect(row().className).toContain('[&>span:not([data-overflow-text])]:truncate') + expect(row().className).toContain('[&>span:not([data-overflow-text])]:text-clip') + expect(row().className).not.toContain('truncate') + }) + + it('uses the canonical fade-only label beside an icon', () => { + openMenu( + + + + + ) + + const label = row().querySelector('[data-overflow-text]') + expect(label?.textContent).toBe('A long workflow label') + expect(label?.className).toContain('text-clip') + expect(label?.className).not.toContain('truncate') }) }) diff --git a/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx b/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx index 0198bda51c5..f5bd88c565e 100644 --- a/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx +++ b/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx @@ -26,7 +26,7 @@ import { Check, ChevronRight, Circle, Search } from '../../icons' import { cn } from '../../lib/cn' import { chipContentGap, chipFieldSurfaceClass } from '../chip/chip-chrome' import { InsideModalContext } from '../modal/modal' -import { OverflowText } from '../overflow-text/overflow-text' +import { OverflowText, type OverflowTextProps } from '../overflow-text/overflow-text' const ANIMATION_CLASSES = 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=open]:animate-in motion-reduce:animate-none' @@ -81,13 +81,29 @@ const MENU_ROW_SELECTED_CLASS = * {@link withOverflowLabel}. */ const MENU_ROW_SINGLE_LINE_CLASS = - 'whitespace-nowrap [&>span]:min-w-0 [&>span:not([data-overflow-text])]:truncate' + 'whitespace-nowrap [&>span]:min-w-0 [&>span:not([data-overflow-text])]:overflow-hidden [&>span:not([data-overflow-text])]:text-clip' + +export type DropdownMenuItemLabelProps = Omit + +/** Canonical fade-only label for a menu row with icons, checks, or actions. */ +const DropdownMenuItemLabel = React.memo(function DropdownMenuItemLabel({ + className, + ...props +}: DropdownMenuItemLabelProps) { + return ( + + ) +}) /** * Wraps a row's bare text children in a truncating box so a label wider than * the menu uses the platform overflow treatment rather than being cut mid-word - * at the surface edge. Consumer-provided direct `` labels retain an - * ellipsis fallback; a canonical {@link OverflowText} owns its fade and tooltip. + * at the surface edge. Consumer-provided rich spans get a fade-free hard clip; + * human labels with adjacent icons/actions use {@link DropdownMenuItemLabel}. * * Adjacent text is coalesced into a single box: a row is a flex container, so * wrapping `Insert row {n}` as two boxes would make them two flex items and @@ -100,9 +116,9 @@ function withOverflowLabel(children: React.ReactNode): React.ReactNode { const flushText = () => { if (text.length === 0) return rebuilt.push( - + {text} - +
) text = [] } @@ -531,6 +547,7 @@ export { DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, + DropdownMenuItemLabel, DropdownMenuItemAction, DropdownMenuCheckboxItem, DropdownMenuRadioItem, diff --git a/packages/emcn/src/components/index.ts b/packages/emcn/src/components/index.ts index f0830e272ad..736dce6dd56 100644 --- a/packages/emcn/src/components/index.ts +++ b/packages/emcn/src/components/index.ts @@ -113,6 +113,8 @@ export { DropdownMenuGroup, DropdownMenuItem, DropdownMenuItemAction, + DropdownMenuItemLabel, + type DropdownMenuItemLabelProps, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup, @@ -166,6 +168,7 @@ export { export { OverflowText, type OverflowTextProps, + overflowTextClipClass, overflowTextFadeClass, } from './overflow-text/overflow-text' export { diff --git a/packages/emcn/src/components/overflow-text/overflow-text.test.tsx b/packages/emcn/src/components/overflow-text/overflow-text.test.tsx index d057c1019ab..294797f9356 100644 --- a/packages/emcn/src/components/overflow-text/overflow-text.test.tsx +++ b/packages/emcn/src/components/overflow-text/overflow-text.test.tsx @@ -4,7 +4,8 @@ import { act, StrictMode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { OverflowText } from './overflow-text' +import { cn } from '../../lib/cn' +import { OverflowText, overflowTextFadeClass } from './overflow-text' let host: HTMLDivElement let root: Root @@ -75,6 +76,13 @@ function setWidths(element: HTMLElement, clientWidth: number, scrollWidth: numbe } describe('OverflowText', () => { + it('makes the shared fade treatment override an accidental ellipsis', () => { + const className = cn('truncate', overflowTextFadeClass) + + expect(className).toContain('text-clip') + expect(className).not.toContain('truncate') + }) + it('fades and reveals the full value only when clipped', () => { act(() => root.render() @@ -159,6 +167,27 @@ describe('OverflowText', () => { ) }) + it('opens from a focused menu item that uses roving tabindex', () => { + act(() => + root.render( +
+ +
+ ) + ) + const item = host.querySelector('[role="menuitem"]') + const label = host.querySelector('[data-overflow-text]') + if (!item || !label) throw new Error('Menu label did not render') + + setWidths(label, 80, 180) + vi.spyOn(item, 'matches').mockReturnValue(true) + act(() => item.focus()) + + expect(document.querySelector('[data-native-surface-overlay]')?.textContent).toBe( + 'A long menu label' + ) + }) + it('keeps decorated visible content out of the plain tooltip label', () => { act(() => root.render( diff --git a/packages/emcn/src/components/overflow-text/overflow-text.tsx b/packages/emcn/src/components/overflow-text/overflow-text.tsx index f845d49dc23..c87d5d105e5 100644 --- a/packages/emcn/src/components/overflow-text/overflow-text.tsx +++ b/packages/emcn/src/components/overflow-text/overflow-text.tsx @@ -10,9 +10,12 @@ import { useIsOverflowing, } from '../tooltip/tooltip' -/** Shared 18px trailing fade for measured special cases such as breadcrumb groups. */ +/** Complete fade-only clipping treatment for measured special cases. */ export const overflowTextFadeClass = - '[-webkit-mask-image:linear-gradient(to_right,black_calc(100%_-_18px),transparent)] [mask-image:linear-gradient(to_right,black_calc(100%_-_18px),transparent)]' + 'overflow-hidden text-clip whitespace-nowrap [-webkit-mask-image:linear-gradient(to_right,black_calc(100%_-_16px),transparent)] [mask-image:linear-gradient(to_right,black_calc(100%_-_16px),transparent)]' + +/** Fade-free clipping for externally measured labels and rich-content overflow exceptions. */ +export const overflowTextClipClass = 'block min-w-0 overflow-hidden text-clip whitespace-nowrap' export interface OverflowTextProps { /** Full text shown in the tooltip and used as the default visible content. */ @@ -51,7 +54,7 @@ export const OverflowText = memo(function OverflowText({ if (focusTarget !== 'nearest-interactive') return null return ( node.current?.closest( - 'a[href], button, [role="button"], [tabindex]:not([tabindex="-1"])' + 'a[href], button, [role="button"], [role^="menuitem"], [tabindex]:not([tabindex="-1"])' ) ?? null ) }, [focusTarget, node]) @@ -72,11 +75,7 @@ export const OverflowText = memo(function OverflowText({ {children ?? label} diff --git a/packages/emcn/src/components/tab-strip/tab-strip.tsx b/packages/emcn/src/components/tab-strip/tab-strip.tsx index 8ede2a176f6..2cb12802028 100644 --- a/packages/emcn/src/components/tab-strip/tab-strip.tsx +++ b/packages/emcn/src/components/tab-strip/tab-strip.tsx @@ -17,7 +17,7 @@ import { AnimatePresence, motion, useReducedMotion } from 'framer-motion' import { Plus, X } from '../../icons' import { cn } from '../../lib/cn' import { Button } from '../button/button' -import { overflowTextFadeClass } from '../overflow-text/overflow-text' +import { overflowTextClipClass, overflowTextFadeClass } from '../overflow-text/overflow-text' import { Tooltip } from '../tooltip/tooltip' const DRAG_EDGE_ZONE = 40 @@ -445,9 +445,9 @@ const Tab = forwardRef(function Tab( {tab.title} diff --git a/packages/emcn/src/components/toast/toast.test.tsx b/packages/emcn/src/components/toast/toast.test.tsx index 71420296b30..9458e336978 100644 --- a/packages/emcn/src/components/toast/toast.test.tsx +++ b/packages/emcn/src/components/toast/toast.test.tsx @@ -30,6 +30,7 @@ beforeEach(() => { class { disconnect(): void {} observe(): void {} + unobserve(): void {} } ) }) diff --git a/packages/testing/src/mocks/redis-config.mock.ts b/packages/testing/src/mocks/redis-config.mock.ts index 82c9a4d88d2..9e5e548f2c8 100644 --- a/packages/testing/src/mocks/redis-config.mock.ts +++ b/packages/testing/src/mocks/redis-config.mock.ts @@ -43,6 +43,27 @@ function getRedisConnectionDefaultsImpl(url?: string): { } } +/** + * Mirrors the real `describeRedisConnection` under its Redis-unavailable + * default: no client, no lifecycle history, and nothing derivable from an + * unset REDIS_URL. + */ +function describeRedisConnectionImpl() { + return { + status: 'no-client', + clientAgeMs: null, + readyAgeMs: null, + msSinceLastPingOk: null, + connects: 0, + reconnects: 0, + errors: 0, + lastErrorMessage: null, + hostKind: 'unknown' as const, + tls: false, + sniOverride: false, + } +} + /** * Controllable mock functions for `@/lib/core/config/redis`. * Default: `getConfiguredRedisUrl` and `getRedisClient` return `null` (tests @@ -69,6 +90,7 @@ export const redisConfigMockFns = { mockExtendLock: vi.fn().mockResolvedValue(true), mockCloseRedisConnection: vi.fn().mockResolvedValue(undefined), mockResetForTesting: vi.fn(), + mockDescribeRedisConnection: vi.fn(describeRedisConnectionImpl), } /** @@ -86,6 +108,9 @@ export function resetRedisConfigMock(): void { redisConfigMockFns.mockExtendLock.mockReset().mockResolvedValue(true) redisConfigMockFns.mockCloseRedisConnection.mockReset().mockResolvedValue(undefined) redisConfigMockFns.mockResetForTesting.mockReset() + redisConfigMockFns.mockDescribeRedisConnection + .mockReset() + .mockImplementation(describeRedisConnectionImpl) } /** @@ -107,4 +132,5 @@ export const redisConfigMock = { extendLock: redisConfigMockFns.mockExtendLock, closeRedisConnection: redisConfigMockFns.mockCloseRedisConnection, resetForTesting: redisConfigMockFns.mockResetForTesting, + describeRedisConnection: redisConfigMockFns.mockDescribeRedisConnection, } diff --git a/packages/workflow-renderer/src/lib/overflow-span.tsx b/packages/workflow-renderer/src/lib/overflow-span.tsx index 608b10b3434..d56a47211b7 100644 --- a/packages/workflow-renderer/src/lib/overflow-span.tsx +++ b/packages/workflow-renderer/src/lib/overflow-span.tsx @@ -1,10 +1,11 @@ import type { ReactNode } from 'react' -import { OverflowText } from '@sim/emcn' +import { cn, OverflowText } from '@sim/emcn' import type { CodePreview } from '../types' import { CodeHoverCard } from './code-hover-card' interface OverflowSpanProps { value: string + /** Layout and typography only; the renderer owns its overflow treatment. */ className: string /** Rich content shown instead of the plain value when this is clipped code. */ codePreview?: CodePreview @@ -26,7 +27,7 @@ interface OverflowSpanProps { export function OverflowSpan({ value, className, codePreview, children }: OverflowSpanProps) { if (codePreview) { return ( - + {children ?? value} ) diff --git a/packages/workflow-renderer/src/note/note-block-view.tsx b/packages/workflow-renderer/src/note/note-block-view.tsx index f5a7cb0926b..43f45cca2c0 100644 --- a/packages/workflow-renderer/src/note/note-block-view.tsx +++ b/packages/workflow-renderer/src/note/note-block-view.tsx @@ -1033,14 +1033,14 @@ export function NoteBlockView({ !isEnabled && 'opacity-50' )} > - + {renderMarkedName(name ?? '', nameSearchRange)} ) : ( {renderMarkedName(name ?? '', nameSearchRange)} diff --git a/packages/workflow-renderer/src/subflow/subflow-node-view.tsx b/packages/workflow-renderer/src/subflow/subflow-node-view.tsx index 2b407055cf4..1635c9da746 100644 --- a/packages/workflow-renderer/src/subflow/subflow-node-view.tsx +++ b/packages/workflow-renderer/src/subflow/subflow-node-view.tsx @@ -618,7 +618,7 @@ export function SubflowNodeView({ >
diff --git a/packages/workflow-renderer/src/workflow-block/canvas-sentence-view.tsx b/packages/workflow-renderer/src/workflow-block/canvas-sentence-view.tsx index c1ee95881d1..3d00d41211d 100644 --- a/packages/workflow-renderer/src/workflow-block/canvas-sentence-view.tsx +++ b/packages/workflow-renderer/src/workflow-block/canvas-sentence-view.tsx @@ -55,7 +55,7 @@ export function CanvasSentenceView({ segments, renderChip }: CanvasSentenceViewP const value = renderChip(segment.subBlockId) const placeholder = segment.noun ? ( - + ) : null const chip = value || placeholder diff --git a/packages/workflow-renderer/src/workflow-block/sub-block-row-view.tsx b/packages/workflow-renderer/src/workflow-block/sub-block-row-view.tsx index 112fd8eccae..a1b901b6aa1 100644 --- a/packages/workflow-renderer/src/workflow-block/sub-block-row-view.tsx +++ b/packages/workflow-renderer/src/workflow-block/sub-block-row-view.tsx @@ -58,7 +58,7 @@ export function SubBlockRowView({ @@ -70,7 +70,7 @@ export function SubBlockRowView({ @@ -99,7 +99,7 @@ export function SubBlockRowView({
) @@ -109,13 +109,13 @@ export function SubBlockRowView({
{displayValue !== undefined && ( diff --git a/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx b/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx index 87e53e5964b..ea54bae5346 100644 --- a/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx +++ b/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx @@ -950,7 +950,7 @@ export function WorkflowBlockView({ diff --git a/scripts/check-actorless-executor-operations.test.ts b/scripts/check-actorless-executor-operations.test.ts new file mode 100644 index 00000000000..778efbf612b --- /dev/null +++ b/scripts/check-actorless-executor-operations.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest' +import { + auditSubjectRequirements, + parseOperationPolicies, + referencedOperations, +} from './check-actorless-executor-operations' + +describe('operation policy parsing', () => { + it('reads an inline delegatedServices list', () => { + const policies = parseOperationPolicies(` + export const logOperations = { + list: defineWorkspaceOperation({ + id: 'logs.list', + delegatedServices: ['copilot', 'executor'], + }), + readStats: defineWorkspaceOperation({ + id: 'logs.read_stats', + delegatedServices: ['copilot'], + }), + } as const + `) + + expect(policies.get('logOperations.list')).toBe(true) + expect(policies.get('logOperations.readStats')).toBe(false) + }) + + it('resolves a policy spread into the definition', () => { + const policies = parseOperationPolicies(` + const READER_POLICY = { + principalKinds: ['session', 'delegated'], + delegatedServices: ['copilot', 'executor'], + } as const + export const logOperations = { + readDetail: defineWorkspaceOperation({ id: 'logs.read_detail', ...READER_POLICY }), + } as const + `) + + expect(policies.get('logOperations.readDetail')).toBe(true) + }) + + it('resolves an operation declared through a same-file factory', () => { + const policies = parseOperationPolicies(` + const TOOL_POLICY = { delegatedServices: ['copilot', 'executor'] } as const + const UI_POLICY = { delegatedServices: ['copilot'] } as const + function toolReadOperation(id: Id) { + return defineWorkspaceOperation({ id, minimumRole: 'read', ...TOOL_POLICY }) + } + function readOperation(id: Id) { + return defineWorkspaceOperation({ id, minimumRole: 'read', ...UI_POLICY }) + } + export const tableOperations = { + queryRows: toolReadOperation('tables.rows.query'), + listTables: readOperation('tables.list'), + } as const + `) + + expect(policies.get('tableOperations.queryRows')).toBe(true) + expect(policies.get('tableOperations.listTables')).toBe(false) + }) + + it('treats an operation with no delegated services as executor-free', () => { + const policies = parseOperationPolicies(` + export const workspaceOperations = { + read: defineWorkspaceOperation({ id: 'workspaces.read', minimumRole: 'read' }), + } as const + `) + + expect(policies.get('workspaceOperations.read')).toBe(false) + }) +}) + +describe('operation references', () => { + it('collects only declared operations', () => { + const referenced = referencedOperations( + ` + const useCase = defineAuthorizedWorkspaceUseCase({ + operation: logOperations.readDetail, + execute: () => input.signal?.throwIfAborted(), + }) + `, + new Set(['logOperations.readDetail', 'logOperations.list']) + ) + + expect(referenced).toEqual(['logOperations.readDetail']) + }) +}) + +describe('subject requirement audit', () => { + const call = ' const userId = requirePrincipalSubjectUserId(principal)' + + it('flags an unannotated call', () => { + const findings = auditSubjectRequirements(`function run() {\n${call}\n}`, ['ops.thing']) + + expect(findings).toEqual([ + { file: '', line: 2, reason: 'unannotated', operations: ['ops.thing'] }, + ]) + }) + + it('accepts an annotated call', () => { + const source = `function run() {\n // actorless-unsupported: skills belong to a person\n${call}\n}` + + expect(auditSubjectRequirements(source, [])).toEqual([]) + }) + + it('tolerates context comments above the annotation', () => { + const source = [ + 'function run() {', + ' // The library is per-user.', + ' // actorless-unsupported: skills belong to a person', + call, + '}', + ].join('\n') + + expect(auditSubjectRequirements(source, [])).toEqual([]) + }) + + it('rejects an annotation with no reason', () => { + const source = `function run() {\n // actorless-unsupported:\n${call}\n}` + + expect(auditSubjectRequirements(source, [])).toEqual([ + { file: '', line: 3, reason: 'empty-reason', operations: [] }, + ]) + }) + + it('ignores an annotation separated from the call by code', () => { + const source = [ + 'function run() {', + ' // actorless-unsupported: not attached to the call below', + ' const workspaceId = context.workspaceId', + call, + '}', + ].join('\n') + + expect(auditSubjectRequirements(source, [])).toEqual([ + { file: '', line: 4, reason: 'unannotated', operations: [] }, + ]) + }) +}) diff --git a/scripts/check-actorless-executor-operations.ts b/scripts/check-actorless-executor-operations.ts new file mode 100644 index 00000000000..91a49b46f8d --- /dev/null +++ b/scripts/check-actorless-executor-operations.ts @@ -0,0 +1,275 @@ +#!/usr/bin/env bun +/** + * Fails when an operation an actorless run can reach demands a human subject. + * + * A workflow executes under a `Principal`, and several triggers have no person + * behind them: a schedule, the public API, a webhook carrying no external subject. + * Those runs still hold real authority — `workspace-authorization.ts` admits an + * actorless executor delegation whose current workflow is a deployment — so they + * are authorized callers with no `subjectUserId`. Any use case they can reach that + * calls `requirePrincipalSubjectUserId` therefore throws, and because + * `PrincipalSubjectUserRequiredError` is not an `OrchestrationError` it surfaces as + * an opaque 500 rather than anything a workflow author can act on. + * + * That is not hypothetical: the Logs detail tools broke for every scheduled run + * this way, and the failure reached production because nothing connected "this + * operation admits `delegatedServices: ['executor']`" to "this use case requires a + * person". This audit connects them. + * + * A subject is genuinely required often enough that the rule is an annotation, not + * a ban. Write `// actorless-unsupported: ` above the call to declare that + * the operation has no meaning without a person — the annotation turns a silent + * 500 into a documented gap that a reviewer can weigh. + */ +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { dirname, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) +const ROOT = resolve(SCRIPT_DIR, '..') +const SCAN_ROOTS = ['apps/sim/lib', 'apps/sim/app'] +const REQUIRE_CALL = 'requirePrincipalSubjectUserId(' +const ANNOTATION = 'actorless-unsupported:' +const MAX_ANNOTATION_LOOKBACK = 3 + +/** + * `apps/sim/lib/internal/**` is the in-process tool surface: every handler under it + * mints an executor delegation, so its modules are executor-reachable whether or + * not they bind a named operation. + */ +const EXECUTOR_SURFACE_PREFIX = 'apps/sim/lib/internal/' + +export interface ActorlessFinding { + file: string + line: number + reason: 'unannotated' | 'empty-reason' + operations: string[] +} + +/** How the file was shown to be reachable by an actorless run. */ +type Reachability = 'internal-surface' | 'declared-operation' | 'unproven' + +/** Text of the balanced `(...)` or `{...}` group that starts at `openIndex`. */ +function balancedGroup(source: string, openIndex: number): string { + const open = source[openIndex] + const close = open === '(' ? ')' : '}' + let depth = 0 + for (let index = openIndex; index < source.length; index++) { + const char = source[index] + if (char === open) depth++ + else if (char === close) { + depth-- + if (depth === 0) return source.slice(openIndex, index + 1) + } + } + return source.slice(openIndex) +} + +function admitsExecutor(text: string): boolean { + const match = /delegatedServices\s*:\s*\[([^\]]*)\]/.exec(text) + return match ? /['"]executor['"]/.test(match[1]) : false +} + +/** + * Maps every `.` declared in an operations module to whether its + * policy admits an executor delegation, resolving same-file policy constants that + * are spread into the definition (e.g. `...LOG_READER_PRINCIPAL_POLICY`). + */ +export function parseOperationPolicies(source: string): Map { + const policies = new Map() + + const spreadable = new Map() + const constPattern = /(?:^|\n)\s*(?:export\s+)?const\s+([A-Za-z0-9_$]+)\s*=\s*\{/g + for (let match = constPattern.exec(source); match; match = constPattern.exec(source)) { + const braceIndex = source.indexOf('{', match.index + match[0].length - 1) + spreadable.set(match[1], admitsExecutor(balancedGroup(source, braceIndex))) + } + + /** Whether a `defineWorkspaceOperation({...})` call admits an executor delegation. */ + const definitionAdmitsExecutor = (definition: string): boolean => { + if (admitsExecutor(definition)) return true + return [...definition.matchAll(/\.\.\.([A-Za-z0-9_$]+)/g)].some( + (spread) => spreadable.get(spread[1]) === true + ) + } + + // Several domains declare their operations through same-file factories + // (`toolReadOperation('tables.rows.query')`) rather than inline, so the policy has + // to be resolved through the factory or those operations read as executor-free. + const factories = new Map() + const factoryPattern = /(?:^|\n)\s*(?:export\s+)?function\s+([A-Za-z0-9_$]+)\s*[<(]/g + for (let match = factoryPattern.exec(source); match; match = factoryPattern.exec(source)) { + const bodyIndex = source.indexOf('{', match.index + match[0].length - 1) + if (bodyIndex === -1) continue + const body = balancedGroup(source, bodyIndex) + const defineIndex = body.indexOf('defineWorkspaceOperation') + if (defineIndex === -1) continue + const parenIndex = body.indexOf('(', defineIndex) + factories.set(match[1], definitionAdmitsExecutor(balancedGroup(body, parenIndex))) + } + + const namespacePattern = /(?:^|\n)\s*export\s+const\s+([A-Za-z0-9_$]+)\s*=\s*\{/g + for (let match = namespacePattern.exec(source); match; match = namespacePattern.exec(source)) { + const namespace = match[1] + const braceIndex = source.indexOf('{', match.index + match[0].length - 1) + const body = balancedGroup(source, braceIndex) + + const entryPattern = /([A-Za-z0-9_$]+)\s*:\s*([A-Za-z0-9_$]+)\s*\(/g + for (let entry = entryPattern.exec(body); entry; entry = entryPattern.exec(body)) { + const [, key, callee] = entry + if (callee === 'defineWorkspaceOperation') { + const parenIndex = body.indexOf('(', entry.index + entry[0].length - 1) + policies.set( + `${namespace}.${key}`, + definitionAdmitsExecutor(balancedGroup(body, parenIndex)) + ) + } else if (factories.has(callee)) { + policies.set(`${namespace}.${key}`, factories.get(callee) === true) + } + } + } + + return policies +} + +/** The declared operations a module references, whether to define or to bind them. */ +export function referencedOperations(source: string, known: Set): string[] { + const referenced = new Set() + for (const match of source.matchAll(/([A-Za-z0-9_$]+)\.([A-Za-z0-9_$]+)/g)) { + const id = `${match[1]}.${match[2]}` + if (known.has(id)) referenced.add(id) + } + return [...referenced].sort() +} + +/** + * Flags `requirePrincipalSubjectUserId` calls that are not declared actorless-unsupported. + * Mirrors the placement rule the boundary annotations use: the annotation must sit in one + * of the preceding comment lines, so extra context above it is fine. + */ +export function auditSubjectRequirements(source: string, operations: string[]): ActorlessFinding[] { + const findings: ActorlessFinding[] = [] + const lines = source.split('\n') + + for (const [index, line] of lines.entries()) { + if (!line.includes(REQUIRE_CALL)) continue + + let annotation: string | undefined + for (let back = index - 1; back >= 0 && back >= index - MAX_ANNOTATION_LOOKBACK; back--) { + const candidate = lines[back].trim() + if (candidate === '') continue + if (!candidate.startsWith('//') && !candidate.startsWith('*')) break + const found = candidate.indexOf(ANNOTATION) + if (found !== -1) { + annotation = candidate.slice(found + ANNOTATION.length).trim() + break + } + } + + if (annotation === undefined) { + findings.push({ file: '', line: index + 1, reason: 'unannotated', operations }) + } else if (annotation === '') { + findings.push({ file: '', line: index + 1, reason: 'empty-reason', operations }) + } + } + + return findings +} + +function walk(directory: string, into: string[]): string[] { + for (const entry of readdirSync(directory)) { + if (entry === 'node_modules' || entry === '.next') continue + const full = join(directory, entry) + if (statSync(full).isDirectory()) walk(full, into) + else if (full.endsWith('.ts') && !full.endsWith('.test.ts')) into.push(full) + } + return into +} + +function main(): void { + const sourceFiles = SCAN_ROOTS.flatMap((root) => walk(join(ROOT, root), [])) + + const policies = new Map() + for (const file of sourceFiles) { + if (!file.endsWith('/application/operations.ts')) continue + for (const [id, executor] of parseOperationPolicies(readFileSync(file, 'utf8'))) { + policies.set(id, executor) + } + } + const known = new Set(policies.keys()) + const executorAdmitting = new Set([...policies].filter(([, yes]) => yes).map(([id]) => id)) + + // Domains with at least one executor-admitting operation. A shared use-case + // factory in such a domain names no operation of its own — it takes one as an + // argument — so it cannot be proven executor-free and fails closed here. + const executorDomains = new Set() + for (const file of sourceFiles) { + if (!file.endsWith('/application/operations.ts')) continue + const parsed = parseOperationPolicies(readFileSync(file, 'utf8')) + if ([...parsed.values()].some(Boolean)) { + executorDomains.add(relative(ROOT, dirname(dirname(file)))) + } + } + + const findings: ActorlessFinding[] = [] + const reachabilityByFinding = new Map() + let auditedFiles = 0 + + for (const file of sourceFiles) { + const source = readFileSync(file, 'utf8') + if (!source.includes(REQUIRE_CALL)) continue + + const relativePath = relative(ROOT, file) + const operations = referencedOperations(source, known) + const reachability: Reachability | undefined = relativePath.startsWith(EXECUTOR_SURFACE_PREFIX) + ? 'internal-surface' + : operations.some((id) => policies.get(id) === true) + ? 'declared-operation' + : operations.length === 0 && + [...executorDomains].some((domain) => relativePath.startsWith(`${domain}/`)) + ? 'unproven' + : undefined + if (!reachability) continue + + auditedFiles++ + for (const audited of auditSubjectRequirements(source, operations)) { + const finding = { ...audited, file: relativePath } + findings.push(finding) + reachabilityByFinding.set(finding, reachability) + } + } + + if (findings.length > 0) { + console.error( + 'Operations an actorless run can reach must not silently require a human subject:' + ) + for (const finding of findings) { + const reachability = reachabilityByFinding.get(finding) + const via = + reachability === 'internal-surface' + ? 'in-process tool surface' + : reachability === 'unproven' + ? 'shared use case in a domain with executor-admitting operations' + : `reachable via ${finding.operations.filter((id) => executorAdmitting.has(id)).join(', ')}` + const problem = + finding.reason === 'empty-reason' + ? `${ANNOTATION} needs a reason` + : `unannotated requirePrincipalSubjectUserId (${via})` + console.error(` ${finding.file}:${finding.line} ${problem}`) + } + console.error( + `\nA scheduled, public-API, or subject-less webhook run reaches these with no user, and` + + `\n\`requirePrincipalSubjectUserId\` throws a 500 there rather than anything actionable.` + + `\nEither resolve the user optionally (\`resolvePrincipalSubjectUserId\`) when it is only` + + `\nattribution, or declare the gap with \`// ${ANNOTATION} \` above the call.` + ) + process.exit(1) + } + + console.log( + `✓ no undeclared human-subject requirements on actorless-reachable operations ` + + `(${executorAdmitting.size} executor-admitting operations, ${auditedFiles} files audited)` + ) +} + +if (import.meta.main) main() diff --git a/scripts/check-tool-registry-boundary.baseline.json b/scripts/check-tool-registry-boundary.baseline.json index cd4e0371e60..34c38e86d06 100644 --- a/scripts/check-tool-registry-boundary.baseline.json +++ b/scripts/check-tool-registry-boundary.baseline.json @@ -83,16 +83,16 @@ "gateways": {} }, "app/workspace/[workspaceId]/chat/[chatId]/page.tsx": { - "modules": 3000, + "modules": 2882, "gateways": { - "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1391, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 1036, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 895, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 892, + "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1261, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 906, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 758, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 755, "apps/sim/triggers/registry.ts": 472, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 342, - "apps/sim/blocks/registry.ts": 315, - "apps/sim/lib/auth/index.ts": 231 + "apps/sim/blocks/registry.ts": 318, + "apps/sim/lib/auth/index.ts": 238, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 198 } }, "app/workspace/[workspaceId]/error.tsx": { @@ -173,16 +173,16 @@ "gateways": {} }, "app/workspace/[workspaceId]/home/page.tsx": { - "modules": 3000, + "modules": 2882, "gateways": { - "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1391, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 1036, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 895, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 892, + "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1261, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 906, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 758, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 755, "apps/sim/triggers/registry.ts": 472, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 342, - "apps/sim/blocks/registry.ts": 315, - "apps/sim/lib/auth/index.ts": 231 + "apps/sim/blocks/registry.ts": 318, + "apps/sim/lib/auth/index.ts": 238, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 198 } }, "app/workspace/[workspaceId]/integrations/[block]/page.tsx": { @@ -542,16 +542,16 @@ } }, "app/workspace/[workspaceId]/tables/[tableId]/page.tsx": { - "modules": 2225, + "modules": 1803, "gateways": { - "apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx": 574, - "apps/sim/triggers/registry.ts": 472, - "apps/sim/lib/auth/index.ts": 344, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts": 322, - "apps/sim/blocks/registry.ts": 315, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 277, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 273, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 241 + "apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx": 1659, + "apps/sim/triggers/registry.ts": 508, + "apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts": 332, + "apps/sim/blocks/registry.ts": 319, + "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 286, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 282, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 249, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts": 238 } }, "app/workspace/[workspaceId]/tables/error.tsx": { @@ -595,42 +595,38 @@ } }, "app/workspace/[workspaceId]/w/[workflowId]/layout.tsx": { - "modules": 2195, + "modules": 145, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 2194, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 556, - "apps/sim/triggers/registry.ts": 508, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 472, - "apps/sim/blocks/registry.ts": 335, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 295, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 187, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 156 + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 144, + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 142, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, + "apps/sim/hooks/queries/copilot-feedback.ts": 70 } }, "app/workspace/[workspaceId]/w/[workflowId]/page.tsx": { - "modules": 2226, + "modules": 2053, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 2225, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 2052, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/registry.ts": 335, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 312, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 274, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 231, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 181, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 179 + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 344, + "apps/sim/blocks/registry.ts": 338, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 307, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 245, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 151, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 144 } }, "app/workspace/[workspaceId]/w/page.tsx": { - "modules": 2195, + "modules": 2035, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 981, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 818, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 542, "apps/sim/triggers/registry.ts": 508, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 472, - "apps/sim/blocks/registry.ts": 335, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 295, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 168, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 156, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 146 + "apps/sim/blocks/registry.ts": 338, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 310, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 153, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 146, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts": 140 } }, "app/workspace/layout.tsx": { diff --git a/scripts/check-tool-registry-boundary.ts b/scripts/check-tool-registry-boundary.ts index 95fc53c8a51..544dca12c82 100644 --- a/scripts/check-tool-registry-boundary.ts +++ b/scripts/check-tool-registry-boundary.ts @@ -4,7 +4,7 @@ * entry's module graph grows past its recorded baseline. * * `@/tools/registry` is a barrel over 4,300+ tools whose `ToolConfig`s hold - * closures (`request.headers`, `transformResponse`, `directExecution`). Those + * closures (`request.headers`, `transformResponse`). Those * closures reach every integration's SDK client and parser, so reaching the * barrel costs ~4,700 modules — it was 71-82% of every workspace route's module * graph until those edges were cut. diff --git a/scripts/check-tool-request-boundary.test.ts b/scripts/check-tool-request-boundary.test.ts index 36a2000e862..5291d3154f7 100644 --- a/scripts/check-tool-request-boundary.test.ts +++ b/scripts/check-tool-request-boundary.test.ts @@ -15,6 +15,42 @@ function auditRequest(request: string) { } describe('tool self-hop audit', () => { + it('rejects the retired direct execution property', () => { + const audit = auditToolSelfHops(` + const tool = { + id: 'test_tool', + directExecution: async () => ({ success: true, output: {} }), + } + `) + + expect(audit.violations).toEqual([ + expect.objectContaining({ reason: 'retired-direct-execution' }), + ]) + }) + + it('rejects the retired direct execution method signature', () => { + const audit = auditToolSelfHops(` + interface LegacyTool { + directExecution(params: unknown): Promise + } + `) + + expect(audit.violations).toEqual([ + expect.objectContaining({ reason: 'retired-direct-execution' }), + ]) + }) + + it('allows ordinary operation implementations', () => { + const audit = auditToolSelfHops(` + const tool = { + id: 'test_tool', + operation: { input: (params) => params }, + } + `) + + expect(audit.violations).toEqual([]) + }) + it('allows an absolute external provider URL', () => { const audit = auditRequest( "url: 'https://api.example.com/v1/items', method: 'GET', headers: () => ({})" diff --git a/scripts/check-tool-request-boundary.ts b/scripts/check-tool-request-boundary.ts index 1faaaa9b373..dc2f7d2ce1f 100644 --- a/scripts/check-tool-request-boundary.ts +++ b/scripts/check-tool-request-boundary.ts @@ -44,6 +44,7 @@ export interface ToolSelfHopViolation { reason: | 'same-origin-tool-request' | 'legacy-internal-policy' + | 'retired-direct-execution' | 'unresolved-request-policy' | 'unapproved-same-origin-policy' } @@ -1601,8 +1602,25 @@ export function auditToolSelfHops(source: string, file = 'source.ts'): ToolSelfH let detectedSelfHops = 0 let legacyInternalPolicies = 0 const resolver = createSelfHopResolver(program, file) + const retiredDirectExecutionLocations = new Set() const visit = (node: SyntaxNode) => { + if ( + ['ObjectProperty', 'ObjectMethod', 'TSPropertySignature', 'TSMethodSignature'].includes( + node.type + ) && + getStaticPropertyName(node) === 'directExecution' + ) { + const location = node.start ?? node.loc?.start.line ?? -1 + if (!retiredDirectExecutionLocations.has(location)) { + retiredDirectExecutionLocations.add(location) + violations.push({ + file, + line: node.loc?.start.line ?? 1, + reason: 'retired-direct-execution', + }) + } + } if (node.type === 'ObjectExpression') { const idProperty = getObjectProperty(node, 'id') const toolId = idProperty ? getToolId(node, resolver) : undefined @@ -1921,7 +1939,9 @@ function main(): void { ? 'replace the /api self-hop with InternalToolConfig.operation and a registered server handler' : violation.reason === 'legacy-internal-policy' ? 'request.internal is obsolete; use InternalToolConfig.operation for in-process work' - : 'request configuration could not be audited; keep it in a statically resolvable local helper' + : violation.reason === 'retired-direct-execution' + ? 'directExecution is retired; use InternalToolConfig.operation and a registered server handler' + : 'request configuration could not be audited; keep it in a statically resolvable local helper' console.error( ` ${relative(ROOT, violation.file)}:${violation.line} ${violation.toolId ?? 'unknown tool'}: ${description}` ) diff --git a/scripts/generate-docs.test.ts b/scripts/generate-docs.test.ts index a6ef7b90815..af9c8e15c0e 100644 --- a/scripts/generate-docs.test.ts +++ b/scripts/generate-docs.test.ts @@ -161,7 +161,7 @@ describe('documentation input parameter parsing', () => { }) }) - it('stops at legacy request metadata after a comment', () => { + it('stops at operation metadata after a comment', () => { const tool = extractToolInfo( 'example_send', ` @@ -175,16 +175,13 @@ describe('documentation input parameter parsing', () => { description: 'The message', }, }, - // Direct execution short-circuits this legacy request descriptor. - request: { - url: () => '', - method: 'POST', + operation: { + input: (params) => params, modelInput: { mode: 'project', select: (params) => ({ message: params.message }), }, }, - directExecution: async () => ({ success: true }), outputs: {}, } ` @@ -718,7 +715,7 @@ describe('a source the scanner cannot get through is reported, not swallowed', ( it('still reports null with no parseError for a spread-only subBlocks array', () => { const supplied = extractBlockSuppliedParamIds( - "subBlocks: [...NotionBlock.subBlocks], tools: { config: { params: (p) => ({ renamedByMapper: p.a }) } },", + 'subBlocks: [...NotionBlock.subBlocks], tools: { config: { params: (p) => ({ renamedByMapper: p.a }) } },', 'SpreadBlock' ) diff --git a/scripts/sync-tool-metadata.ts b/scripts/sync-tool-metadata.ts index b15d331c7b6..1f7a751bd35 100644 --- a/scripts/sync-tool-metadata.ts +++ b/scripts/sync-tool-metadata.ts @@ -4,7 +4,7 @@ * * `apps/sim/tools/registry.ts` is a ~9,000-line barrel importing all 4,300+ * tools. Each `ToolConfig` mixes plain data (`params`, `outputs`, `name`) with - * closures (`request.headers`, `transformResponse`, `directExecution`, + * closures (`request.headers`, `transformResponse`, * `postProcess`), and it is those closures — and the SDK clients and API * helpers they reach — that make the barrel cost ~4,700 modules to compile. * @@ -52,7 +52,7 @@ const OUTPUTS_PATH = resolve(GENERATED_DIR, 'tool-outputs.ts') /** * Fields copied into `tool-metadata.ts`. Every one must be plain data. * - * Deliberately excluded: `request`, `transformResponse`, `directExecution`, + * Deliberately excluded: `request`, `transformResponse`, * `postProcess` (closures, and the whole reason the registry is expensive); * `hosting` and `schemaEnrichment` (contain predicates/`enrichSchema`, and are * only consumed server-side); `outputs` (emitted separately).