diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 2417e6acb58..fcdab73224d 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -2439,6 +2439,21 @@ export function JiraIcon(props: SVGProps) { ) } +export function LinqIcon(props: SVGProps) { + return ( + + + + + ) +} + export function LinearIcon(props: React.SVGProps) { return ( ) { ) } +export function TogetherIcon(props: SVGProps) { + return ( + + + + + + ) +} + +export function BasetenIcon(props: SVGProps) { + return ( + + + + ) +} + export function MondayIcon(props: SVGProps) { return ( = { linear_v2: LinearIcon, linkedin: LinkedInIcon, linkup: LinkupIcon, + linq: LinqIcon, loops: LoopsIcon, luma: LumaIcon, mailchimp: MailchimpIcon, diff --git a/apps/docs/content/docs/en/tools/linq.mdx b/apps/docs/content/docs/en/tools/linq.mdx new file mode 100644 index 00000000000..3bb5f0994e5 --- /dev/null +++ b/apps/docs/content/docs/en/tools/linq.mdx @@ -0,0 +1,787 @@ +--- +title: Linq +description: Send iMessage, SMS, and RCS messages and manage conversations with Linq +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[Linq](https://linqapp.com/) is an API-first messaging platform that lets you reach people on iMessage, SMS, and RCS through real conversations. Linq handles the messaging plumbing — Apple and carrier delivery, group chats, read receipts, typing indicators, reactions, and attachments — behind a single REST API designed for programmatic access. + +**Why Linq?** +- **iMessage, SMS, and RCS in one API:** Send and receive across all three channels from the same chats and phone numbers, with automatic delivery over the best available service. +- **Rich conversations:** Media, link previews, screen and bubble effects, tapback reactions, inline replies, voice memos, and editable messages — not just plain text. +- **Group chat management:** Create groups, add and remove participants, rename chats, update icons, and leave conversations. +- **Capability checks:** Verify whether an address supports iMessage or RCS before you send, so you pick the right channel every time. +- **Real-time webhooks:** Subscribe to message, reaction, participant, and call events with HMAC-SHA256 signature verification. + +**Using Linq in Sim** + +Sim's Linq integration connects your agentic workflows directly to Linq using an API key. With 34 operations spanning chats, messages, attachments, phone numbers, capability checks, contact cards, and webhook subscriptions, you can build conversational messaging automations without writing backend code. + +**Key benefits of using Linq in Sim:** +- **Conversational agents:** Send and read messages in iMessage, SMS, or RCS chats, react with tapbacks, and reply inline to build natural two-way conversations. +- **Reliable delivery:** Check iMessage/RCS capability and set a preferred service so each message goes out over the right channel. +- **Files and voice:** Upload attachments up to 100MB and send media or voice memos straight from your workflow. +- **Event-driven flows:** Manage webhook subscriptions so workflows can react to inbound messages, reactions, and participant changes. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Reach people on iMessage, SMS, and RCS through Linq. Start chats, send messages with media, links, effects, and replies, send voice memos, react with tapbacks, manage group participants, check iMessage/RCS capability, configure contact cards, and subscribe to webhook events — all through a single Linq API key. + + + +## Tools + +### `linq_add_participant` + +Add a participant to a group chat (3+ existing participants) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Linq API key | +| `chatId` | string | Yes | The unique identifier of the group chat | +| `handle` | string | Yes | Phone number \(E.164 format\) or email address of the participant to add | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Human-readable status message | +| `status` | string | Queued action status | +| `traceId` | string | Trace ID for the queued action | + +### `linq_check_imessage` + +Check whether an address (phone number or email) supports iMessage + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Linq API key | +| `address` | string | Yes | Phone number \(E.164 format\) or email address to check | +| `from` | string | No | Sender phone number to check from \(defaults to an available number\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `address` | string | The address that was checked | +| `available` | boolean | Whether the address supports iMessage | + +### `linq_check_rcs` + +Check whether an address (phone number or email) supports RCS + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Linq API key | +| `address` | string | Yes | Phone number \(E.164 format\) or email address to check | +| `from` | string | No | Sender phone number to check from \(defaults to an available number\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `address` | string | The address that was checked | +| `available` | boolean | Whether the address supports RCS | + +### `linq_create_attachment` + +Upload a file to Linq as a reusable attachment (max 100MB) and get an attachment ID to send in messages + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Linq API key | +| `file` | file | No | File to upload \(a UserFile from a file-upload field or a previous block\) | +| `fileContent` | string | No | Legacy base64-encoded file content fallback | +| `filename` | string | No | Override the file name \(defaults to the uploaded file name\) | +| `contentType` | string | No | Override the MIME type \(defaults to the uploaded file type\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `attachmentId` | string | Reusable attachment ID to reference when sending messages or voice memos | +| `downloadUrl` | string | URL the attachment can be downloaded from | +| `filename` | string | File name | +| `contentType` | string | MIME type of the file | +| `sizeBytes` | number | File size in bytes | +| `status` | string | Upload status | + +### `linq_create_chat` + +Start a new iMessage, SMS, or RCS chat and send the first message + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Linq API key | +| `from` | string | Yes | Sender phone number in E.164 format \(e.g. +14155551234\) | +| `to` | array | Yes | Recipient handles \(phone numbers in E.164 format or email addresses\) | +| `text` | string | No | Text content of the first message. Optional, but at least one of text, media, attachment, or link is required | +| `mediaUrl` | string | No | Optional publicly accessible HTTPS URL of an image, video, or file to attach | +| `attachmentId` | string | No | Optional ID of a pre-uploaded attachment to send instead of a media URL | +| `preferredService` | string | No | Preferred delivery service: iMessage, SMS, or RCS | +| `effectName` | string | No | Optional iMessage effect name \(e.g. confetti, fireworks, lasers\) | +| `effectType` | string | No | Optional effect type: screen or bubble | +| `replyToMessageId` | string | No | Optional message ID to reply to inline | +| `replyToPartIndex` | number | No | Optional part index of the message being replied to | +| `idempotencyKey` | string | No | Optional idempotency key to safely retry the request | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `chatId` | string | ID of the created chat | +| `displayName` | string | Display name of the chat | +| `isGroup` | boolean | Whether the chat is a group chat | +| `service` | string | Delivery service used \(iMessage, SMS, RCS\) | +| `handles` | json | Participant handles in the chat | +| `healthStatus` | json | Messaging line health status | +| `message` | json | The sent message object with parts and delivery info | + +### `linq_create_contact_card` + +Set up a contact card (Name and Photo Sharing) for a phone number + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Linq API key | +| `phoneNumber` | string | Yes | Phone number in E.164 format the card applies to | +| `firstName` | string | Yes | First name to display | +| `lastName` | string | No | Last name to display | +| `imageUrl` | string | No | Profile photo URL | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `phoneNumber` | string | Phone number the card applies to | +| `firstName` | string | First name | +| `lastName` | string | Last name | +| `imageUrl` | string | Profile photo URL | +| `isActive` | boolean | Whether the card is active | + +### `linq_create_webhook_subscription` + +Subscribe an HTTPS endpoint to Linq webhook events + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Linq API key | +| `targetUrl` | string | Yes | HTTPS endpoint that will receive webhook events | +| `subscribedEvents` | array | Yes | Event types to subscribe to \(e.g. message.sent, message.delivered\) | +| `phoneNumbers` | array | No | E.164 phone numbers to filter events by \(omit for all numbers\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Subscription ID | +| `targetUrl` | string | Endpoint that receives events | +| `subscribedEvents` | json | Subscribed event types | +| `phoneNumbers` | json | Filtered phone numbers \(null = all\) | +| `isActive` | boolean | Whether the subscription is active | +| `createdAt` | string | ISO 8601 creation timestamp | +| `updatedAt` | string | ISO 8601 update timestamp | +| `signingSecret` | string | HMAC-SHA256 signing secret. Store securely — it cannot be retrieved again | + +### `linq_delete_attachment` + +Permanently delete an attachment owned by your account + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Linq API key | +| `attachmentId` | string | Yes | The unique identifier of the attachment to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the attachment was deleted | + +### `linq_delete_message` + +Delete a message from the Linq API only (does not unsend it; recipients still see it) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Linq API key | +| `messageId` | string | Yes | The unique identifier of the message to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the message was deleted | + +### `linq_delete_webhook_subscription` + +Delete a webhook subscription from your account + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Linq API key | +| `subscriptionId` | string | Yes | The unique identifier of the webhook subscription to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the subscription was deleted | + +### `linq_edit_message` + +Edit the text of a sent message (up to 5 times, within 15 minutes of sending; iMessage only) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Linq API key | +| `messageId` | string | Yes | The unique identifier of the message to edit | +| `text` | string | Yes | New text content for the message part | +| `partIndex` | number | No | Index of the message part to edit \(defaults to 0\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Message ID | +| `chatId` | string | ID of the chat the message belongs to | +| `isFromMe` | boolean | Whether the message was sent by you | +| `isDelivered` | boolean | Whether the message was delivered | +| `isRead` | boolean | Whether the message was read | +| `service` | string | Delivery service \(iMessage, SMS, RCS\) | +| `createdAt` | string | ISO 8601 creation timestamp | +| `updatedAt` | string | ISO 8601 update timestamp | +| `sentAt` | string | ISO 8601 sent timestamp | +| `parts` | json | Updated message parts with reactions | +| `message` | json | The full updated message object | + +### `linq_get_attachment` + +Retrieve metadata for an attachment, including its download URL and status + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Linq API key | +| `attachmentId` | string | Yes | The unique identifier of the attachment | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Attachment ID | +| `filename` | string | File name | +| `contentType` | string | MIME type of the file | +| `sizeBytes` | number | File size in bytes | +| `status` | string | Upload status \(pending, complete, failed\) | +| `downloadUrl` | string | URL to download the file | +| `createdAt` | string | ISO 8601 creation timestamp | + +### `linq_get_chat` + +Retrieve a chat by ID, including participants and line health + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Linq API key | +| `chatId` | string | Yes | The unique identifier of the chat | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Chat ID | +| `displayName` | string | Display name of the chat | +| `isGroup` | boolean | Whether the chat is a group chat | +| `isArchived` | boolean | Whether the chat is archived | +| `service` | string | Delivery service \(iMessage, SMS, RCS\) | +| `createdAt` | string | ISO 8601 creation timestamp | +| `updatedAt` | string | ISO 8601 update timestamp | +| `handles` | json | Participant handles in the chat | +| `healthStatus` | json | Messaging line health status | + +### `linq_get_contact_card` + +Retrieve contact cards, optionally filtered by phone number + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Linq API key | +| `phoneNumber` | string | No | E.164 phone number to filter by \(omit to return all cards\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `contactCards` | array | Contact cards on the account | +| ↳ `phoneNumber` | string | Phone number in E.164 format | +| ↳ `firstName` | string | First name | +| ↳ `lastName` | string | Last name | +| ↳ `imageUrl` | string | Profile photo URL | +| ↳ `isActive` | boolean | Whether the card is active | + +### `linq_get_message` + +Retrieve a single message by ID, including parts, reactions, and delivery status + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Linq API key | +| `messageId` | string | Yes | The unique identifier of the message | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Message ID | +| `chatId` | string | ID of the chat the message belongs to | +| `isFromMe` | boolean | Whether the message was sent by you | +| `isDelivered` | boolean | Whether the message was delivered | +| `isRead` | boolean | Whether the message was read | +| `service` | string | Delivery service \(iMessage, SMS, RCS\) | +| `createdAt` | string | ISO 8601 creation timestamp | +| `updatedAt` | string | ISO 8601 update timestamp | +| `sentAt` | string | ISO 8601 sent timestamp | +| `parts` | json | Message parts \(text, media, link\) with reactions | +| `message` | json | The full message object | + +### `linq_get_webhook_subscription` + +Retrieve a webhook subscription by ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Linq API key | +| `subscriptionId` | string | Yes | The unique identifier of the webhook subscription | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Subscription ID | +| `targetUrl` | string | Endpoint that receives events | +| `subscribedEvents` | json | Subscribed event types | +| `phoneNumbers` | json | Filtered phone numbers \(null = all\) | +| `isActive` | boolean | Whether the subscription is active | +| `createdAt` | string | ISO 8601 creation timestamp | +| `updatedAt` | string | ISO 8601 update timestamp | + +### `linq_leave_chat` + +Leave an iMessage group chat (4+ active participants; not supported for direct chats) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Linq API key | +| `chatId` | string | Yes | The unique identifier of the group chat | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Human-readable status message | +| `status` | string | Queued action status \(e.g. accepted\) | +| `traceId` | string | Trace ID for the queued action | + +### `linq_list_chats` + +List chats, optionally filtered by sender or participant handle + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Linq API key | +| `from` | string | No | Filter by sender phone number in E.164 format | +| `to` | string | No | Filter by participant handle \(phone number or email\) | +| `limit` | number | No | Results per page \(default 20, max 100\) | +| `cursor` | string | No | Pagination cursor from a previous response | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `chats` | json | Array of chat objects | +| `nextCursor` | string | Cursor for the next page, or null if there are no more results | + +### `linq_list_messages` + +List messages in a chat with pagination + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Linq API key | +| `chatId` | string | Yes | The unique identifier of the chat | +| `limit` | number | No | Maximum number of messages to return | +| `cursor` | string | No | Pagination cursor from a previous response | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `messages` | json | Array of message objects with parts and reactions | +| `nextCursor` | string | Cursor for the next page, or null if there are no more results | + +### `linq_list_phone_numbers` + +List all phone numbers assigned to your partner account, with line health + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Linq API key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `phoneNumbers` | array | Phone numbers assigned to the account | +| ↳ `id` | string | Phone number ID | +| ↳ `phoneNumber` | string | Phone number in E.164 format | +| ↳ `healthStatus` | json | Line health status \(status, doc_url\) | + +### `linq_list_thread` + +List all messages in the thread that contains a given message + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Linq API key | +| `messageId` | string | Yes | The ID of any message in the thread | +| `order` | string | No | Sort order: asc \(oldest first\) or desc \(newest first\) | +| `limit` | number | No | Maximum number of messages to return | +| `cursor` | string | No | Pagination cursor from a previous response | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `messages` | json | Array of message objects in the thread | +| `nextCursor` | string | Cursor for the next page, or null if there are no more results | + +### `linq_list_webhook_events` + +List all webhook event types available to subscribe to + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Linq API key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `events` | json | Available webhook event type names | +| `docUrl` | string | Documentation URL for webhook events | + +### `linq_list_webhook_subscriptions` + +List all webhook subscriptions on your account + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Linq API key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `subscriptions` | array | Webhook subscriptions | +| ↳ `id` | string | Subscription ID | +| ↳ `targetUrl` | string | Endpoint that receives events | +| ↳ `subscribedEvents` | json | Subscribed event types | +| ↳ `phoneNumbers` | json | Filtered phone numbers \(null = all\) | +| ↳ `isActive` | boolean | Whether the subscription is active | +| ↳ `createdAt` | string | ISO 8601 creation timestamp | +| ↳ `updatedAt` | string | ISO 8601 update timestamp | + +### `linq_mark_chat_read` + +Mark all messages in a chat as read + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Linq API key | +| `chatId` | string | Yes | The unique identifier of the chat | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the chat was marked as read | + +### `linq_react_to_message` + +Add or remove a tapback reaction on a message + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Linq API key | +| `messageId` | string | Yes | The unique identifier of the message to react to | +| `operation` | string | Yes | Whether to add or remove the reaction: add or remove | +| `type` | string | Yes | Reaction type: love, like, dislike, laugh, emphasize, question, custom, or sticker | +| `customEmoji` | string | No | Emoji to use when type is custom | +| `partIndex` | number | No | Index of the message part to react to \(defaults to the entire message\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Human-readable status message | +| `status` | string | Queued action status | +| `traceId` | string | Trace ID for the queued action | + +### `linq_remove_participant` + +Remove a participant from a group chat (minimum 3 participants must remain) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Linq API key | +| `chatId` | string | Yes | The unique identifier of the group chat | +| `handle` | string | Yes | Phone number \(E.164 format\) or email address of the participant to remove | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Human-readable status message | +| `status` | string | Queued action status | +| `traceId` | string | Trace ID for the queued action | + +### `linq_send_message` + +Send a message to an existing chat, with optional media, link, effect, or reply + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Linq API key | +| `chatId` | string | Yes | The unique identifier of the chat | +| `text` | string | No | Text content of the message. Optional, but at least one of text, media, attachment, or link is required | +| `mediaUrl` | string | No | Optional publicly accessible HTTPS URL of an image, video, or file to attach | +| `attachmentId` | string | No | Optional ID of a pre-uploaded attachment to send instead of a media URL | +| `linkUrl` | string | No | Optional URL to send as a rich link preview. Linq requires a link to be its own message, so when set, text and media are ignored | +| `preferredService` | string | No | Preferred delivery service: iMessage, SMS, or RCS | +| `effectName` | string | No | Optional iMessage effect name \(e.g. confetti, fireworks, lasers\) | +| `effectType` | string | No | Optional effect type: screen or bubble | +| `replyToMessageId` | string | No | Optional message ID to reply to inline | +| `replyToPartIndex` | number | No | Optional part index of the message being replied to | +| `idempotencyKey` | string | No | Optional idempotency key to safely retry the request | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `chatId` | string | ID of the chat the message was sent to | +| `messageId` | string | ID of the sent message | +| `deliveryStatus` | string | Delivery status \(pending, queued, sent, delivered, failed\) | +| `sentAt` | string | ISO 8601 timestamp the message was sent | +| `service` | string | Delivery service \(iMessage, SMS, RCS\) | +| `message` | json | The full sent message object with parts | + +### `linq_send_voice_memo` + +Send a voice memo to a chat from a URL or a pre-uploaded attachment + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Linq API key | +| `chatId` | string | Yes | The unique identifier of the chat | +| `voiceMemoUrl` | string | No | Publicly accessible HTTPS URL of the audio file \(MP3, M4A, AAC, CAF, WAV, AIFF, AMR\) | +| `attachmentId` | string | No | ID of a pre-uploaded audio attachment \(use instead of voiceMemoUrl\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | ID of the sent voice memo message | +| `status` | string | Delivery status | +| `from` | string | Sender handle | +| `to` | json | Recipient handles | +| `service` | string | Delivery service \(iMessage, SMS, RCS\) | +| `voiceMemo` | json | Audio file metadata \(id, filename, mime_type, size_bytes, url, duration_ms\) | + +### `linq_share_contact_card` + +Share your configured contact card (Name and Photo Sharing) with a chat + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Linq API key | +| `chatId` | string | Yes | The unique identifier of the chat | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the contact card was shared | + +### `linq_start_typing` + +Show a typing indicator in a one-on-one chat (iMessage only, not group chats) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Linq API key | +| `chatId` | string | Yes | The unique identifier of the chat | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the typing indicator was sent | + +### `linq_stop_typing` + +Stop the typing indicator in a one-on-one chat (iMessage only, not group chats) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Linq API key | +| `chatId` | string | Yes | The unique identifier of the chat | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the typing indicator was stopped | + +### `linq_update_chat` + +Update chat properties such as group display name and icon + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Linq API key | +| `chatId` | string | Yes | The unique identifier of the chat | +| `displayName` | string | No | New display name for the group chat | +| `groupChatIcon` | string | No | New group chat icon \(publicly accessible image URL\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `chatId` | string | ID of the updated chat | +| `status` | string | Status of the queued update | + +### `linq_update_contact_card` + +Partially update an existing active contact card for a phone number + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Linq API key | +| `phoneNumber` | string | Yes | Phone number in E.164 format identifying the card to update | +| `firstName` | string | No | New first name | +| `lastName` | string | No | New last name | +| `imageUrl` | string | No | New profile photo URL | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `phoneNumber` | string | Phone number the card applies to | +| `firstName` | string | First name | +| `lastName` | string | Last name | +| `imageUrl` | string | Profile photo URL | +| `isActive` | boolean | Whether the card is active | + +### `linq_update_webhook_subscription` + +Update a webhook subscription (target URL, events, phone filter, or active state) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Linq API key | +| `subscriptionId` | string | Yes | The unique identifier of the webhook subscription | +| `targetUrl` | string | No | New HTTPS endpoint that will receive events | +| `subscribedEvents` | array | No | New set of event types to subscribe to | +| `phoneNumbers` | array | No | New set of E.164 phone numbers to filter by | +| `isActive` | boolean | No | Whether the subscription should be active | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Subscription ID | +| `targetUrl` | string | Endpoint that receives events | +| `subscribedEvents` | json | Subscribed event types | +| `phoneNumbers` | json | Filtered phone numbers \(null = all\) | +| `isActive` | boolean | Whether the subscription is active | +| `createdAt` | string | ISO 8601 creation timestamp | +| `updatedAt` | string | ISO 8601 update timestamp | + + diff --git a/apps/docs/content/docs/en/tools/meta.json b/apps/docs/content/docs/en/tools/meta.json index dbc2ef5b7f7..a17c92d28e7 100644 --- a/apps/docs/content/docs/en/tools/meta.json +++ b/apps/docs/content/docs/en/tools/meta.json @@ -110,6 +110,7 @@ "linear", "linkedin", "linkup", + "linq", "loops", "luma", "mailchimp", diff --git a/apps/docs/content/docs/en/tools/table.mdx b/apps/docs/content/docs/en/tools/table.mdx index 388005b772c..e7cf5993704 100644 --- a/apps/docs/content/docs/en/tools/table.mdx +++ b/apps/docs/content/docs/en/tools/table.mdx @@ -275,7 +275,11 @@ Filters use MongoDB-style operators for flexible querying: | `$lte` | Less than or equal | `{"quantity": {"$lte": 10}}` | | `$in` | In array | `{"status": {"$in": ["active", "pending"]}}` | | `$nin` | Not in array | `{"type": {"$nin": ["spam", "blocked"]}}` | -| `$contains` | String contains | `{"email": {"$contains": "@gmail.com"}}` | +| `$contains` | String contains (case-insensitive) | `{"email": {"$contains": "@gmail.com"}}` | +| `$ncontains` | Does not contain (case-insensitive; matches empty cells) | `{"email": {"$ncontains": "@spam.com"}}` | +| `$startsWith` | Starts with (case-insensitive) | `{"name": {"$startsWith": "Dr."}}` | +| `$endsWith` | Ends with (case-insensitive) | `{"file": {"$endsWith": ".pdf"}}` | +| `$empty` | Cell is empty (`true`) or non-empty (`false`) | `{"phone": {"$empty": true}}` | ### Combining Filters diff --git a/apps/realtime/package.json b/apps/realtime/package.json index a7ec69192bd..17f412773e1 100644 --- a/apps/realtime/package.json +++ b/apps/realtime/package.json @@ -43,6 +43,6 @@ "@types/node": "24.2.1", "socket.io-client": "4.8.1", "typescript": "^5.7.3", - "vitest": "^3.0.8" + "vitest": "^4.1.0" } } diff --git a/apps/sim/app/(landing)/integrations/data/icon-mapping.ts b/apps/sim/app/(landing)/integrations/data/icon-mapping.ts index 81c24aeadd8..ef0582c6f41 100644 --- a/apps/sim/app/(landing)/integrations/data/icon-mapping.ts +++ b/apps/sim/app/(landing)/integrations/data/icon-mapping.ts @@ -111,6 +111,7 @@ import { LinearIcon, LinkedInIcon, LinkupIcon, + LinqIcon, LoopsIcon, LumaIcon, MailchimpIcon, @@ -323,6 +324,7 @@ export const blockTypeToIconMap: Record = { linear_v2: LinearIcon, linkedin: LinkedInIcon, linkup: LinkupIcon, + linq: LinqIcon, loops: LoopsIcon, luma: LumaIcon, mailchimp: MailchimpIcon, diff --git a/apps/sim/app/(landing)/integrations/data/integrations.json b/apps/sim/app/(landing)/integrations/data/integrations.json index 8d7bec0c5ed..fd58672771b 100644 --- a/apps/sim/app/(landing)/integrations/data/integrations.json +++ b/apps/sim/app/(landing)/integrations/data/integrations.json @@ -8679,6 +8679,161 @@ "integrationTypes": ["search", "sales"], "tags": ["web-scraping", "enrichment"] }, + { + "type": "linq", + "slug": "linq", + "name": "Linq", + "description": "Send iMessage, SMS, and RCS messages and manage conversations with Linq", + "longDescription": "Reach people on iMessage, SMS, and RCS through Linq. Start chats, send messages with media, links, effects, and replies, send voice memos, react with tapbacks, manage group participants, check iMessage/RCS capability, configure contact cards, and subscribe to webhook events — all through a single Linq API key.", + "bgColor": "#000000", + "iconName": "LinqIcon", + "docsUrl": "https://docs.sim.ai/tools/linq", + "operations": [ + { + "name": "Send Message", + "description": "Send a message to an existing chat, with optional media, link, effect, or reply" + }, + { + "name": "Create Chat", + "description": "Start a new iMessage, SMS, or RCS chat and send the first message" + }, + { + "name": "List Chats", + "description": "List chats, optionally filtered by sender or participant handle" + }, + { + "name": "Get Chat", + "description": "Retrieve a chat by ID, including participants and line health" + }, + { + "name": "Update Chat", + "description": "Update chat properties such as group display name and icon" + }, + { + "name": "Mark Chat as Read", + "description": "Mark all messages in a chat as read" + }, + { + "name": "Leave Chat", + "description": "Leave an iMessage group chat (4+ active participants; not supported for direct chats)" + }, + { + "name": "Add Participant", + "description": "Add a participant to a group chat (3+ existing participants)" + }, + { + "name": "Remove Participant", + "description": "Remove a participant from a group chat (minimum 3 participants must remain)" + }, + { + "name": "Start Typing", + "description": "Show a typing indicator in a one-on-one chat (iMessage only, not group chats)" + }, + { + "name": "Stop Typing", + "description": "Stop the typing indicator in a one-on-one chat (iMessage only, not group chats)" + }, + { + "name": "Send Voice Memo", + "description": "Send a voice memo to a chat from a URL or a pre-uploaded attachment" + }, + { + "name": "Share Contact Card", + "description": "Share your configured contact card (Name and Photo Sharing) with a chat" + }, + { + "name": "List Messages", + "description": "List messages in a chat with pagination" + }, + { + "name": "List Thread", + "description": "List all messages in the thread that contains a given message" + }, + { + "name": "Get Message", + "description": "Retrieve a single message by ID, including parts, reactions, and delivery status" + }, + { + "name": "Edit Message", + "description": "Edit the text of a sent message (up to 5 times, within 15 minutes of sending; iMessage only)" + }, + { + "name": "Delete Message", + "description": "Delete a message from the Linq API only (does not unsend it; recipients still see it)" + }, + { + "name": "React to Message", + "description": "Add or remove a tapback reaction on a message" + }, + { + "name": "Create Attachment", + "description": "Upload a file to Linq as a reusable attachment (max 100MB) and get an attachment ID to send in messages" + }, + { + "name": "Get Attachment", + "description": "Retrieve metadata for an attachment, including its download URL and status" + }, + { + "name": "Delete Attachment", + "description": "Permanently delete an attachment owned by your account" + }, + { + "name": "List Phone Numbers", + "description": "List all phone numbers assigned to your partner account, with line health" + }, + { + "name": "Check iMessage", + "description": "Check whether an address (phone number or email) supports iMessage" + }, + { + "name": "Check RCS", + "description": "Check whether an address (phone number or email) supports RCS" + }, + { + "name": "Get Contact Card", + "description": "Retrieve contact cards, optionally filtered by phone number" + }, + { + "name": "Create Contact Card", + "description": "Set up a contact card (Name and Photo Sharing) for a phone number" + }, + { + "name": "Update Contact Card", + "description": "Partially update an existing active contact card for a phone number" + }, + { + "name": "Create Webhook Subscription", + "description": "Subscribe an HTTPS endpoint to Linq webhook events" + }, + { + "name": "List Webhook Subscriptions", + "description": "List all webhook subscriptions on your account" + }, + { + "name": "Get Webhook Subscription", + "description": "Retrieve a webhook subscription by ID" + }, + { + "name": "Update Webhook Subscription", + "description": "Update a webhook subscription (target URL, events, phone filter, or active state)" + }, + { + "name": "Delete Webhook Subscription", + "description": "Delete a webhook subscription from your account" + }, + { + "name": "List Webhook Events", + "description": "List all webhook event types available to subscribe to" + } + ], + "operationCount": 34, + "triggers": [], + "triggerCount": 0, + "authType": "api-key", + "category": "tools", + "integrationTypes": ["communication", "developer-tools"], + "tags": ["messaging", "automation", "webhooks"] + }, { "type": "loops", "slug": "loops", diff --git a/apps/sim/app/(landing)/models/utils.ts b/apps/sim/app/(landing)/models/utils.ts index fdc0dc548f0..8942dfa948d 100644 --- a/apps/sim/app/(landing)/models/utils.ts +++ b/apps/sim/app/(landing)/models/utils.ts @@ -8,6 +8,9 @@ const PROVIDER_PREFIXES: Record = { bedrock: ['bedrock/'], cerebras: ['cerebras/'], fireworks: ['fireworks/'], + together: ['together/'], + baseten: ['baseten/'], + 'ollama-cloud': ['ollama-cloud/'], groq: ['groq/'], openrouter: ['openrouter/'], vllm: ['vllm/'], diff --git a/apps/sim/app/api/copilot/chat/stop/route.test.ts b/apps/sim/app/api/copilot/chat/stop/route.test.ts index bab5465507d..452131f21e1 100644 --- a/apps/sim/app/api/copilot/chat/stop/route.test.ts +++ b/apps/sim/app/api/copilot/chat/stop/route.test.ts @@ -1,79 +1,19 @@ /** * @vitest-environment node */ -import { authMockFns } from '@sim/testing' +import { authMockFns, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockSelect, - mockFrom, - mockWhereSelect, - mockLimit, - mockForUpdate, - mockUpdate, - mockSet, - mockWhereUpdate, - mockReturning, - mockPublishStatusChanged, - mockSql, - mockTransaction, -} = vi.hoisted(() => { - const mockSelect = vi.fn() - const mockFrom = vi.fn() - const mockWhereSelect = vi.fn() - const mockLimit = vi.fn() - const mockForUpdate = vi.fn() - const mockUpdate = vi.fn() - const mockSet = vi.fn() - const mockWhereUpdate = vi.fn() - const mockReturning = vi.fn() - const mockPublishStatusChanged = vi.fn() - const mockSql = vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ - strings, - values, - })) - const mockTransaction = vi.fn( - (callback: (tx: { select: typeof mockSelect; update: typeof mockUpdate }) => unknown) => - callback({ select: mockSelect, update: mockUpdate }) - ) - - return { - mockSelect, - mockFrom, - mockWhereSelect, - mockLimit, - mockForUpdate, - mockUpdate, - mockSet, - mockWhereUpdate, - mockReturning, - mockPublishStatusChanged, - mockSql, - mockTransaction, - } -}) +vi.mock('@sim/db', () => dbChainMock) -vi.mock('@sim/db/schema', () => ({ - copilotChats: { - id: 'copilotChats.id', - userId: 'copilotChats.userId', - workspaceId: 'copilotChats.workspaceId', - messages: 'copilotChats.messages', - conversationId: 'copilotChats.conversationId', - }, -})) - -vi.mock('@sim/db', () => ({ - db: { - transaction: mockTransaction, - }, +const { mockAppendCopilotChatMessages, mockPublishStatusChanged } = vi.hoisted(() => ({ + mockAppendCopilotChatMessages: vi.fn(), + mockPublishStatusChanged: vi.fn(), })) -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })), - eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), - sql: mockSql, +vi.mock('@/lib/copilot/chat/messages-store', () => ({ + appendCopilotChatMessages: mockAppendCopilotChatMessages, })) vi.mock('@/lib/copilot/tasks', () => ({ @@ -92,39 +32,33 @@ function createRequest(body: Record) { }) } +/** + * Sequence the two in-tx reads `finalizeAssistantTurn` issues: the chat row + * (`FOR UPDATE ... LIMIT 1`) and the last-message lookup that drives dedup + * (both terminate on `.limit(1)`). + */ +function mockReads(opts: { + chat: Record | null + last?: { messageId: string; role: string } +}) { + dbChainMockFns.limit.mockResolvedValueOnce(opts.chat ? [opts.chat] : []) + dbChainMockFns.limit.mockResolvedValueOnce(opts.last ? [opts.last] : []) +} + describe('copilot chat stop route', () => { beforeEach(() => { vi.clearAllMocks() - + // Drain the once-queue (clearAllMocks/resetDbChainMock don't), then restore defaults. + dbChainMockFns.limit.mockReset() + resetDbChainMock() authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) - - mockLimit.mockResolvedValue([ - { - workspaceId: 'ws-1', - messages: [{ id: 'stream-1', role: 'user', content: 'hello' }], - conversationId: 'stream-1', - }, - ]) - mockForUpdate.mockReturnValue({ limit: mockLimit }) - mockWhereSelect.mockReturnValue({ for: mockForUpdate }) - mockFrom.mockReturnValue({ where: mockWhereSelect }) - mockSelect.mockReturnValue({ from: mockFrom }) - - mockReturning.mockResolvedValue([{ workspaceId: 'ws-1' }]) - mockWhereUpdate.mockReturnValue({ returning: mockReturning }) - mockSet.mockReturnValue({ where: mockWhereUpdate }) - mockUpdate.mockReturnValue({ set: mockSet }) }) it('returns 401 when unauthenticated', async () => { authMockFns.mockGetSession.mockResolvedValueOnce(null) const response = await POST( - createRequest({ - chatId: 'chat-1', - streamId: 'stream-1', - content: '', - }) + createRequest({ chatId: 'chat-1', streamId: 'stream-1', content: '' }) ) expect(response.status).toBe(401) @@ -132,41 +66,37 @@ describe('copilot chat stop route', () => { }) it('is a no-op when the chat is missing', async () => { - mockLimit.mockResolvedValueOnce([]) + mockReads({ chat: null }) const response = await POST( - createRequest({ - chatId: 'missing-chat', - streamId: 'stream-1', - content: '', - }) + createRequest({ chatId: 'missing-chat', streamId: 'stream-1', content: '' }) ) expect(response.status).toBe(200) expect(await response.json()).toEqual({ success: true }) - expect(mockUpdate).not.toHaveBeenCalled() + expect(mockAppendCopilotChatMessages).not.toHaveBeenCalled() }) it('appends a stopped assistant message even with no content', async () => { + mockReads({ + chat: { workspaceId: 'ws-1', conversationId: 'stream-1', model: null }, + last: { messageId: 'stream-1', role: 'user' }, + }) + const response = await POST( - createRequest({ - chatId: 'chat-1', - streamId: 'stream-1', - content: '', - }) + createRequest({ chatId: 'chat-1', streamId: 'stream-1', content: '' }) ) expect(response.status).toBe(200) expect(await response.json()).toEqual({ success: true }) - const setArg = mockSet.mock.calls[0]?.[0] - expect(setArg).toBeTruthy() + const setArg = dbChainMockFns.set.mock.calls[0]?.[0] as Record expect(setArg.conversationId).toBeNull() - expect(setArg.messages).toBeTruthy() + expect(Object.hasOwn(setArg, 'messages')).toBe(false) - const appendedPayload = JSON.parse(setArg.messages.values[1] as string) - expect(appendedPayload).toHaveLength(1) - expect(appendedPayload[0]).toMatchObject({ + expect(mockAppendCopilotChatMessages).toHaveBeenCalledTimes(1) + const [, appended] = mockAppendCopilotChatMessages.mock.calls[0] + expect(appended[0]).toMatchObject({ role: 'assistant', content: '', contentBlocks: [{ type: 'complete', status: 'cancelled' }], @@ -181,32 +111,21 @@ describe('copilot chat stop route', () => { }) it('appends a stopped assistant message if the stream marker was already cleared', async () => { - mockLimit.mockResolvedValueOnce([ - { - workspaceId: 'ws-1', - messages: [{ id: 'stream-1', role: 'user', content: 'hello' }], - conversationId: null, - }, - ]) + mockReads({ + chat: { workspaceId: 'ws-1', conversationId: null, model: null }, + last: { messageId: 'stream-1', role: 'user' }, + }) const response = await POST( - createRequest({ - chatId: 'chat-1', - streamId: 'stream-1', - content: 'partial', - }) + createRequest({ chatId: 'chat-1', streamId: 'stream-1', content: 'partial' }) ) expect(response.status).toBe(200) expect(await response.json()).toEqual({ success: true }) - const setArg = mockSet.mock.calls[0]?.[0] - expect(setArg.messages).toBeTruthy() - const appendedPayload = JSON.parse(setArg.messages.values[1] as string) - expect(appendedPayload[0]).toMatchObject({ - role: 'assistant', - content: 'partial', - }) + expect(mockAppendCopilotChatMessages).toHaveBeenCalledTimes(1) + const [, appended] = mockAppendCopilotChatMessages.mock.calls[0] + expect(appended[0]).toMatchObject({ role: 'assistant', content: 'partial' }) expect(mockPublishStatusChanged).toHaveBeenCalledWith({ workspaceId: 'ws-1', @@ -217,28 +136,19 @@ describe('copilot chat stop route', () => { }) it('republishes completed status when the assistant was already persisted', async () => { - mockLimit.mockResolvedValueOnce([ - { - workspaceId: 'ws-1', - messages: [ - { id: 'stream-1', role: 'user', content: 'hello' }, - { id: 'assistant-1', role: 'assistant', content: 'partial' }, - ], - conversationId: null, - }, - ]) + mockReads({ + chat: { workspaceId: 'ws-1', conversationId: null, model: null }, + last: { messageId: 'assistant-1', role: 'assistant' }, + }) const response = await POST( - createRequest({ - chatId: 'chat-1', - streamId: 'stream-1', - content: 'partial', - }) + createRequest({ chatId: 'chat-1', streamId: 'stream-1', content: 'partial' }) ) expect(response.status).toBe(200) expect(await response.json()).toEqual({ success: true }) - expect(mockUpdate).not.toHaveBeenCalled() + expect(mockAppendCopilotChatMessages).not.toHaveBeenCalled() + expect(dbChainMockFns.set).not.toHaveBeenCalled() expect(mockPublishStatusChanged).toHaveBeenCalledWith({ workspaceId: 'ws-1', chatId: 'chat-1', diff --git a/apps/sim/app/api/copilot/chat/update-messages/route.test.ts b/apps/sim/app/api/copilot/chat/update-messages/route.test.ts index c56415116ab..2e7dfa134c8 100644 --- a/apps/sim/app/api/copilot/chat/update-messages/route.test.ts +++ b/apps/sim/app/api/copilot/chat/update-messages/route.test.ts @@ -16,6 +16,7 @@ const { mockSet, mockUpdateWhere, mockReturning, + mockReplaceCopilotChatMessages, } = vi.hoisted(() => ({ mockSelect: vi.fn(), mockFrom: vi.fn(), @@ -25,15 +26,23 @@ const { mockSet: vi.fn(), mockUpdateWhere: vi.fn(), mockReturning: vi.fn(), + mockReplaceCopilotChatMessages: vi.fn(), })) vi.mock('@sim/db', () => ({ db: { select: mockSelect, update: mockUpdate, + transaction: async ( + cb: (tx: { update: typeof mockUpdate; select: typeof mockSelect }) => unknown + ) => cb({ update: mockUpdate, select: mockSelect }), }, })) +vi.mock('@/lib/copilot/chat/messages-store', () => ({ + replaceCopilotChatMessages: mockReplaceCopilotChatMessages, +})) + vi.mock('drizzle-orm', () => ({ and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })), eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), @@ -257,10 +266,13 @@ describe('Copilot Chat Update Messages API Route', () => { expect(mockSelect).toHaveBeenCalled() expect(mockUpdate).toHaveBeenCalled() - expect(mockSet).toHaveBeenCalledWith({ + expect(mockSet).toHaveBeenCalledWith({ updatedAt: expect.any(Date) }) + expect(mockReplaceCopilotChatMessages).toHaveBeenCalledWith( + 'chat-123', messages, - updatedAt: expect.any(Date), - }) + { chatModel: 'gpt-4' }, + expect.anything() + ) }) it('should successfully update chat messages with optional fields', async () => { @@ -315,8 +327,10 @@ describe('Copilot Chat Update Messages API Route', () => { messageCount: 2, }) - expect(mockSet).toHaveBeenCalledWith({ - messages: [ + expect(mockSet).toHaveBeenCalledWith({ updatedAt: expect.any(Date) }) + expect(mockReplaceCopilotChatMessages).toHaveBeenCalledWith( + 'chat-456', + [ { id: 'msg-1', role: 'user', @@ -345,8 +359,9 @@ describe('Copilot Chat Update Messages API Route', () => { ], }, ], - updatedAt: expect.any(Date), - }) + { chatModel: 'gpt-4' }, + expect.anything() + ) }) it('should handle empty messages array', async () => { @@ -373,10 +388,13 @@ describe('Copilot Chat Update Messages API Route', () => { messageCount: 0, }) - expect(mockSet).toHaveBeenCalledWith({ - messages: [], - updatedAt: expect.any(Date), - }) + expect(mockSet).toHaveBeenCalledWith({ updatedAt: expect.any(Date) }) + expect(mockReplaceCopilotChatMessages).toHaveBeenCalledWith( + 'chat-789', + [], + { chatModel: 'gpt-4' }, + expect.anything() + ) }) it('should handle database errors during chat lookup', async () => { @@ -485,10 +503,13 @@ describe('Copilot Chat Update Messages API Route', () => { messageCount: 100, }) - expect(mockSet).toHaveBeenCalledWith({ + expect(mockSet).toHaveBeenCalledWith({ updatedAt: expect.any(Date) }) + expect(mockReplaceCopilotChatMessages).toHaveBeenCalledWith( + 'chat-large', messages, - updatedAt: expect.any(Date), - }) + { chatModel: 'gpt-4' }, + expect.anything() + ) }) it('should handle messages with both user and assistant roles', async () => { diff --git a/apps/sim/app/api/copilot/chat/update-messages/route.ts b/apps/sim/app/api/copilot/chat/update-messages/route.ts index 1b654c4930a..7c7792e3f2c 100644 --- a/apps/sim/app/api/copilot/chat/update-messages/route.ts +++ b/apps/sim/app/api/copilot/chat/update-messages/route.ts @@ -6,7 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { updateCopilotMessagesContract } from '@/lib/api/contracts/copilot' import { parseRequest } from '@/lib/api/server' import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle' -import { replaceCopilotChatMessages } from '@/lib/copilot/chat/messages-dual-write' +import { replaceCopilotChatMessages } from '@/lib/copilot/chat/messages-store' import { normalizeMessage, type PersistedMessage } from '@/lib/copilot/chat/persisted-message' import { authenticateCopilotRequestSessionOnly, @@ -73,9 +73,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { return createNotFoundResponse('Chat not found or unauthorized') } - // Update chat with new messages, plan artifact, and config const updateData: Record = { - messages: normalizedMessages, updatedAt: new Date(), } @@ -87,16 +85,20 @@ export const POST = withRouteHandler(async (req: NextRequest) => { updateData.config = config } - const [updated] = await db - .update(copilotChats) - .set(updateData) - .where(eq(copilotChats.id, chatId)) - .returning({ model: copilotChats.model }) - if (updated) { - await replaceCopilotChatMessages(chatId, normalizedMessages, { - chatModel: updated.model ?? null, - }) - } + await db.transaction(async (tx) => { + const [updated] = await tx + .update(copilotChats) + .set(updateData) + .where(eq(copilotChats.id, chatId)) + .returning({ model: copilotChats.model }) + if (!updated) return + await replaceCopilotChatMessages( + chatId, + normalizedMessages, + { chatModel: updated.model ?? null }, + tx + ) + }) logger.info(`[${tracker.requestId}] Successfully updated chat`, { chatId, diff --git a/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts b/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts index 251578bbd80..9ce957da1ef 100644 --- a/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts +++ b/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts @@ -94,16 +94,23 @@ describe('Copilot Checkpoints Revert API Route', () => { vi.spyOn(Date, 'now').mockReturnValue(1640995200000) const originalDate = Date - vi.spyOn(global, 'Date').mockImplementation(((...args: any[]) => { + const buildDate = (args: any[]): Date => { if (args.length === 0) { - const mockDate = new originalDate('2024-01-01T00:00:00.000Z') - return mockDate + return new originalDate('2024-01-01T00:00:00.000Z') } if (args.length === 1) { return new originalDate(args[0]) } return new originalDate(args[0], args[1], args[2], args[3], args[4], args[5], args[6]) - }) as any) + } + vi.spyOn(global, 'Date').mockImplementation( + class { + constructor(...args: any[]) { + // biome-ignore lint/correctness/noConstructorReturn: vitest 4 constructs mocks via Reflect.construct; returning a real Date overrides the instance so `new Date(...)` yields a genuine Date the route can call .toISOString()/.getTime() on + return buildDate(args) + } + } as any + ) }) afterEach(() => { diff --git a/apps/sim/app/api/files/authorization.test.ts b/apps/sim/app/api/files/authorization.test.ts new file mode 100644 index 00000000000..463a1bdf0c0 --- /dev/null +++ b/apps/sim/app/api/files/authorization.test.ts @@ -0,0 +1,153 @@ +/** + * Tests for KB file authorization (`verifyKBFileAccess` via `verifyFileAccess`). + * + * These lock in the security-critical contract: access is granted only when a + * trusted ownership binding names a workspace the caller can access AND an active + * document still references the exact key. A planted `document.fileUrl` (the + * reported vulnerability) can never grant access because ownership comes from the + * binding, not the document. + * + * @vitest-environment node + */ +import { dbChainMock, dbChainMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetFileMetadataByKey, mockGetUserEntityPermissions } = vi.hoisted(() => ({ + mockGetFileMetadataByKey: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), +})) + +vi.mock('@sim/db', () => dbChainMock) + +vi.mock('@/lib/uploads', () => ({ + getFileMetadata: vi.fn(), +})) + +vi.mock('@/lib/uploads/config', () => ({ + BLOB_CHAT_CONFIG: {}, + S3_CHAT_CONFIG: {}, +})) + +vi.mock('@/lib/uploads/server/metadata', () => ({ + getFileMetadataByKey: mockGetFileMetadataByKey, +})) + +vi.mock('@/lib/uploads/utils/file-utils', () => ({ + inferContextFromKey: vi.fn(() => 'knowledge-base'), +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetUserEntityPermissions, +})) + +vi.mock('@/executor/constants', () => ({ + isUuid: vi.fn(() => false), +})) + +import { verifyFileAccess, verifyKBFileWriteAccess } from '@/app/api/files/authorization' + +const CLOUD_KEY = 'kb/1780162789495-secret.txt' +const USER_ID = 'user-1' + +function grantAccess(cloudKey: string) { + return verifyFileAccess(cloudKey, USER_ID, undefined, 'knowledge-base') +} + +describe('verifyKBFileAccess (binding-only)', () => { + beforeEach(() => { + vi.clearAllMocks() + // Default liveness query result: one active document references the exact storage key. + dbChainMockFns.limit.mockResolvedValue([{ id: 'doc-1' }]) + }) + + it('grants access when the binding owner workspace is accessible and an active document references the key', async () => { + mockGetFileMetadataByKey.mockResolvedValue({ workspaceId: 'ws-1', deletedAt: null }) + mockGetUserEntityPermissions.mockResolvedValue('read') + + await expect(grantAccess(CLOUD_KEY)).resolves.toBe(true) + expect(mockGetUserEntityPermissions).toHaveBeenCalledWith(USER_ID, 'workspace', 'ws-1') + }) + + it('denies when the caller lacks permission on the owner workspace (cross-tenant)', async () => { + mockGetFileMetadataByKey.mockResolvedValue({ workspaceId: 'victim-ws', deletedAt: null }) + mockGetUserEntityPermissions.mockResolvedValue(null) + + await expect(grantAccess(CLOUD_KEY)).resolves.toBe(false) + }) + + it('denies when there is no ownership binding (planted or un-backfilled key)', async () => { + mockGetFileMetadataByKey.mockResolvedValue(null) + + await expect(grantAccess(CLOUD_KEY)).resolves.toBe(false) + // Authorization never consults workspace permissions without a binding. + expect(mockGetUserEntityPermissions).not.toHaveBeenCalled() + }) + + it('denies when the binding is soft-deleted', async () => { + mockGetFileMetadataByKey.mockResolvedValue({ workspaceId: 'ws-1', deletedAt: new Date() }) + + await expect(grantAccess(CLOUD_KEY)).resolves.toBe(false) + expect(mockGetUserEntityPermissions).not.toHaveBeenCalled() + }) + + it('denies when the binding has no workspace owner', async () => { + mockGetFileMetadataByKey.mockResolvedValue({ workspaceId: null, deletedAt: null }) + + await expect(grantAccess(CLOUD_KEY)).resolves.toBe(false) + expect(mockGetUserEntityPermissions).not.toHaveBeenCalled() + }) + + it('denies when no active document references the key (archived/soft-deleted KB liveness)', async () => { + mockGetFileMetadataByKey.mockResolvedValue({ workspaceId: 'ws-1', deletedAt: null }) + mockGetUserEntityPermissions.mockResolvedValue('admin') + dbChainMockFns.limit.mockResolvedValue([]) + + await expect(grantAccess(CLOUD_KEY)).resolves.toBe(false) + }) + + it('fails closed when the binding lookup throws', async () => { + mockGetFileMetadataByKey.mockRejectedValue(new Error('db down')) + + await expect(grantAccess(CLOUD_KEY)).resolves.toBe(false) + }) +}) + +describe('verifyKBFileWriteAccess (binding-only delete authorization)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('grants delete when the caller has write on the owner workspace', async () => { + mockGetFileMetadataByKey.mockResolvedValue({ workspaceId: 'ws-1', deletedAt: null }) + mockGetUserEntityPermissions.mockResolvedValue('write') + + await expect(verifyKBFileWriteAccess(CLOUD_KEY, USER_ID)).resolves.toBe(true) + }) + + it('grants delete when the caller is admin on the owner workspace', async () => { + mockGetFileMetadataByKey.mockResolvedValue({ workspaceId: 'ws-1', deletedAt: null }) + mockGetUserEntityPermissions.mockResolvedValue('admin') + + await expect(verifyKBFileWriteAccess(CLOUD_KEY, USER_ID)).resolves.toBe(true) + }) + + it('denies delete when the caller has only read on the owner workspace', async () => { + mockGetFileMetadataByKey.mockResolvedValue({ workspaceId: 'ws-1', deletedAt: null }) + mockGetUserEntityPermissions.mockResolvedValue('read') + + await expect(verifyKBFileWriteAccess(CLOUD_KEY, USER_ID)).resolves.toBe(false) + }) + + it('denies delete when there is no binding (no fallback)', async () => { + mockGetFileMetadataByKey.mockResolvedValue(null) + + await expect(verifyKBFileWriteAccess(CLOUD_KEY, USER_ID)).resolves.toBe(false) + expect(mockGetUserEntityPermissions).not.toHaveBeenCalled() + }) + + it('fails closed when the binding lookup throws', async () => { + mockGetFileMetadataByKey.mockRejectedValue(new Error('db down')) + + await expect(verifyKBFileWriteAccess(CLOUD_KEY, USER_ID)).resolves.toBe(false) + }) +}) diff --git a/apps/sim/app/api/files/authorization.ts b/apps/sim/app/api/files/authorization.ts index 07ec36261b4..b2cc73c429f 100644 --- a/apps/sim/app/api/files/authorization.ts +++ b/apps/sim/app/api/files/authorization.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { document, knowledgeBase, workspaceFile } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, isNull, like, or } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import { NextResponse } from 'next/server' import { getFileMetadata } from '@/lib/uploads' import type { StorageContext } from '@/lib/uploads/config' @@ -28,6 +28,21 @@ interface AuthorizationResult { workspaceId?: string } +type WorkspacePermission = 'read' | 'write' | 'admin' + +/** + * Whether a resolved workspace permission satisfies a file operation. Read and + * download paths accept any membership; destructive operations (`requireWrite`) + * require write or admin, matching the permission needed to create the file. + */ +function workspacePermissionSatisfies( + permission: WorkspacePermission | null, + requireWrite: boolean +): boolean { + if (permission === null) return false + return requireWrite ? permission === 'write' || permission === 'admin' : true +} + /** * Lookup workspace file by storage key from database * @param key Storage key to lookup @@ -117,11 +132,13 @@ export async function verifyFileAccess( userId: string, customConfig?: StorageConfig, context?: StorageContext | 'general', - isLocal?: boolean + isLocal?: boolean, + options?: { requireWrite?: boolean } ): Promise { + const requireWrite = options?.requireWrite ?? false try { if (context === 'general') { - return await verifyRegularFileAccess(cloudKey, userId, customConfig, isLocal) + return await verifyRegularFileAccess(cloudKey, userId, customConfig, isLocal, requireWrite) } // Infer context from key if not explicitly provided @@ -139,12 +156,12 @@ export async function verifyFileAccess( // 1. Workspace / mothership files: Check database first (most reliable for both local and cloud) if (inferredContext === 'workspace' || inferredContext === 'mothership') { - return await verifyWorkspaceFileAccess(cloudKey, userId, customConfig, isLocal) + return await verifyWorkspaceFileAccess(cloudKey, userId, customConfig, isLocal, requireWrite) } // 2. Execution files: workspace_id/workflow_id/execution_id/filename if (inferredContext === 'execution') { - return await verifyExecutionFileAccess(cloudKey, userId, customConfig) + return await verifyExecutionFileAccess(cloudKey, userId, customConfig, requireWrite) } // 3. Copilot files: Check database first, then metadata, then path pattern (legacy) @@ -159,12 +176,12 @@ export async function verifyFileAccess( // 5. Chat files: chat/filename if (inferredContext === 'chat') { - return await verifyChatFileAccess(cloudKey, userId, customConfig) + return await verifyChatFileAccess(cloudKey, userId, customConfig, requireWrite) } // 6. Regular uploads: UUID-filename or timestamp-filename // Check metadata for userId/workspaceId, or database for workspace files - return await verifyRegularFileAccess(cloudKey, userId, customConfig, isLocal) + return await verifyRegularFileAccess(cloudKey, userId, customConfig, isLocal, requireWrite) } catch (error) { logger.error('Error verifying file access:', { cloudKey, userId, error }) // Deny access on error to be safe @@ -180,7 +197,8 @@ async function verifyWorkspaceFileAccess( cloudKey: string, userId: string, customConfig?: StorageConfig, - isLocal?: boolean + isLocal?: boolean, + requireWrite = false ): Promise { try { const anyWorkspaceFileRecord = await getFileMetadataByKey(cloudKey, 'workspace', { @@ -202,7 +220,7 @@ async function verifyWorkspaceFileAccess( 'workspace', workspaceFileRecord.workspaceId ) - if (permission !== null) { + if (workspacePermissionSatisfies(permission, requireWrite)) { logger.debug('Workspace file access granted (database lookup)', { userId, workspaceId: workspaceFileRecord.workspaceId, @@ -225,7 +243,7 @@ async function verifyWorkspaceFileAccess( if (workspaceId) { const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (permission !== null) { + if (workspacePermissionSatisfies(permission, requireWrite)) { logger.debug('Workspace file access granted (metadata)', { userId, workspaceId, @@ -257,7 +275,8 @@ async function verifyWorkspaceFileAccess( async function verifyExecutionFileAccess( cloudKey: string, userId: string, - customConfig?: StorageConfig + customConfig?: StorageConfig, + requireWrite = false ): Promise { const parts = cloudKey.split('/') @@ -285,7 +304,7 @@ async function verifyExecutionFileAccess( } const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (permission === null) { + if (!workspacePermissionSatisfies(permission, requireWrite)) { logger.warn('User does not have workspace access for execution file', { userId, workspaceId, @@ -371,8 +390,46 @@ async function verifyCopilotFileAccess( } /** - * Verify access to KB files - * KB files: kb/filename + * Whether an active KB document (non-archived/excluded/deleted, in a + * non-deleted KB) in the owning workspace references exactly `cloudKey`, matched + * on the document's persisted canonical `storageKey`. This is an exact, indexed + * lookup — no URL parsing or wildcard matching at read time. It is a lifecycle + * signal only: it reflects whether the file is still part of a live KB, not who + * owns it (ownership comes from the binding). + */ +async function hasActiveKbDocumentForKey(cloudKey: string, workspaceId: string): Promise { + const rows = await db + .select({ id: document.id }) + .from(document) + .innerJoin(knowledgeBase, eq(document.knowledgeBaseId, knowledgeBase.id)) + .where( + and( + eq(knowledgeBase.workspaceId, workspaceId), + eq(document.storageKey, cloudKey), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt), + isNull(knowledgeBase.deletedAt) + ) + ) + .limit(1) + + return rows.length > 0 +} + +/** + * Verify access to KB files (`kb/`). + * + * Authorization is determined entirely by clear state: + * 1. Ownership — the trusted `workspace_files` binding (exact key) names the + * owning workspace; the caller must have permission on it. Ownership is + * never inferred from an attacker-authorable `document.fileUrl`. + * 2. Liveness — an active document must still reference the exact key, so the + * retained bytes of an archived document or soft-deleted KB are not + * downloadable (the liveness document is not an authorization signal). + * + * A missing binding denies (the ownership backfill populates bindings for + * pre-existing objects before this path is deployed). */ async function verifyKBFileAccess( cloudKey: string, @@ -380,64 +437,83 @@ async function verifyKBFileAccess( customConfig?: StorageConfig ): Promise { try { - const activeKbFileDocuments = await db - .select({ - workspaceId: knowledgeBase.workspaceId, - }) - .from(document) - .innerJoin(knowledgeBase, eq(document.knowledgeBaseId, knowledgeBase.id)) - .where( - and( - eq(document.userExcluded, false), - isNull(document.archivedAt), - isNull(document.deletedAt), - isNull(knowledgeBase.deletedAt), - or( - like(document.fileUrl, `%${cloudKey}%`), - like(document.fileUrl, `%${encodeURIComponent(cloudKey)}%`) - ) - ) - ) - .limit(10) - - for (const doc of activeKbFileDocuments) { - if (!doc.workspaceId) { - continue - } + const binding = await getFileMetadataByKey(cloudKey, 'knowledge-base', { + includeDeleted: true, + }) - const permission = await getUserEntityPermissions(userId, 'workspace', doc.workspaceId) - if (permission !== null) { - logger.debug('KB file access granted (active document lookup)', { - userId, - workspaceId: doc.workspaceId, - cloudKey, - }) - return true - } + if (!binding) { + logger.warn('KB file access denied: no ownership binding', { userId, cloudKey }) + return false + } + if (binding.deletedAt) { + logger.warn('KB file access denied for deleted file binding', { userId, cloudKey }) + return false + } + if (!binding.workspaceId) { + logger.warn('KB file binding missing workspace owner', { userId, cloudKey }) + return false } - // KB file access must resolve through an active KB document. Metadata alone is not enough - // because parent archives intentionally keep the underlying file bytes around for history. - const fileRecord = await getFileMetadataByKey(cloudKey, 'knowledge-base', { - includeDeleted: true, - }) + const permission = await getUserEntityPermissions(userId, 'workspace', binding.workspaceId) + if (permission === null) { + logger.warn('User does not have workspace access for KB file', { + userId, + workspaceId: binding.workspaceId, + cloudKey, + }) + return false + } - if (fileRecord?.deletedAt) { - logger.warn('KB file access denied for deleted file metadata', { userId, cloudKey }) + if (!(await hasActiveKbDocumentForKey(cloudKey, binding.workspaceId))) { + logger.warn('KB file access denied: no active document references the file', { + userId, + cloudKey, + }) return false } - logger.warn('KB file access denied because no active KB document matched the file', { - cloudKey, + logger.debug('KB file access granted (ownership binding)', { userId, + workspaceId: binding.workspaceId, + cloudKey, }) - return false + return true } catch (error) { logger.error('Error verifying KB file access', { cloudKey, userId, error }) return false } } +/** + * Authorize a destructive operation (delete) on a KB file. + * + * Binding-only: resolves the owning workspace from the trusted ownership binding + * and requires write/admin permission. Never uses the transitional read fallback, + * so a not-yet-bound key cannot be deleted cross-tenant. + */ +export async function verifyKBFileWriteAccess(cloudKey: string, userId: string): Promise { + try { + const binding = await getFileMetadataByKey(cloudKey, 'knowledge-base') + if (!binding?.workspaceId) { + logger.warn('KB file delete denied: no ownership binding', { userId, cloudKey }) + return false + } + const permission = await getUserEntityPermissions(userId, 'workspace', binding.workspaceId) + if (permission !== 'write' && permission !== 'admin') { + logger.warn('KB file delete denied: write/admin required on owner workspace', { + userId, + workspaceId: binding.workspaceId, + cloudKey, + }) + return false + } + return true + } catch (error) { + logger.error('Error verifying KB file write access', { cloudKey, userId, error }) + return false + } +} + /** * Verify access to chat files * Chat files: chat/filename @@ -445,7 +521,8 @@ async function verifyKBFileAccess( async function verifyChatFileAccess( cloudKey: string, userId: string, - customConfig?: StorageConfig + customConfig?: StorageConfig, + requireWrite = false ): Promise { try { const config: StorageConfig = customConfig || (await getChatStorageConfig()) @@ -459,7 +536,7 @@ async function verifyChatFileAccess( } const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (permission === null) { + if (!workspacePermissionSatisfies(permission, requireWrite)) { logger.warn('User does not have workspace access for chat file', { userId, workspaceId, @@ -485,7 +562,8 @@ async function verifyRegularFileAccess( cloudKey: string, userId: string, customConfig?: StorageConfig, - isLocal?: boolean + isLocal?: boolean, + requireWrite = false ): Promise { try { // Priority 1: Check if this might be a workspace file (check database) @@ -497,7 +575,7 @@ async function verifyRegularFileAccess( 'workspace', workspaceFileRecord.workspaceId ) - if (permission !== null) { + if (workspacePermissionSatisfies(permission, requireWrite)) { logger.debug('Regular file access granted (workspace file from database)', { userId, workspaceId: workspaceFileRecord.workspaceId, @@ -532,7 +610,7 @@ async function verifyRegularFileAccess( // If file has workspaceId, verify workspace membership if (workspaceId) { const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (permission !== null) { + if (workspacePermissionSatisfies(permission, requireWrite)) { logger.debug('Regular file access granted (workspace membership)', { userId, workspaceId, diff --git a/apps/sim/app/api/files/delete/route.ts b/apps/sim/app/api/files/delete/route.ts index 4eeeb538747..c483faa3c6b 100644 --- a/apps/sim/app/api/files/delete/route.ts +++ b/apps/sim/app/api/files/delete/route.ts @@ -9,7 +9,7 @@ import type { StorageContext } from '@/lib/uploads/config' import { deleteFile, hasCloudStorage } from '@/lib/uploads/core/storage-service' import { deleteFileMetadata } from '@/lib/uploads/server/metadata' import { extractStorageKey, inferContextFromKey } from '@/lib/uploads/utils/file-utils' -import { verifyFileAccess } from '@/app/api/files/authorization' +import { verifyFileAccess, verifyKBFileWriteAccess } from '@/app/api/files/authorization' import { createErrorResponse, createSuccessResponse, @@ -64,13 +64,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const storageContext: StorageContext = context || inferContextFromKey(key) - const hasAccess = await verifyFileAccess( - key, - userId, - undefined, // customConfig - storageContext, // context - !hasCloudStorage() // isLocal - ) + // Deletes require write/admin on the owning workspace (owner-scoped files + // like copilot/regular uploads still authorize by ownership). KB deletes + // are binding-only and never use the transitional read fallback that file + // serving allows. + const hasAccess = + storageContext === 'knowledge-base' + ? await verifyKBFileWriteAccess(key, userId) + : await verifyFileAccess( + key, + userId, + undefined, // customConfig + storageContext, // context + !hasCloudStorage(), // isLocal + { requireWrite: true } + ) if (!hasAccess) { logger.warn('Unauthorized file delete attempt', { userId, key, context: storageContext }) diff --git a/apps/sim/app/api/files/multipart/route.ts b/apps/sim/app/api/files/multipart/route.ts index 8612f0b1f83..fbdb4e4016a 100644 --- a/apps/sim/app/api/files/multipart/route.ts +++ b/apps/sim/app/api/files/multipart/route.ts @@ -18,11 +18,13 @@ import { isUsingCloudStorage, type StorageContext, } from '@/lib/uploads' +import { deleteFile } from '@/lib/uploads/core/storage-service' import { signUploadToken, type UploadTokenPayload, verifyUploadToken, } from '@/lib/uploads/core/upload-token' +import { recordKnowledgeBaseFileOwnership } from '@/lib/uploads/server/metadata' import { QUOTA_EXEMPT_STORAGE_CONTEXTS, type StorageConfig } from '@/lib/uploads/shared/types' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -82,6 +84,28 @@ const verifyTokenForUser = (token: string | undefined, userId: string) => { return result.payload } +/** + * Record a trusted storage-key -> workspace ownership binding for completed + * knowledge-base uploads. KB file authorization resolves the owning workspace + * from this binding, so every KB object must have one. No-op for other contexts. + */ +const recordKnowledgeBaseOwnership = async ( + payload: UploadTokenPayload, + key: string +): Promise => { + if (payload.context !== 'knowledge-base' || !payload.workspaceId) { + return + } + await recordKnowledgeBaseFileOwnership({ + key, + userId: payload.userId, + workspaceId: payload.workspaceId, + originalName: payload.fileName ?? key.split('/').pop() ?? key, + contentType: payload.contentType ?? 'application/octet-stream', + size: typeof payload.fileSize === 'number' ? payload.fileSize : 0, + }) +} + export const POST = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() @@ -220,6 +244,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId, workspaceId, context: storageContext, + fileName, + contentType, + ...(typeof fileSize === 'number' ? { fileSize } : {}), }) logger.info( @@ -301,6 +328,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const { uploadId, key, context } = payload const config = getStorageConfig(context) + let completed: { location: string; path: string; key: string } if (storageProvider === 's3' && s3Module) { const { completeS3MultipartUpload } = s3Module const s3Parts = parts.map((p) => { @@ -309,40 +337,41 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } return { ETag: p.etag, PartNumber: p.partNumber } }) - const result = await completeS3MultipartUpload( + completed = await completeS3MultipartUpload( key, uploadId, s3Parts, buildS3CustomConfig(config) ) - return { - success: true as const, - location: result.location, - path: result.path, - key: result.key, - } - } - - if (storageProvider === 'blob' && blobModule) { + } else if (storageProvider === 'blob' && blobModule) { const { completeMultipartUpload, deriveBlobBlockId } = blobModule const blobParts = parts.map((p) => ({ partNumber: p.partNumber, blockId: deriveBlobBlockId(p.partNumber), })) - const result = await completeMultipartUpload( - key, - blobParts, - buildBlobCustomConfig(config) - ) - return { - success: true as const, - location: result.location, - path: result.path, - key: result.key, + completed = await completeMultipartUpload(key, blobParts, buildBlobCustomConfig(config)) + } else { + throw new Error(`Unsupported storage provider: ${storageProvider}`) + } + + try { + await recordKnowledgeBaseOwnership(payload, completed.key) + } catch (error) { + // The object is committed, but without an ownership binding a KB file + // is unreadable and undeletable via the KB paths. Remove the orphan + // best-effort and surface a retryable error so the client re-uploads. + if (payload.context === 'knowledge-base') { + await deleteFile({ key: completed.key, context: 'knowledge-base' }).catch(() => {}) } + throw error } - throw new Error(`Unsupported storage provider: ${storageProvider}`) + return { + success: true as const, + location: completed.location, + path: completed.path, + key: completed.key, + } } if ('uploads' in data && Array.isArray(data.uploads)) { diff --git a/apps/sim/app/api/files/presigned/batch/route.ts b/apps/sim/app/api/files/presigned/batch/route.ts index ac5015c9a7d..846c381d051 100644 --- a/apps/sim/app/api/files/presigned/batch/route.ts +++ b/apps/sim/app/api/files/presigned/batch/route.ts @@ -13,7 +13,9 @@ import { generateBatchPresignedUploadUrls, hasCloudStorage, } from '@/lib/uploads/core/storage-service' +import { recordKnowledgeBaseFileOwnershipMany } from '@/lib/uploads/server/metadata' import { validateFileType } from '@/lib/uploads/utils/validation' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { createErrorResponse } from '@/app/api/files/utils' const logger = createLogger('BatchPresignedUploadAPI') @@ -60,6 +62,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const uploadType = uploadTypeResult.data as StorageContext + const sessionUserId = session.user.id + + let knowledgeBaseWorkspaceId: string | null = null if (uploadType === 'knowledge-base') { for (const file of files) { const fileValidationError = validateFileType(file.fileName, file.contentType) @@ -74,9 +79,27 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } } - } - const sessionUserId = session.user.id + knowledgeBaseWorkspaceId = request.nextUrl.searchParams.get('workspaceId') + if (!knowledgeBaseWorkspaceId?.trim()) { + return NextResponse.json( + { error: 'workspaceId query parameter is required for knowledge-base uploads' }, + { status: 400 } + ) + } + + const permission = await getUserEntityPermissions( + sessionUserId, + 'workspace', + knowledgeBaseWorkspaceId + ) + if (permission !== 'write' && permission !== 'admin') { + return NextResponse.json( + { error: 'Write or Admin access required for knowledge-base uploads' }, + { status: 403 } + ) + } + } if (uploadType === 'copilot' && !sessionUserId?.trim()) { return NextResponse.json( @@ -126,6 +149,20 @@ export const POST = withRouteHandler(async (request: NextRequest) => { `Generated ${files.length} presigned URLs in ${duration}ms (avg ${Math.round(duration / files.length)}ms per file)` ) + if (uploadType === 'knowledge-base' && knowledgeBaseWorkspaceId) { + const ownerWorkspaceId = knowledgeBaseWorkspaceId + await recordKnowledgeBaseFileOwnershipMany( + presignedUrls.map((urlResponse, index) => ({ + key: urlResponse.key, + userId: sessionUserId, + workspaceId: ownerWorkspaceId, + originalName: files[index].fileName, + contentType: files[index].contentType, + size: files[index].fileSize, + })) + ) + } + const storagePrefix = USE_BLOB_STORAGE ? 'blob' : 's3' return NextResponse.json({ diff --git a/apps/sim/app/api/files/presigned/route.test.ts b/apps/sim/app/api/files/presigned/route.test.ts index 9abfa5be2d4..b8c3d046573 100644 --- a/apps/sim/app/api/files/presigned/route.test.ts +++ b/apps/sim/app/api/files/presigned/route.test.ts @@ -93,6 +93,8 @@ vi.mock('@/lib/uploads/contexts/execution/utils', () => ({ vi.mock('@/lib/uploads/server/metadata', () => ({ insertFileMetadata: mockInsertFileMetadata, + recordKnowledgeBaseFileOwnership: (ownership: Record) => + mockInsertFileMetadata({ ...ownership, context: 'knowledge-base' }), })) vi.mock('@/lib/uploads/utils/file-utils', () => ({ @@ -352,7 +354,7 @@ describe('/api/files/presigned', () => { }) const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=knowledge-base', + 'http://localhost:3000/api/files/presigned?type=knowledge-base&workspaceId=ws-1', { method: 'POST', body: JSON.stringify({ @@ -810,7 +812,7 @@ describe('/api/files/presigned', () => { setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=knowledge-base', + 'http://localhost:3000/api/files/presigned?type=knowledge-base&workspaceId=ws-1', { method: 'POST', body: JSON.stringify({ @@ -826,5 +828,44 @@ describe('/api/files/presigned', () => { expect(mockValidateFileType).toHaveBeenCalledWith('doc.pdf', 'application/pdf') expect(mockValidateAttachmentFileType).not.toHaveBeenCalled() }) + + it('requires workspaceId for knowledge-base uploads', async () => { + setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) + + const request = new NextRequest( + 'http://localhost:3000/api/files/presigned?type=knowledge-base', + { + method: 'POST', + body: JSON.stringify({ + fileName: 'doc.pdf', + contentType: 'application/pdf', + fileSize: 4096, + }), + } + ) + + const response = await POST(request) + expect(response.status).toBe(400) + }) + + it('returns 403 when the user lacks write access to the workspace', async () => { + setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) + mockGetUserEntityPermissions.mockResolvedValue('read') + + const request = new NextRequest( + 'http://localhost:3000/api/files/presigned?type=knowledge-base&workspaceId=ws-1', + { + method: 'POST', + body: JSON.stringify({ + fileName: 'doc.pdf', + contentType: 'application/pdf', + fileSize: 4096, + }), + } + ) + + const response = await POST(request) + expect(response.status).toBe(403) + }) }) }) diff --git a/apps/sim/app/api/files/presigned/route.ts b/apps/sim/app/api/files/presigned/route.ts index 3312434f04d..99c1eec2db6 100644 --- a/apps/sim/app/api/files/presigned/route.ts +++ b/apps/sim/app/api/files/presigned/route.ts @@ -11,7 +11,7 @@ import { USE_BLOB_STORAGE } from '@/lib/uploads/config' import { generateExecutionFileKey } from '@/lib/uploads/contexts/execution/utils' import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { generatePresignedUploadUrl, hasCloudStorage } from '@/lib/uploads/core/storage-service' -import { insertFileMetadata } from '@/lib/uploads/server/metadata' +import { insertFileMetadata, recordKnowledgeBaseFileOwnership } from '@/lib/uploads/server/metadata' import { isImageFileType } from '@/lib/uploads/utils/file-utils' import { validateAttachmentFileType, validateFileType } from '@/lib/uploads/utils/validation' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -252,6 +252,40 @@ export const POST = withRouteHandler(async (request: NextRequest) => { contentType, size: fileSize, }) + } else if (uploadType === 'knowledge-base') { + const workspaceId = request.nextUrl.searchParams.get('workspaceId') + if (!workspaceId?.trim()) { + throw new ValidationError( + 'workspaceId query parameter is required for knowledge-base uploads' + ) + } + + const permission = await getUserEntityPermissions(sessionUserId, 'workspace', workspaceId) + if (permission !== 'write' && permission !== 'admin') { + return NextResponse.json( + { error: 'Write or Admin access required for knowledge-base uploads' }, + { status: 403 } + ) + } + + presignedUrlResponse = await generatePresignedUploadUrl({ + fileName, + contentType, + fileSize, + context: 'knowledge-base', + userId: sessionUserId, + expirationSeconds: 3600, + metadata: { workspaceId }, + }) + + await recordKnowledgeBaseFileOwnership({ + key: presignedUrlResponse.key, + userId: sessionUserId, + workspaceId, + originalName: fileName, + contentType, + size: fileSize, + }) } else { if (uploadType === 'profile-pictures') { if (!sessionUserId?.trim()) { diff --git a/apps/sim/app/api/files/upload/route.ts b/apps/sim/app/api/files/upload/route.ts index 52e6f3a5116..c5835858647 100644 --- a/apps/sim/app/api/files/upload/route.ts +++ b/apps/sim/app/api/files/upload/route.ts @@ -141,18 +141,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { throw new InvalidRequestError(validationError.message) } - if (workspaceId) { - const permission = await getUserEntityPermissions( - session.user.id, - 'workspace', - workspaceId + if (!workspaceId) { + throw new InvalidRequestError('workspaceId is required for knowledge-base uploads') + } + + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (permission !== 'write' && permission !== 'admin') { + return NextResponse.json( + { error: 'Write or Admin access required for knowledge-base uploads' }, + { status: 403 } ) - if (permission === null) { - return NextResponse.json( - { error: 'Insufficient permissions for workspace' }, - { status: 403 } - ) - } } logger.info(`Uploading knowledge-base file: ${originalName}`) @@ -166,10 +164,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { uploadedAt: new Date().toISOString(), purpose: 'knowledge-base', userId: session.user.id, - } - - if (workspaceId) { - metadata.workspaceId = workspaceId + workspaceId, } const fileInfo = await storageService.uploadFile({ diff --git a/apps/sim/app/api/folders/[id]/route.test.ts b/apps/sim/app/api/folders/[id]/route.test.ts index a60e552dc06..95cb3d53b05 100644 --- a/apps/sim/app/api/folders/[id]/route.test.ts +++ b/apps/sim/app/api/folders/[id]/route.test.ts @@ -465,22 +465,27 @@ describe('Individual Folder API Route', () => { expect(response.status).toBe(403) const data = await response.json() - expect(data).toHaveProperty('error', 'Admin access required to delete folders') + expect(data).toHaveProperty('error', 'Write or Admin access required to delete folders') }) - it('should return 403 when user has only write permissions for delete', async () => { + it('should allow folder deletion for write permissions', async () => { mockAuthenticatedUser() mockGetUserEntityPermissions.mockResolvedValue('write') + mockDbRef.current = createFolderDbMock({ + folderLookupResult: mockFolder, + }) + const req = createMockRequest('DELETE') const params = Promise.resolve({ id: 'folder-1' }) const response = await DELETE(req, { params }) - expect(response.status).toBe(403) + expect(response.status).toBe(200) const data = await response.json() - expect(data).toHaveProperty('error', 'Admin access required to delete folders') + expect(data).toHaveProperty('success', true) + expect(mockPerformDeleteFolder).toHaveBeenCalled() }) it('should allow folder deletion for admin permissions', async () => { diff --git a/apps/sim/app/api/folders/[id]/route.ts b/apps/sim/app/api/folders/[id]/route.ts index bc622793bc4..26d5f218005 100644 --- a/apps/sim/app/api/folders/[id]/route.ts +++ b/apps/sim/app/api/folders/[id]/route.ts @@ -140,9 +140,9 @@ export const DELETE = withRouteHandler( existingFolder.workspaceId ) - if (workspacePermission !== 'admin') { + if (!workspacePermission || workspacePermission === 'read') { return NextResponse.json( - { error: 'Admin access required to delete folders' }, + { error: 'Write or Admin access required to delete folders' }, { status: 403 } ) } diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts index 1e13574c61d..d2e4ff37804 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts @@ -359,10 +359,15 @@ export const DELETE = withRouteHandler(async (request: NextRequest, { params }: return { deletedDocs: deleteDocuments ? docs : [], docCount: docs.length } }) + const kbWorkspaceId = writeCheck.knowledgeBase?.workspaceId ?? null + if (deleteDocuments) { await Promise.all([ deletedDocs.length > 0 - ? deleteDocumentStorageFiles(deletedDocs, requestId) + ? deleteDocumentStorageFiles( + deletedDocs.map((doc) => ({ ...doc, workspaceId: kbWorkspaceId })), + requestId + ) : Promise.resolve(), cleanupUnusedTagDefinitions(knowledgeBaseId, requestId).catch((error) => { logger.warn(`[${requestId}] Failed to cleanup tag definitions`, error) @@ -374,13 +379,12 @@ export const DELETE = withRouteHandler(async (request: NextRequest, { params }: `[${requestId}] Deleted connector ${connectorId}${deleteDocuments ? ` and ${docCount} documents` : `, kept ${docCount} documents`}` ) - const kbWorkspaceId = writeCheck.knowledgeBase.workspaceId ?? '' captureServerEvent( auth.userId, 'knowledge_base_connector_removed', { knowledge_base_id: knowledgeBaseId, - workspace_id: kbWorkspaceId, + workspace_id: kbWorkspaceId ?? '', connector_type: existingConnector[0].connectorType, documents_deleted: deleteDocuments ? docCount : 0, }, diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/[chunkId]/route.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/[chunkId]/route.ts index 10594ff6635..ba225c3ecf4 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/[chunkId]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/[chunkId]/route.ts @@ -6,7 +6,7 @@ import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { deleteChunk, updateChunk } from '@/lib/knowledge/chunks/service' -import { checkChunkAccess } from '@/app/api/knowledge/utils' +import { checkChunkAccess, checkChunkWriteAccess } from '@/app/api/knowledge/utils' const logger = createLogger('ChunkByIdAPI') @@ -75,7 +75,7 @@ export const PUT = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const accessCheck = await checkChunkAccess( + const accessCheck = await checkChunkWriteAccess( knowledgeBaseId, documentId, chunkId, @@ -147,7 +147,7 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const accessCheck = await checkChunkAccess( + const accessCheck = await checkChunkWriteAccess( knowledgeBaseId, documentId, chunkId, diff --git a/apps/sim/app/api/knowledge/[id]/documents/route.test.ts b/apps/sim/app/api/knowledge/[id]/documents/route.test.ts index d66f2cdd402..dc770f1520b 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/route.test.ts @@ -40,6 +40,7 @@ vi.mock('@/lib/knowledge/documents/service', () => ({ deleteDocument: vi.fn(), markDocumentAsFailedTimeout: vi.fn(), retryDocumentProcessing: vi.fn(), + KnowledgeBaseFileOwnershipError: class KnowledgeBaseFileOwnershipError extends Error {}, })) vi.mock('@sim/audit', () => auditMock) diff --git a/apps/sim/app/api/knowledge/[id]/documents/route.ts b/apps/sim/app/api/knowledge/[id]/documents/route.ts index 128f70ae37c..b159fcf52f1 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/route.ts @@ -20,6 +20,7 @@ import { createSingleDocument, getDocuments, getProcessingConfig, + KnowledgeBaseFileOwnershipError, processDocumentsWithQueue, type TagFilterCondition, } from '@/lib/knowledge/documents/service' @@ -302,6 +303,13 @@ export const POST = withRouteHandler( } catch (error) { logger.error(`[${requestId}] Error creating document`, error) + if (error instanceof KnowledgeBaseFileOwnershipError) { + return NextResponse.json( + { error: 'File URL does not reference a file owned by this knowledge base' }, + { status: 403 } + ) + } + const errorMessage = getErrorMessage(error, 'Failed to create document') const isStorageLimitError = errorMessage.includes('Storage limit exceeded') || errorMessage.includes('storage limit') diff --git a/apps/sim/app/api/knowledge/[id]/documents/upsert/route.test.ts b/apps/sim/app/api/knowledge/[id]/documents/upsert/route.test.ts index d5f64cf306e..212aebce619 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/upsert/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/upsert/route.test.ts @@ -32,6 +32,7 @@ vi.mock('@/lib/knowledge/documents/service', () => ({ deleteDocument: vi.fn(), getProcessingConfig: vi.fn().mockReturnValue({ maxConcurrentDocuments: 1, batchSize: 1 }), processDocumentsWithQueue: vi.fn(), + KnowledgeBaseFileOwnershipError: class KnowledgeBaseFileOwnershipError extends Error {}, })) import { createDocumentRecords, processDocumentsWithQueue } from '@/lib/knowledge/documents/service' diff --git a/apps/sim/app/api/knowledge/[id]/documents/upsert/route.ts b/apps/sim/app/api/knowledge/[id]/documents/upsert/route.ts index 4bde63da800..b2cb988c37a 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/upsert/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/upsert/route.ts @@ -15,6 +15,7 @@ import { createDocumentRecords, deleteDocument, getProcessingConfig, + KnowledgeBaseFileOwnershipError, processDocumentsWithQueue, } from '@/lib/knowledge/documents/service' import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' @@ -219,6 +220,13 @@ export const POST = withRouteHandler( } catch (error) { logger.error(`[${requestId}] Error upserting document`, error) + if (error instanceof KnowledgeBaseFileOwnershipError) { + return NextResponse.json( + { error: 'File URL does not reference a file owned by this knowledge base' }, + { status: 403 } + ) + } + const errorMessage = getErrorMessage(error, 'Failed to upsert document') const isStorageLimitError = errorMessage.includes('Storage limit exceeded') || errorMessage.includes('storage limit') diff --git a/apps/sim/app/api/knowledge/utils.ts b/apps/sim/app/api/knowledge/utils.ts index 9dac8ffdb8e..13c9079202f 100644 --- a/apps/sim/app/api/knowledge/utils.ts +++ b/apps/sim/app/api/knowledge/utils.ts @@ -153,11 +153,16 @@ interface ChunkAccessDenied { export type ChunkAccessCheck = ChunkAccessResult | ChunkAccessDenied /** - * Check if a user has access to a knowledge base + * Resolve knowledge-base access for a user, gated by read or write permission. + * + * Read (`requireWrite: false`) grants on any workspace permission; write + * (`requireWrite: true`) requires `write`/`admin`. Legacy non-workspace KBs grant + * to the owning user in both modes. */ -export async function checkKnowledgeBaseAccess( +async function resolveKnowledgeBaseAccess( knowledgeBaseId: string, - userId: string + userId: string, + requireWrite: boolean ): Promise { const kb = await db .select({ @@ -180,10 +185,10 @@ export async function checkKnowledgeBaseAccess( if (kbData.workspaceId) { // Workspace KB: use workspace permissions only const userPermission = await getUserEntityPermissions(userId, 'workspace', kbData.workspaceId) - if (userPermission !== null) { - return { hasAccess: true, knowledgeBase: kbData } - } - return { hasAccess: false } + const permitted = requireWrite + ? userPermission === 'write' || userPermission === 'admin' + : userPermission !== null + return permitted ? { hasAccess: true, knowledgeBase: kbData } : { hasAccess: false } } // Legacy non-workspace KB: allow owner access @@ -195,7 +200,18 @@ export async function checkKnowledgeBaseAccess( } /** - * Check if a user has write access to a knowledge base + * Check if a user has read access to a knowledge base. + */ +export async function checkKnowledgeBaseAccess( + knowledgeBaseId: string, + userId: string +): Promise { + return resolveKnowledgeBaseAccess(knowledgeBaseId, userId, false) +} + +/** + * Check if a user has write access to a knowledge base. + * * Write access is granted if: * 1. KB has a workspace: user has write or admin permissions on that workspace * 2. KB has no workspace (legacy): user owns the KB directly @@ -204,52 +220,20 @@ export async function checkKnowledgeBaseWriteAccess( knowledgeBaseId: string, userId: string ): Promise { - const kb = await db - .select({ - id: knowledgeBase.id, - userId: knowledgeBase.userId, - workspaceId: knowledgeBase.workspaceId, - name: knowledgeBase.name, - embeddingModel: knowledgeBase.embeddingModel, - }) - .from(knowledgeBase) - .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) - .limit(1) - - if (kb.length === 0) { - return { hasAccess: false, notFound: true } - } - - const kbData = kb[0] - - if (kbData.workspaceId) { - // Workspace KB: use workspace permissions only - const userPermission = await getUserEntityPermissions(userId, 'workspace', kbData.workspaceId) - if (userPermission === 'write' || userPermission === 'admin') { - return { hasAccess: true, knowledgeBase: kbData } - } - return { hasAccess: false } - } - - // Legacy non-workspace KB: allow owner access - if (kbData.userId === userId) { - return { hasAccess: true, knowledgeBase: kbData } - } - - return { hasAccess: false } + return resolveKnowledgeBaseAccess(knowledgeBaseId, userId, true) } /** - * Check if a user has write access to a specific document - * Write access is granted if user has write access to the knowledge base + * Resolve document access within a knowledge base, gated by read or write + * permission on the KB (see {@link resolveKnowledgeBaseAccess}). */ -export async function checkDocumentWriteAccess( +async function resolveDocumentAccess( knowledgeBaseId: string, documentId: string, - userId: string + userId: string, + requireWrite: boolean ): Promise { - // First check if user has write access to the knowledge base - const kbAccess = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, userId) + const kbAccess = await resolveKnowledgeBaseAccess(knowledgeBaseId, userId, requireWrite) if (!kbAccess.hasAccess) { return { @@ -259,50 +243,8 @@ export async function checkDocumentWriteAccess( } } - // Check if document exists const doc = await db - .select({ - id: document.id, - filename: document.filename, - fileUrl: document.fileUrl, - fileSize: document.fileSize, - mimeType: document.mimeType, - chunkCount: document.chunkCount, - tokenCount: document.tokenCount, - characterCount: document.characterCount, - enabled: document.enabled, - processingStatus: document.processingStatus, - processingError: document.processingError, - uploadedAt: document.uploadedAt, - processingStartedAt: document.processingStartedAt, - processingCompletedAt: document.processingCompletedAt, - knowledgeBaseId: document.knowledgeBaseId, - // Text tags - tag1: document.tag1, - tag2: document.tag2, - tag3: document.tag3, - tag4: document.tag4, - tag5: document.tag5, - tag6: document.tag6, - tag7: document.tag7, - // Number tags (5 slots) - number1: document.number1, - number2: document.number2, - number3: document.number3, - number4: document.number4, - number5: document.number5, - // Date tags (2 slots) - date1: document.date1, - date2: document.date2, - // Boolean tags (3 slots) - boolean1: document.boolean1, - boolean2: document.boolean2, - boolean3: document.boolean3, - // Connector fields - connectorId: document.connectorId, - sourceUrl: document.sourceUrl, - externalId: document.externalId, - }) + .select() .from(document) .where( and( @@ -327,60 +269,41 @@ export async function checkDocumentWriteAccess( } /** - * Check if a user has access to a document within a knowledge base + * Check if a user has read access to a document within a knowledge base. */ export async function checkDocumentAccess( knowledgeBaseId: string, documentId: string, userId: string ): Promise { - // First check if user has access to the knowledge base - const kbAccess = await checkKnowledgeBaseAccess(knowledgeBaseId, userId) - - if (!kbAccess.hasAccess) { - return { - hasAccess: false, - notFound: kbAccess.notFound, - reason: kbAccess.notFound ? 'Knowledge base not found' : 'Unauthorized knowledge base access', - } - } - - const doc = await db - .select() - .from(document) - .where( - and( - eq(document.id, documentId), - eq(document.knowledgeBaseId, knowledgeBaseId), - eq(document.userExcluded, false), - isNull(document.archivedAt), - isNull(document.deletedAt) - ) - ) - .limit(1) - - if (doc.length === 0) { - return { hasAccess: false, notFound: true, reason: 'Document not found' } - } + return resolveDocumentAccess(knowledgeBaseId, documentId, userId, false) +} - return { - hasAccess: true, - document: doc[0] as DocumentData, - knowledgeBase: kbAccess.knowledgeBase!, - } +/** + * Check if a user has write access to a specific document. + * Write access is granted if user has write access to the knowledge base. + */ +export async function checkDocumentWriteAccess( + knowledgeBaseId: string, + documentId: string, + userId: string +): Promise { + return resolveDocumentAccess(knowledgeBaseId, documentId, userId, true) } /** - * Check if a user has access to a chunk within a document and knowledge base + * Resolve chunk access within a document/knowledge base, gated by read or write + * permission on the KB. The document must exist and be fully processed + * (`processingStatus === 'completed'`) before its chunks are accessible. */ -export async function checkChunkAccess( +async function resolveChunkAccess( knowledgeBaseId: string, documentId: string, chunkId: string, - userId: string + userId: string, + requireWrite: boolean ): Promise { - // First check if user has access to the knowledge base - const kbAccess = await checkKnowledgeBaseAccess(knowledgeBaseId, userId) + const kbAccess = await resolveKnowledgeBaseAccess(knowledgeBaseId, userId, requireWrite) if (!kbAccess.hasAccess) { return { @@ -410,7 +333,7 @@ export async function checkChunkAccess( const docData = doc[0] as DocumentData - // Check if document processing is completed + // Chunks are only accessible once the document has finished processing. if (docData.processingStatus !== 'completed') { return { hasAccess: false, @@ -435,3 +358,32 @@ export async function checkChunkAccess( knowledgeBase: kbAccess.knowledgeBase!, } } + +/** + * Check if a user has read access to a chunk within a document and knowledge base. + */ +export async function checkChunkAccess( + knowledgeBaseId: string, + documentId: string, + chunkId: string, + userId: string +): Promise { + return resolveChunkAccess(knowledgeBaseId, documentId, chunkId, userId, false) +} + +/** + * Check if a user has write access to a chunk. + * + * Mirrors {@link checkChunkAccess} but requires write/admin on the knowledge + * base's workspace (or KB ownership for legacy KBs), matching the permission + * needed to create chunks. Used for chunk mutation (update and delete) so those + * operations require the same permission as creation rather than read. + */ +export async function checkChunkWriteAccess( + knowledgeBaseId: string, + documentId: string, + chunkId: string, + userId: string +): Promise { + return resolveChunkAccess(knowledgeBaseId, documentId, chunkId, userId, true) +} diff --git a/apps/sim/app/api/mcp/servers/route.ts b/apps/sim/app/api/mcp/servers/route.ts index cfcc099eb85..ddf5e468a9f 100644 --- a/apps/sim/app/api/mcp/servers/route.ts +++ b/apps/sim/app/api/mcp/servers/route.ts @@ -134,10 +134,10 @@ export const POST = withRouteHandler( ) /** - * DELETE - Delete an MCP server from the workspace (requires admin permission) + * DELETE - Delete an MCP server from the workspace (requires write permission) */ export const DELETE = withRouteHandler( - withMcpAuth('admin')( + withMcpAuth('write')( async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => { try { const { searchParams } = new URL(request.url) diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts index 718b65aa0bb..d765c4985f7 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts @@ -147,7 +147,7 @@ export const PATCH = withRouteHandler( * DELETE - Delete a workflow MCP server and all its tools */ export const DELETE = withRouteHandler( - withMcpAuth('admin')( + withMcpAuth('write')( async ( request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }, diff --git a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts index 54f4cc588ee..fec6bc6c192 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts @@ -6,8 +6,8 @@ import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { forkMothershipChatContract } from '@/lib/api/contracts/mothership-tasks' import { parseRequest } from '@/lib/api/server' -import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-dual-write' -import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message' +import { loadCopilotChatMessages } from '@/lib/copilot/chat/lifecycle' +import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store' import { fetchGo } from '@/lib/copilot/request/go/fetch' import { authenticateCopilotRequestSessionOnly, @@ -50,9 +50,19 @@ export const POST = withRouteHandler( const { chatId } = parsed.data.params const { upToMessageId } = parsed.data.body - // Load parent chat and verify ownership. const [parent] = await db - .select() + .select({ + id: copilotChats.id, + userId: copilotChats.userId, + type: copilotChats.type, + workspaceId: copilotChats.workspaceId, + title: copilotChats.title, + model: copilotChats.model, + resources: copilotChats.resources, + previewYaml: copilotChats.previewYaml, + planArtifact: copilotChats.planArtifact, + config: copilotChats.config, + }) .from(copilotChats) .where(eq(copilotChats.id, chatId)) .limit(1) @@ -65,8 +75,7 @@ export const POST = withRouteHandler( await assertActiveWorkspaceAccess(parent.workspaceId, userId) } - // Find the fork point in the Sim-side messages array. - const messages = Array.isArray(parent.messages) ? (parent.messages as PersistedMessage[]) : [] + const messages = await loadCopilotChatMessages(chatId) const forkIdx = messages.findIndex((m) => m.id === upToMessageId) if (forkIdx < 0) { return createBadRequestResponse('Message not found in chat') @@ -83,32 +92,36 @@ export const POST = withRouteHandler( const title = `Fork | ${baseTitle}` const now = new Date() - const [newChat] = await db - .insert(copilotChats) - .values({ - id: newId, - userId, - workspaceId: parent.workspaceId, - type: parent.type, - title, - model: parent.model, - messages: forkedMessages, - resources: parentResources, - previewYaml: parent.previewYaml, - planArtifact: parent.planArtifact, - config: parent.config, - conversationId: null, - updatedAt: now, - lastSeenAt: now, - }) - .returning({ id: copilotChats.id, workspaceId: copilotChats.workspaceId }) + const newChat = await db.transaction(async (tx) => { + const [row] = await tx + .insert(copilotChats) + .values({ + id: newId, + userId, + workspaceId: parent.workspaceId, + type: parent.type, + title, + model: parent.model, + resources: parentResources, + previewYaml: parent.previewYaml, + planArtifact: parent.planArtifact, + config: parent.config, + conversationId: null, + updatedAt: now, + lastSeenAt: now, + }) + .returning({ id: copilotChats.id, workspaceId: copilotChats.workspaceId }) + + if (!row) return null + + await appendCopilotChatMessages(newId, forkedMessages, { chatModel: parent.model }, tx) + return row + }) if (!newChat) { return createInternalServerErrorResponse('Failed to create forked chat') } - await appendCopilotChatMessages(newId, forkedMessages, { chatModel: parent.model }) - // Clone copilot-service conversation state (messages, active_messages, memory files). // Best-effort: if the copilot service doesn't have a row for the source chat yet, skip. try { diff --git a/apps/sim/app/api/mothership/chats/route.ts b/apps/sim/app/api/mothership/chats/route.ts index 1b7157fdde5..c5610da215d 100644 --- a/apps/sim/app/api/mothership/chats/route.ts +++ b/apps/sim/app/api/mothership/chats/route.ts @@ -106,7 +106,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { type: 'mothership', title: null, model: 'claude-opus-4-6', - messages: [], updatedAt: now, lastSeenAt: now, }) diff --git a/apps/sim/app/api/providers/baseten/models/route.test.ts b/apps/sim/app/api/providers/baseten/models/route.test.ts new file mode 100644 index 00000000000..8ada3c427ea --- /dev/null +++ b/apps/sim/app/api/providers/baseten/models/route.test.ts @@ -0,0 +1,252 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockFilterBlacklistedModels, + mockIsProviderBlacklisted, + mockGetBYOKKey, + mockGetSession, + mockGetUserEntityPermissions, + mutableEnv, +} = vi.hoisted(() => ({ + mockFilterBlacklistedModels: vi.fn(), + mockIsProviderBlacklisted: vi.fn(), + mockGetBYOKKey: vi.fn(), + mockGetSession: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mutableEnv: { BASETEN_API_KEY: undefined as string | undefined }, +})) + +vi.mock('@/lib/core/config/env', () => ({ env: mutableEnv })) + +vi.mock('@/providers/utils', () => ({ + filterBlacklistedModels: mockFilterBlacklistedModels, + isProviderBlacklisted: mockIsProviderBlacklisted, +})) + +vi.mock('@/lib/api-key/byok', () => ({ + getBYOKKey: mockGetBYOKKey, +})) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetUserEntityPermissions, +})) + +import { GET } from '@/app/api/providers/baseten/models/route' + +const BASETEN_MODELS_URL = 'https://inference.baseten.co/v1/models' + +function jsonResponse(body: unknown, init: { ok?: boolean; status?: number } = {}): Response { + const status = init.status ?? 200 + const ok = init.ok ?? (status >= 200 && status < 300) + return { + ok, + status, + statusText: ok ? 'OK' : 'Error', + json: vi.fn(async () => body), + } as unknown as Response +} + +function setEnvKey(value: string | undefined): void { + mutableEnv.BASETEN_API_KEY = value +} + +function authHeaderFromLastFetch(mockFetch: ReturnType): unknown { + const init = mockFetch.mock.calls.at(-1)?.[1] as RequestInit | undefined + return (init?.headers as Record | undefined)?.Authorization +} + +describe('GET /api/providers/baseten/models', () => { + let mockFetch: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + + mockFetch = vi.fn() + vi.stubGlobal('fetch', mockFetch) + + mockIsProviderBlacklisted.mockReturnValue(false) + mockFilterBlacklistedModels.mockImplementation((models: string[]) => models) + mockGetBYOKKey.mockResolvedValue(null) + mockGetSession.mockResolvedValue(null) + mockGetUserEntityPermissions.mockResolvedValue(null) + setEnvKey(undefined) + }) + + it('returns empty models without fetching when the provider is blacklisted', async () => { + mockIsProviderBlacklisted.mockReturnValue(true) + setEnvKey('env-key') + + const res = await GET(createMockRequest('GET')) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ models: [] }) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('returns empty models when no workspaceId and no env key are available', async () => { + const res = await GET(createMockRequest('GET')) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ models: [] }) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('fetches models with the env key and prefixes each id with baseten/', async () => { + setEnvKey('env-key') + mockFetch.mockResolvedValueOnce( + jsonResponse({ + data: [{ id: 'openai/gpt-oss-120b' }, { id: 'deepseek-ai/DeepSeek-V3' }], + }) + ) + + const res = await GET(createMockRequest('GET')) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + models: ['baseten/openai/gpt-oss-120b', 'baseten/deepseek-ai/DeepSeek-V3'], + }) + + expect(mockFetch).toHaveBeenCalledTimes(1) + const [url, init] = mockFetch.mock.calls[0] + expect(url).toBe(BASETEN_MODELS_URL) + expect((init.headers as Record).Authorization).toBe('Bearer env-key') + }) + + it('uses the BYOK key when workspaceId, session, and permission are present', async () => { + setEnvKey('env-key') + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockGetUserEntityPermissions.mockResolvedValue('admin') + mockGetBYOKKey.mockResolvedValue({ apiKey: 'byok-key', isBYOK: true }) + mockFetch.mockResolvedValueOnce(jsonResponse({ data: [{ id: 'model-a' }] })) + + const res = await GET( + createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/test?workspaceId=ws-1') + ) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ models: ['baseten/model-a'] }) + + expect(mockGetBYOKKey).toHaveBeenCalledWith('ws-1', 'baseten') + expect(authHeaderFromLastFetch(mockFetch)).toBe('Bearer byok-key') + }) + + it('falls back to the env key when there is a workspaceId but no session', async () => { + setEnvKey('env-key') + mockGetSession.mockResolvedValue(null) + mockFetch.mockResolvedValueOnce(jsonResponse({ data: [{ id: 'model-a' }] })) + + const res = await GET( + createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/test?workspaceId=ws-1') + ) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ models: ['baseten/model-a'] }) + expect(mockGetBYOKKey).not.toHaveBeenCalled() + expect(authHeaderFromLastFetch(mockFetch)).toBe('Bearer env-key') + }) + + it('falls back to the env key when the user lacks workspace permission', async () => { + setEnvKey('env-key') + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockGetUserEntityPermissions.mockResolvedValue(null) + mockFetch.mockResolvedValueOnce(jsonResponse({ data: [{ id: 'model-a' }] })) + + const res = await GET( + createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/test?workspaceId=ws-1') + ) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ models: ['baseten/model-a'] }) + expect(mockGetBYOKKey).not.toHaveBeenCalled() + expect(authHeaderFromLastFetch(mockFetch)).toBe('Bearer env-key') + }) + + it('returns empty models when the upstream responds 401', async () => { + setEnvKey('env-key') + mockFetch.mockResolvedValueOnce(jsonResponse({}, { ok: false, status: 401 })) + + const res = await GET(createMockRequest('GET')) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ models: [] }) + }) + + it('returns empty models when the upstream responds 500', async () => { + setEnvKey('env-key') + mockFetch.mockResolvedValueOnce(jsonResponse({}, { ok: false, status: 500 })) + + const res = await GET(createMockRequest('GET')) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ models: [] }) + }) + + it('returns empty models when fetch throws', async () => { + setEnvKey('env-key') + mockFetch.mockRejectedValueOnce(new Error('network down')) + + const res = await GET(createMockRequest('GET')) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ models: [] }) + }) + + it('returns empty models when the upstream data array is empty', async () => { + setEnvKey('env-key') + mockFetch.mockResolvedValueOnce(jsonResponse({ data: [] })) + + const res = await GET(createMockRequest('GET')) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ models: [] }) + }) + + it('returns empty models when the upstream omits the data field', async () => { + setEnvKey('env-key') + mockFetch.mockResolvedValueOnce(jsonResponse({ object: 'list' })) + + const res = await GET(createMockRequest('GET')) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ models: [] }) + }) + + it('dedupes repeated model ids', async () => { + setEnvKey('env-key') + mockFetch.mockResolvedValueOnce( + jsonResponse({ + data: [{ id: 'model-a' }, { id: 'model-a' }, { id: 'model-b' }], + }) + ) + + const res = await GET(createMockRequest('GET')) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ models: ['baseten/model-a', 'baseten/model-b'] }) + }) + + it('drops models removed by the blacklist filter', async () => { + setEnvKey('env-key') + mockFilterBlacklistedModels.mockImplementation((models: string[]) => + models.filter((m) => m !== 'baseten/blocked-model') + ) + mockFetch.mockResolvedValueOnce( + jsonResponse({ + data: [{ id: 'allowed-model' }, { id: 'blocked-model' }], + }) + ) + + const res = await GET(createMockRequest('GET')) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ models: ['baseten/allowed-model'] }) + }) +}) diff --git a/apps/sim/app/api/providers/baseten/models/route.ts b/apps/sim/app/api/providers/baseten/models/route.ts new file mode 100644 index 00000000000..b73a6711421 --- /dev/null +++ b/apps/sim/app/api/providers/baseten/models/route.ts @@ -0,0 +1,93 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { + basetenProviderModelsQuerySchema, + basetenUpstreamResponseSchema, + providerModelsResponseSchema, +} from '@/lib/api/contracts/providers' +import { validationErrorResponse } from '@/lib/api/server' +import { getBYOKKey } from '@/lib/api-key/byok' +import { getSession } from '@/lib/auth' +import { env } from '@/lib/core/config/env' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { filterBlacklistedModels, isProviderBlacklisted } from '@/providers/utils' + +const logger = createLogger('BasetenModelsAPI') + +export const GET = withRouteHandler(async (request: NextRequest) => { + if (isProviderBlacklisted('baseten')) { + logger.info('Baseten provider is blacklisted, returning empty models') + return NextResponse.json({ models: [] }) + } + + let apiKey: string | undefined + + const queryValidation = basetenProviderModelsQuerySchema.safeParse({ + workspaceId: request.nextUrl.searchParams.get('workspaceId') ?? undefined, + }) + if (!queryValidation.success) return validationErrorResponse(queryValidation.error) + const { workspaceId } = queryValidation.data + if (workspaceId) { + const session = await getSession() + if (session?.user?.id) { + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (permission) { + const byokResult = await getBYOKKey(workspaceId, 'baseten') + if (byokResult) { + apiKey = byokResult.apiKey + } + } + } + } + + if (!apiKey) { + apiKey = env.BASETEN_API_KEY + } + + if (!apiKey) { + logger.info('No Baseten API key available, returning empty models') + return NextResponse.json({ models: [] }) + } + + try { + const response = await fetch('https://inference.baseten.co/v1/models', { + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + cache: 'no-store', + }) + + if (!response.ok) { + logger.warn('Failed to fetch Baseten models', { + status: response.status, + statusText: response.statusText, + }) + return NextResponse.json({ models: [] }) + } + + const data = basetenUpstreamResponseSchema.parse(await response.json()) + + const allModels: string[] = [] + for (const model of data.data ?? []) { + allModels.push(`baseten/${model.id}`) + } + + const uniqueModels = Array.from(new Set(allModels)) + const models = filterBlacklistedModels(uniqueModels) + + logger.info('Successfully fetched Baseten models', { + count: models.length, + filtered: uniqueModels.length - models.length, + }) + + return NextResponse.json(providerModelsResponseSchema.parse({ models })) + } catch (error) { + logger.error('Error fetching Baseten models', { + error: getErrorMessage(error, 'Unknown error'), + }) + return NextResponse.json({ models: [] }) + } +}) diff --git a/apps/sim/app/api/providers/ollama-cloud/models/route.test.ts b/apps/sim/app/api/providers/ollama-cloud/models/route.test.ts new file mode 100644 index 00000000000..fd70d1ae8b1 --- /dev/null +++ b/apps/sim/app/api/providers/ollama-cloud/models/route.test.ts @@ -0,0 +1,238 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockFilterBlacklistedModels, + mockIsProviderBlacklisted, + mockGetBYOKKey, + mockGetSession, + mockGetUserEntityPermissions, + mockFetch, +} = vi.hoisted(() => ({ + mockFilterBlacklistedModels: vi.fn(), + mockIsProviderBlacklisted: vi.fn(), + mockGetBYOKKey: vi.fn(), + mockGetSession: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockFetch: vi.fn(), +})) + +vi.mock('@/providers/utils', () => ({ + filterBlacklistedModels: mockFilterBlacklistedModels, + isProviderBlacklisted: mockIsProviderBlacklisted, +})) + +vi.mock('@/lib/api-key/byok', () => ({ + getBYOKKey: mockGetBYOKKey, +})) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetUserEntityPermissions, +})) + +import { GET } from '@/app/api/providers/ollama-cloud/models/route' + +const OLLAMA_CLOUD_TAGS_URL = 'https://ollama.com/api/tags' + +const okResponse = (body: unknown) => ({ + ok: true, + status: 200, + statusText: 'OK', + json: vi.fn().mockResolvedValue(body), +}) + +const errorResponse = (status: number, statusText = 'Unauthorized') => ({ + ok: false, + status, + statusText, + json: vi.fn().mockResolvedValue({}), +}) + +/** + * Builds a request whose query string carries the given workspaceId. Passing + * `undefined` omits the param entirely; passing `''` produces `?workspaceId=`. + */ +const requestWithWorkspace = (workspaceId?: string) => { + const url = new URL('http://localhost:3000/api/providers/ollama-cloud/models') + if (workspaceId !== undefined) { + url.searchParams.set('workspaceId', workspaceId) + } + return createMockRequest('GET', undefined, {}, url.toString()) +} + +const fetchAuthHeader = () => { + const init = mockFetch.mock.calls[0]?.[1] as RequestInit | undefined + const headers = init?.headers as Record | undefined + return headers?.Authorization +} + +/** Grants a session + workspace permission so the BYOK lookup is reached. */ +const grantWorkspaceAccess = () => { + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockGetUserEntityPermissions.mockResolvedValue('admin') +} + +describe('GET /api/providers/ollama-cloud/models', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', mockFetch) + + mockIsProviderBlacklisted.mockReturnValue(false) + mockFilterBlacklistedModels.mockImplementation((models: string[]) => models) + mockGetBYOKKey.mockResolvedValue(null) + mockGetSession.mockResolvedValue(null) + mockGetUserEntityPermissions.mockResolvedValue(null) + }) + + it('returns empty models without calling fetch when the provider is blacklisted', async () => { + mockIsProviderBlacklisted.mockReturnValue(true) + + const res = await GET(requestWithWorkspace('ws-1')) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ models: [] }) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('returns empty models when there is no workspaceId (BYOK only, no env fallback)', async () => { + const res = await GET(requestWithWorkspace()) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ models: [] }) + expect(mockFetch).not.toHaveBeenCalled() + expect(mockGetBYOKKey).not.toHaveBeenCalled() + }) + + it('returns empty models when the workspace has no stored BYOK key (never falls back to a hosted key)', async () => { + grantWorkspaceAccess() + mockGetBYOKKey.mockResolvedValue(null) + + const res = await GET(requestWithWorkspace('ws-1')) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ models: [] }) + expect(mockGetBYOKKey).toHaveBeenCalledWith('ws-1', 'ollama-cloud') + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('fetches /api/tags with the BYOK key and prefixes each model name with ollama-cloud/', async () => { + grantWorkspaceAccess() + mockGetBYOKKey.mockResolvedValue({ apiKey: 'byok-ollama-key' }) + mockFetch.mockResolvedValue( + okResponse({ + models: [{ name: 'gpt-oss:120b' }, { name: 'deepseek-v3.1:671b' }], + }) + ) + + const res = await GET(requestWithWorkspace('ws-1')) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + models: ['ollama-cloud/gpt-oss:120b', 'ollama-cloud/deepseek-v3.1:671b'], + }) + + expect(mockFetch).toHaveBeenCalledTimes(1) + expect(mockFetch.mock.calls[0][0]).toBe(OLLAMA_CLOUD_TAGS_URL) + expect(fetchAuthHeader()).toBe('Bearer byok-ollama-key') + }) + + it('does not call getBYOKKey when there is a workspaceId but no session', async () => { + mockGetSession.mockResolvedValue(null) + + const res = await GET(requestWithWorkspace('ws-1')) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ models: [] }) + expect(mockGetBYOKKey).not.toHaveBeenCalled() + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('does not call getBYOKKey when the session user lacks workspace permission', async () => { + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockGetUserEntityPermissions.mockResolvedValue(null) + + const res = await GET(requestWithWorkspace('ws-1')) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ models: [] }) + expect(mockGetBYOKKey).not.toHaveBeenCalled() + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('returns empty models when the upstream fetch responds non-ok', async () => { + grantWorkspaceAccess() + mockGetBYOKKey.mockResolvedValue({ apiKey: 'byok-ollama-key' }) + mockFetch.mockResolvedValue(errorResponse(401, 'Unauthorized')) + + const res = await GET(requestWithWorkspace('ws-1')) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ models: [] }) + }) + + it('returns empty models when the upstream fetch throws', async () => { + grantWorkspaceAccess() + mockGetBYOKKey.mockResolvedValue({ apiKey: 'byok-ollama-key' }) + mockFetch.mockRejectedValue(new Error('network down')) + + const res = await GET(requestWithWorkspace('ws-1')) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ models: [] }) + }) + + it('returns a validation error for an empty workspaceId query param', async () => { + const res = await GET(requestWithWorkspace('')) + + expect(res.status).toBe(400) + const body = (await res.json()) as { error: string } + expect(body.error).toBe('Validation error') + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('dedupes duplicate model names from the upstream response', async () => { + grantWorkspaceAccess() + mockGetBYOKKey.mockResolvedValue({ apiKey: 'byok-ollama-key' }) + mockFetch.mockResolvedValue( + okResponse({ + models: [{ name: 'gpt-oss:120b' }, { name: 'gpt-oss:120b' }, { name: 'qwen3-coder:480b' }], + }) + ) + + const res = await GET(requestWithWorkspace('ws-1')) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + models: ['ollama-cloud/gpt-oss:120b', 'ollama-cloud/qwen3-coder:480b'], + }) + }) + + it('applies the blacklist filter to the deduped model list', async () => { + grantWorkspaceAccess() + mockGetBYOKKey.mockResolvedValue({ apiKey: 'byok-ollama-key' }) + mockFilterBlacklistedModels.mockImplementation((models: string[]) => + models.filter((m) => !m.includes('qwen')) + ) + mockFetch.mockResolvedValue( + okResponse({ + models: [{ name: 'gpt-oss:120b' }, { name: 'qwen3-coder:480b' }], + }) + ) + + const res = await GET(requestWithWorkspace('ws-1')) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ models: ['ollama-cloud/gpt-oss:120b'] }) + expect(mockFilterBlacklistedModels).toHaveBeenCalledWith([ + 'ollama-cloud/gpt-oss:120b', + 'ollama-cloud/qwen3-coder:480b', + ]) + }) +}) diff --git a/apps/sim/app/api/providers/ollama-cloud/models/route.ts b/apps/sim/app/api/providers/ollama-cloud/models/route.ts new file mode 100644 index 00000000000..bd5673e0842 --- /dev/null +++ b/apps/sim/app/api/providers/ollama-cloud/models/route.ts @@ -0,0 +1,91 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { + ollamaCloudProviderModelsQuerySchema, + ollamaUpstreamResponseSchema, + providerModelsResponseSchema, +} from '@/lib/api/contracts/providers' +import { validationErrorResponse } from '@/lib/api/server' +import { getBYOKKey } from '@/lib/api-key/byok' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { filterBlacklistedModels, isProviderBlacklisted } from '@/providers/utils' + +const logger = createLogger('OllamaCloudModelsAPI') + +/** + * Get available Ollama Cloud models. + * + * Ollama Cloud is BYOK-only — Sim never supplies a hosted key and never bills + * usage. Models are listed only when the workspace has stored its own Ollama + * API key, which is used to authenticate against the cloud `/api/tags` endpoint. + */ +export const GET = withRouteHandler(async (request: NextRequest) => { + if (isProviderBlacklisted('ollama-cloud')) { + logger.info('Ollama Cloud provider is blacklisted, returning empty models') + return NextResponse.json({ models: [] }) + } + + const queryValidation = ollamaCloudProviderModelsQuerySchema.safeParse({ + workspaceId: request.nextUrl.searchParams.get('workspaceId') ?? undefined, + }) + if (!queryValidation.success) return validationErrorResponse(queryValidation.error) + const { workspaceId } = queryValidation.data + + let apiKey: string | undefined + if (workspaceId) { + const session = await getSession() + if (session?.user?.id) { + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (permission) { + const byokResult = await getBYOKKey(workspaceId, 'ollama-cloud') + if (byokResult) { + apiKey = byokResult.apiKey + } + } + } + } + + if (!apiKey) { + logger.info('No Ollama Cloud API key available, returning empty models') + return NextResponse.json({ models: [] }) + } + + try { + const response = await fetch('https://ollama.com/api/tags', { + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + cache: 'no-store', + }) + + if (!response.ok) { + logger.warn('Failed to fetch Ollama Cloud models', { + status: response.status, + statusText: response.statusText, + }) + return NextResponse.json({ models: [] }) + } + + const data = ollamaUpstreamResponseSchema.parse(await response.json()) + + const allModels = data.models.map((model) => `ollama-cloud/${model.name}`) + const uniqueModels = Array.from(new Set(allModels)) + const models = filterBlacklistedModels(uniqueModels) + + logger.info('Successfully fetched Ollama Cloud models', { + count: models.length, + filtered: uniqueModels.length - models.length, + }) + + return NextResponse.json(providerModelsResponseSchema.parse({ models })) + } catch (error) { + logger.error('Error fetching Ollama Cloud models', { + error: getErrorMessage(error, 'Unknown error'), + }) + return NextResponse.json({ models: [] }) + } +}) diff --git a/apps/sim/app/api/providers/together/models/route.test.ts b/apps/sim/app/api/providers/together/models/route.test.ts new file mode 100644 index 00000000000..ae801bb7c56 --- /dev/null +++ b/apps/sim/app/api/providers/together/models/route.test.ts @@ -0,0 +1,259 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockFilterBlacklistedModels, + mockIsProviderBlacklisted, + mockGetBYOKKey, + mockGetSession, + mockGetUserEntityPermissions, + mockFetch, + mutableEnv, +} = vi.hoisted(() => ({ + mockFilterBlacklistedModels: vi.fn(), + mockIsProviderBlacklisted: vi.fn(), + mockGetBYOKKey: vi.fn(), + mockGetSession: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockFetch: vi.fn(), + mutableEnv: { TOGETHER_API_KEY: undefined as string | undefined }, +})) + +vi.mock('@/providers/utils', () => ({ + filterBlacklistedModels: mockFilterBlacklistedModels, + isProviderBlacklisted: mockIsProviderBlacklisted, +})) + +vi.mock('@/lib/api-key/byok', () => ({ + getBYOKKey: mockGetBYOKKey, +})) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetUserEntityPermissions, +})) + +vi.mock('@/lib/core/config/env', () => ({ + env: mutableEnv, +})) + +import { GET } from '@/app/api/providers/together/models/route' + +const TOGETHER_MODELS_URL = 'https://api.together.ai/v1/models' + +const okResponse = (body: unknown) => ({ + ok: true, + status: 200, + statusText: 'OK', + json: vi.fn().mockResolvedValue(body), +}) + +const errorResponse = (status: number, statusText = 'Unauthorized') => ({ + ok: false, + status, + statusText, + json: vi.fn().mockResolvedValue({}), +}) + +/** + * Builds a request whose query string carries the given workspaceId. Passing + * `undefined` omits the param entirely; passing `''` produces `?workspaceId=`. + */ +const requestWithWorkspace = (workspaceId?: string) => { + const url = new URL('http://localhost:3000/api/providers/together/models') + if (workspaceId !== undefined) { + url.searchParams.set('workspaceId', workspaceId) + } + return createMockRequest('GET', undefined, {}, url.toString()) +} + +const fetchAuthHeader = () => { + const init = mockFetch.mock.calls[0]?.[1] as RequestInit | undefined + const headers = init?.headers as Record | undefined + return headers?.Authorization +} + +describe('GET /api/providers/together/models', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', mockFetch) + + mutableEnv.TOGETHER_API_KEY = undefined + mockIsProviderBlacklisted.mockReturnValue(false) + mockFilterBlacklistedModels.mockImplementation((models: string[]) => models) + mockGetBYOKKey.mockResolvedValue(null) + mockGetSession.mockResolvedValue(null) + mockGetUserEntityPermissions.mockResolvedValue(null) + }) + + it('returns empty models without calling fetch when the provider is blacklisted', async () => { + mockIsProviderBlacklisted.mockReturnValue(true) + + const res = await GET(requestWithWorkspace()) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ models: [] }) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('returns empty models when there is no workspaceId and no env key', async () => { + const res = await GET(requestWithWorkspace()) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ models: [] }) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('fetches with the env key and prefixes each model id with together/', async () => { + mutableEnv.TOGETHER_API_KEY = 'env-together-key' + mockFetch.mockResolvedValue( + okResponse([{ id: 'moonshotai/Kimi-K2-Instruct' }, { id: 'Qwen/Qwen2.5-72B-Instruct-Turbo' }]) + ) + + const res = await GET(requestWithWorkspace()) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + models: ['together/moonshotai/Kimi-K2-Instruct', 'together/Qwen/Qwen2.5-72B-Instruct-Turbo'], + }) + + expect(mockFetch).toHaveBeenCalledTimes(1) + expect(mockFetch.mock.calls[0][0]).toBe(TOGETHER_MODELS_URL) + expect(fetchAuthHeader()).toBe('Bearer env-together-key') + }) + + it('uses the BYOK key when a workspace, session, and permission are present', async () => { + mutableEnv.TOGETHER_API_KEY = 'env-together-key' + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockGetUserEntityPermissions.mockResolvedValue('admin') + mockGetBYOKKey.mockResolvedValue({ apiKey: 'byok-together-key' }) + mockFetch.mockResolvedValue(okResponse([{ id: 'moonshotai/Kimi-K2-Instruct' }])) + + const res = await GET(requestWithWorkspace('ws-1')) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ models: ['together/moonshotai/Kimi-K2-Instruct'] }) + + expect(mockGetBYOKKey).toHaveBeenCalledWith('ws-1', 'together') + expect(fetchAuthHeader()).toBe('Bearer byok-together-key') + }) + + it('falls back to the env key when a workspaceId is given but there is no session', async () => { + mutableEnv.TOGETHER_API_KEY = 'env-together-key' + mockGetSession.mockResolvedValue(null) + mockFetch.mockResolvedValue(okResponse([{ id: 'moonshotai/Kimi-K2-Instruct' }])) + + const res = await GET(requestWithWorkspace('ws-1')) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ models: ['together/moonshotai/Kimi-K2-Instruct'] }) + expect(mockGetBYOKKey).not.toHaveBeenCalled() + expect(fetchAuthHeader()).toBe('Bearer env-together-key') + }) + + it('falls back to the env key when the session user lacks workspace permission', async () => { + mutableEnv.TOGETHER_API_KEY = 'env-together-key' + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockGetUserEntityPermissions.mockResolvedValue(null) + mockFetch.mockResolvedValue(okResponse([{ id: 'moonshotai/Kimi-K2-Instruct' }])) + + const res = await GET(requestWithWorkspace('ws-1')) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ models: ['together/moonshotai/Kimi-K2-Instruct'] }) + expect(mockGetBYOKKey).not.toHaveBeenCalled() + expect(fetchAuthHeader()).toBe('Bearer env-together-key') + }) + + it('returns empty models when the upstream fetch responds non-ok', async () => { + mutableEnv.TOGETHER_API_KEY = 'env-together-key' + mockFetch.mockResolvedValue(errorResponse(401, 'Unauthorized')) + + const res = await GET(requestWithWorkspace()) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ models: [] }) + }) + + it('returns empty models when the upstream fetch throws', async () => { + mutableEnv.TOGETHER_API_KEY = 'env-together-key' + mockFetch.mockRejectedValue(new Error('network down')) + + const res = await GET(requestWithWorkspace()) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ models: [] }) + }) + + it('returns a validation error for an empty workspaceId query param', async () => { + const res = await GET(requestWithWorkspace('')) + + expect(res.status).toBe(400) + const body = (await res.json()) as { error: string } + expect(body.error).toBe('Validation error') + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('dedupes duplicate model ids from the upstream array', async () => { + mutableEnv.TOGETHER_API_KEY = 'env-together-key' + mockFetch.mockResolvedValue( + okResponse([ + { id: 'moonshotai/Kimi-K2-Instruct' }, + { id: 'moonshotai/Kimi-K2-Instruct' }, + { id: 'Qwen/Qwen2.5-72B-Instruct-Turbo' }, + ]) + ) + + const res = await GET(requestWithWorkspace()) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + models: ['together/moonshotai/Kimi-K2-Instruct', 'together/Qwen/Qwen2.5-72B-Instruct-Turbo'], + }) + }) + + it('applies the blacklist filter to the deduped model list', async () => { + mutableEnv.TOGETHER_API_KEY = 'env-together-key' + mockFilterBlacklistedModels.mockImplementation((models: string[]) => + models.filter((m) => !m.includes('Qwen')) + ) + mockFetch.mockResolvedValue( + okResponse([{ id: 'moonshotai/Kimi-K2-Instruct' }, { id: 'Qwen/Qwen2.5-72B-Instruct-Turbo' }]) + ) + + const res = await GET(requestWithWorkspace()) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ models: ['together/moonshotai/Kimi-K2-Instruct'] }) + expect(mockFilterBlacklistedModels).toHaveBeenCalledWith([ + 'together/moonshotai/Kimi-K2-Instruct', + 'together/Qwen/Qwen2.5-72B-Instruct-Turbo', + ]) + }) + + it('filters out non-chat model types (image, embedding, rerank, etc.)', async () => { + mutableEnv.TOGETHER_API_KEY = 'env-together-key' + mockFetch.mockResolvedValue( + okResponse([ + { id: 'meta-llama/Llama-3.3-70B-Instruct-Turbo', type: 'chat' }, + { id: 'black-forest-labs/FLUX.1-schnell', type: 'image' }, + { id: 'BAAI/bge-large-en-v1.5', type: 'embedding' }, + { id: 'Salesforce/Llama-Rank-V1', type: 'rerank' }, + { id: 'openai/whisper-large-v3', type: 'transcribe' }, + ]) + ) + + const res = await GET(requestWithWorkspace()) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + models: ['together/meta-llama/Llama-3.3-70B-Instruct-Turbo'], + }) + }) +}) diff --git a/apps/sim/app/api/providers/together/models/route.ts b/apps/sim/app/api/providers/together/models/route.ts new file mode 100644 index 00000000000..dcaa0dc0c5a --- /dev/null +++ b/apps/sim/app/api/providers/together/models/route.ts @@ -0,0 +1,105 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { + providerModelsResponseSchema, + togetherProviderModelsQuerySchema, + togetherUpstreamResponseSchema, +} from '@/lib/api/contracts/providers' +import { validationErrorResponse } from '@/lib/api/server' +import { getBYOKKey } from '@/lib/api-key/byok' +import { getSession } from '@/lib/auth' +import { env } from '@/lib/core/config/env' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { filterBlacklistedModels, isProviderBlacklisted } from '@/providers/utils' + +const logger = createLogger('TogetherModelsAPI') + +/** Together's catalog includes non-text models; only chat models work with chat completions. */ +const NON_CHAT_MODEL_TYPES = new Set([ + 'image', + 'video', + 'audio', + 'transcribe', + 'embedding', + 'moderation', + 'rerank', +]) + +export const GET = withRouteHandler(async (request: NextRequest) => { + if (isProviderBlacklisted('together')) { + logger.info('Together provider is blacklisted, returning empty models') + return NextResponse.json({ models: [] }) + } + + let apiKey: string | undefined + + const queryValidation = togetherProviderModelsQuerySchema.safeParse({ + workspaceId: request.nextUrl.searchParams.get('workspaceId') ?? undefined, + }) + if (!queryValidation.success) return validationErrorResponse(queryValidation.error) + const { workspaceId } = queryValidation.data + if (workspaceId) { + const session = await getSession() + if (session?.user?.id) { + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (permission) { + const byokResult = await getBYOKKey(workspaceId, 'together') + if (byokResult) { + apiKey = byokResult.apiKey + } + } + } + } + + if (!apiKey) { + apiKey = env.TOGETHER_API_KEY + } + + if (!apiKey) { + logger.info('No Together API key available, returning empty models') + return NextResponse.json({ models: [] }) + } + + try { + const response = await fetch('https://api.together.ai/v1/models', { + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + cache: 'no-store', + }) + + if (!response.ok) { + logger.warn('Failed to fetch Together models', { + status: response.status, + statusText: response.statusText, + }) + return NextResponse.json({ models: [] }) + } + + const data = togetherUpstreamResponseSchema.parse(await response.json()) + + const allModels: string[] = [] + for (const model of data) { + if (model.type && NON_CHAT_MODEL_TYPES.has(model.type)) continue + allModels.push(`together/${model.id}`) + } + + const uniqueModels = Array.from(new Set(allModels)) + const models = filterBlacklistedModels(uniqueModels) + + logger.info('Successfully fetched Together models', { + count: models.length, + filtered: uniqueModels.length - models.length, + }) + + return NextResponse.json(providerModelsResponseSchema.parse({ models })) + } catch (error) { + logger.error('Error fetching Together models', { + error: getErrorMessage(error, 'Unknown error'), + }) + return NextResponse.json({ models: [] }) + } +}) diff --git a/apps/sim/app/api/schedules/route.ts b/apps/sim/app/api/schedules/route.ts index 37bdc00ab24..ca36c978cef 100644 --- a/apps/sim/app/api/schedules/route.ts +++ b/apps/sim/app/api/schedules/route.ts @@ -220,8 +220,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const { workspaceId, title, prompt, cronExpression, timezone, lifecycle, maxRuns, startDate } = parsed.data.body - const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId) - if (!hasPermission) { + const permission = await verifyWorkspaceMembership(session.user.id, workspaceId) + if (permission !== 'admin' && permission !== 'write') { return NextResponse.json({ error: 'Not authorized' }, { status: 403 }) } diff --git a/apps/sim/app/api/superuser/import-workflow/route.ts b/apps/sim/app/api/superuser/import-workflow/route.ts index 912278040bb..07ab98932be 100644 --- a/apps/sim/app/api/superuser/import-workflow/route.ts +++ b/apps/sim/app/api/superuser/import-workflow/route.ts @@ -7,8 +7,8 @@ import { type NextRequest, NextResponse } from 'next/server' import { importWorkflowAsSuperuserContract } from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-dual-write' -import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message' +import { loadCopilotChatMessages } from '@/lib/copilot/chat/lifecycle' +import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { verifyEffectiveSuperUser } from '@/lib/templates/permissions' import { parseWorkflowJson } from '@/lib/workflows/operations/import-export' @@ -167,34 +167,46 @@ export const POST = withRouteHandler(async (request: NextRequest) => { // Copy copilot chats associated with the source workflow const sourceCopilotChats = await db - .select() + .select({ + id: copilotChats.id, + title: copilotChats.title, + model: copilotChats.model, + previewYaml: copilotChats.previewYaml, + planArtifact: copilotChats.planArtifact, + config: copilotChats.config, + }) .from(copilotChats) .where(eq(copilotChats.workflowId, workflowId)) let copilotChatsImported = 0 for (const chat of sourceCopilotChats) { - const [imported] = await db - .insert(copilotChats) - .values({ - userId: session.user.id, - workflowId: newWorkflowId, - title: chat.title ? `[Import] ${chat.title}` : null, - messages: chat.messages, - model: chat.model, - conversationId: null, // Don't copy conversation ID - previewYaml: chat.previewYaml, - planArtifact: chat.planArtifact, - config: chat.config, - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning({ id: copilotChats.id }) - if (imported && Array.isArray(chat.messages) && chat.messages.length > 0) { - await appendCopilotChatMessages(imported.id, chat.messages as PersistedMessage[], { - chatModel: chat.model, - }) - } + const sourceMessages = await loadCopilotChatMessages(chat.id) + await db.transaction(async (tx) => { + const [imported] = await tx + .insert(copilotChats) + .values({ + userId: session.user.id, + workflowId: newWorkflowId, + title: chat.title ? `[Import] ${chat.title}` : null, + model: chat.model, + conversationId: null, // Don't copy conversation ID + previewYaml: chat.previewYaml, + planArtifact: chat.planArtifact, + config: chat.config, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning({ id: copilotChats.id }) + if (imported && sourceMessages.length > 0) { + await appendCopilotChatMessages( + imported.id, + sourceMessages, + { chatModel: chat.model }, + tx + ) + } + }) copilotChatsImported++ } diff --git a/apps/sim/app/api/table/[tableId]/import/route.test.ts b/apps/sim/app/api/table/[tableId]/import/route.test.ts index b51b35ecece..1a551745402 100644 --- a/apps/sim/app/api/table/[tableId]/import/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/import/route.test.ts @@ -11,11 +11,13 @@ const { mockBatchInsertRowsWithTx, mockReplaceTableRowsWithTx, mockAddTableColumnsWithTx, + mockDispatchAfterBatchInsert, } = vi.hoisted(() => ({ mockCheckAccess: vi.fn(), mockBatchInsertRowsWithTx: vi.fn(), mockReplaceTableRowsWithTx: vi.fn(), mockAddTableColumnsWithTx: vi.fn(), + mockDispatchAfterBatchInsert: vi.fn(), })) vi.mock('@sim/utils/id', () => ({ @@ -44,6 +46,7 @@ vi.mock('@/lib/table/service', () => ({ batchInsertRowsWithTx: mockBatchInsertRowsWithTx, replaceTableRowsWithTx: mockReplaceTableRowsWithTx, addTableColumnsWithTx: mockAddTableColumnsWithTx, + dispatchAfterBatchInsert: mockDispatchAfterBatchInsert, })) import { POST } from '@/app/api/table/[tableId]/import/route' diff --git a/apps/sim/app/api/table/[tableId]/import/route.ts b/apps/sim/app/api/table/[tableId]/import/route.ts index 9d9ddcfd96d..e097723c023 100644 --- a/apps/sim/app/api/table/[tableId]/import/route.ts +++ b/apps/sim/app/api/table/[tableId]/import/route.ts @@ -29,11 +29,13 @@ import { type CsvHeaderMapping, CsvImportValidationError, coerceRowsForTable, + dispatchAfterBatchInsert, inferColumnType, parseCsvBuffer, replaceTableRowsWithTx, sanitizeName, type TableDefinition, + type TableRow, type TableSchema, validateMapping, } from '@/lib/table' @@ -228,13 +230,13 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro } try { - const inserted = await db.transaction(async (trx) => { + const txResult = await db.transaction(async (trx) => { let working = table if (additions.length > 0) { working = await addTableColumnsWithTx(trx, table, additions, requestId) } - let total = 0 + const allInserted: TableRow[] = [] for (let i = 0; i < coerced.length; i += CSV_MAX_BATCH_SIZE) { const batch = coerced.slice(i, i + CSV_MAX_BATCH_SIZE) const batchRequestId = generateId().slice(0, 8) @@ -249,10 +251,15 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro working, batchRequestId ) - total += result.length + allInserted.push(...result) } - return total + return { inserted: allInserted, working } }) + const { inserted: insertedRows, working: finalTable } = txResult + const inserted = insertedRows.length + // Fire trigger + scheduler AFTER the tx commits — both read through the + // global db connection and would otherwise see no rows. + dispatchAfterBatchInsert(finalTable, insertedRows, requestId) logger.info(`[${requestId}] Append CSV imported`, { tableId: table.id, diff --git a/apps/sim/app/api/tools/linq/upload/route.ts b/apps/sim/app/api/tools/linq/upload/route.ts new file mode 100644 index 00000000000..379b7665c0b --- /dev/null +++ b/apps/sim/app/api/tools/linq/upload/route.ts @@ -0,0 +1,161 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { linqUploadAttachmentContract } from '@/lib/api/contracts/tools/communication/messaging' +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 { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils' +import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' +import { extractLinqError, LINQ_API_BASE, linqHeaders } from '@/tools/linq/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('LinqUploadAttachmentAPI') + +/** Linq pre-upload caps attachments at 100MB. */ +const MAX_SIZE_BYTES = 100 * 1024 * 1024 + +/** + * Upload a file to Linq as a reusable attachment. + * + * Linq uses a two-step pre-upload flow: register the attachment metadata to + * receive a presigned URL, then PUT the bytes to that URL with the exact + * headers Linq returns. The resulting `attachment_id` can be referenced when + * sending messages or voice memos. + */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized Linq upload attempt: ${authResult.error}`) + return NextResponse.json( + { success: false, error: authResult.error || 'Authentication required' }, + { status: 401 } + ) + } + + const parsed = await parseRequest(linqUploadAttachmentContract, request, {}) + if (!parsed.success) return parsed.response + const { apiKey, file, fileContent, filename, contentType } = parsed.data.body + + let buffer: Buffer + let resolvedFilename = filename ?? '' + let resolvedContentType = contentType ?? '' + + if (file) { + const userFiles = processFilesToUserFiles([file as RawFileInput], requestId, logger) + if (userFiles.length === 0) { + return NextResponse.json( + { success: false, error: 'No valid file provided' }, + { status: 400 } + ) + } + const userFile = userFiles[0] + const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) + if (denied) return denied + buffer = await downloadFileFromStorage(userFile, requestId, logger) + if (!resolvedFilename) resolvedFilename = userFile.name + if (!resolvedContentType) resolvedContentType = userFile.type || 'application/octet-stream' + } else if (fileContent) { + buffer = Buffer.from(fileContent, 'base64') + if (!resolvedFilename) resolvedFilename = 'file' + if (!resolvedContentType) resolvedContentType = 'application/octet-stream' + } else { + return NextResponse.json( + { success: false, error: 'A file is required to upload an attachment' }, + { status: 400 } + ) + } + + const sizeBytes = buffer.length + if (sizeBytes === 0) { + return NextResponse.json({ success: false, error: 'File is empty' }, { status: 400 }) + } + if (sizeBytes > MAX_SIZE_BYTES) { + return NextResponse.json( + { + success: false, + error: `File exceeds Linq's 100MB attachment limit (${(sizeBytes / (1024 * 1024)).toFixed(2)}MB)`, + }, + { status: 400 } + ) + } + + logger.info(`[${requestId}] Registering Linq attachment`, { + filename: resolvedFilename, + contentType: resolvedContentType, + sizeBytes, + }) + + const registerResponse = await fetch(`${LINQ_API_BASE}/attachments`, { + method: 'POST', + headers: linqHeaders(apiKey), + body: JSON.stringify({ + filename: resolvedFilename, + content_type: resolvedContentType, + size_bytes: sizeBytes, + }), + }) + const registerData = await registerResponse.json().catch(() => null) + if (!registerResponse.ok) { + return NextResponse.json( + { success: false, error: extractLinqError(registerData, 'Failed to register attachment') }, + { status: registerResponse.status } + ) + } + + const uploadUrl: string | undefined = registerData?.upload_url + const attachmentId: string | undefined = registerData?.attachment_id + if (!uploadUrl || !attachmentId) { + return NextResponse.json( + { success: false, error: 'Linq did not return an upload URL or attachment ID' }, + { status: 502 } + ) + } + + const requiredHeaders: Record = registerData?.required_headers ?? { + 'Content-Type': resolvedContentType, + 'Content-Length': String(sizeBytes), + } + const uploadMethod: string = registerData?.http_method ?? 'PUT' + + logger.info(`[${requestId}] Uploading ${sizeBytes} bytes to presigned URL`) + const uploadResponse = await fetch(uploadUrl, { + method: uploadMethod, + headers: requiredHeaders, + body: new Uint8Array(buffer), + }) + if (!uploadResponse.ok) { + const uploadError = await uploadResponse.text().catch(() => '') + logger.error(`[${requestId}] Presigned upload failed: ${uploadResponse.status}`, uploadError) + return NextResponse.json( + { success: false, error: `Failed to upload file bytes to Linq (${uploadResponse.status})` }, + { status: 502 } + ) + } + + logger.info(`[${requestId}] Attachment uploaded`, { attachmentId }) + return NextResponse.json({ + success: true, + output: { + attachmentId, + downloadUrl: registerData?.download_url ?? null, + filename: resolvedFilename, + contentType: resolvedContentType, + sizeBytes, + status: 'complete', + }, + }) + } catch (error) { + logger.error(`[${requestId}] Error uploading Linq attachment:`, error) + return NextResponse.json( + { success: false, error: getErrorMessage(error, 'Unknown error occurred') }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/workflows/[id]/route.test.ts b/apps/sim/app/api/workflows/[id]/route.test.ts index d752a3e6dc5..c7f48ed36b3 100644 --- a/apps/sim/app/api/workflows/[id]/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/route.test.ts @@ -397,7 +397,42 @@ describe('Workflow By ID API Route', () => { expect(data.error).toBe('Cannot delete the only workflow in the workspace') }) - it.concurrent('should deny deletion for non-admin users', async () => { + it('should allow user with write permission to delete workflow', async () => { + const mockWorkflow = { + id: 'workflow-123', + userId: 'other-user', + name: 'Test Workflow', + workspaceId: 'workspace-456', + } + + mockGetSession({ user: { id: 'user-123' } }) + + mockGetWorkflowById.mockResolvedValue(mockWorkflow) + mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + allowed: true, + status: 200, + workflow: mockWorkflow, + workspacePermission: 'write', + }) + + mockPerformDeleteWorkflow.mockResolvedValue({ success: true }) + + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { + method: 'DELETE', + }) + const params = Promise.resolve({ id: 'workflow-123' }) + + const response = await DELETE(req, { params }) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.success).toBe(true) + expect(mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith( + expect.objectContaining({ workflowId: 'workflow-123', action: 'write' }) + ) + }) + + it.concurrent('should deny deletion for read-only users', async () => { const mockWorkflow = { id: 'workflow-123', userId: 'other-user', @@ -411,9 +446,9 @@ describe('Workflow By ID API Route', () => { mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ allowed: false, status: 403, - message: 'Unauthorized: Access denied to admin this workflow', + message: 'Unauthorized: Access denied to write this workflow', workflow: mockWorkflow, - workspacePermission: null, + workspacePermission: 'read', }) const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { @@ -425,7 +460,7 @@ describe('Workflow By ID API Route', () => { expect(response.status).toBe(403) const data = await response.json() - expect(data.error).toBe('Unauthorized: Access denied to admin this workflow') + expect(data.error).toBe('Unauthorized: Access denied to write this workflow') }) }) diff --git a/apps/sim/app/api/workflows/[id]/route.ts b/apps/sim/app/api/workflows/[id]/route.ts index 2f3f0ebe3ce..a69fffc2715 100644 --- a/apps/sim/app/api/workflows/[id]/route.ts +++ b/apps/sim/app/api/workflows/[id]/route.ts @@ -184,7 +184,7 @@ export const DELETE = withRouteHandler( const authorization = await authorizeWorkflowByWorkspacePermission({ workflowId, userId, - action: 'admin', + action: 'write', }) const workflowData = authorization.workflow || (await getWorkflowById(workflowId)) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts index c9c2931bce2..518844178b0 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts @@ -105,7 +105,8 @@ interface BatchPresignedFile { * single-PUT URL. */ const fetchBatchPresignedData = async ( - files: File[] + files: File[], + workspaceId: string ): Promise<(PresignedUploadInfo | undefined)[]> => { const result: (PresignedUploadInfo | undefined)[] = new Array(files.length).fill(undefined) const smallFileIndices: number[] = [] @@ -114,6 +115,8 @@ const fetchBatchPresignedData = async ( } if (smallFileIndices.length === 0) return result + const batchEndpoint = `${KB_BATCH_PRESIGNED_ENDPOINT}&workspaceId=${encodeURIComponent(workspaceId)}` + for (let start = 0; start < smallFileIndices.length; start += BATCH_REQUEST_SIZE) { const batchIndices = smallFileIndices.slice(start, start + BATCH_REQUEST_SIZE) const batchFiles = batchIndices.map((i) => files[i]) @@ -125,7 +128,7 @@ const fetchBatchPresignedData = async ( })), } - const response = await fetch(KB_BATCH_PRESIGNED_ENDPOINT, { + const response = await fetch(batchEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), @@ -264,7 +267,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) { file, workspaceId: options.workspaceId, context: 'knowledge-base', - presignedEndpoint: '/api/files/presigned?type=knowledge-base', + presignedEndpoint: `/api/files/presigned?type=knowledge-base&workspaceId=${encodeURIComponent(options.workspaceId)}`, presignedOverride: presigned, onProgress, }) @@ -292,6 +295,10 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) { } const uploadFilesInBatches = async (files: File[]): Promise => { + if (!options.workspaceId) { + throw new KnowledgeUploadError('workspaceId is required for upload', 'MISSING_WORKSPACE_ID') + } + const fileStatuses: FileUploadStatus[] = files.map((file) => ({ fileName: file.name, fileSize: file.size, @@ -303,7 +310,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) { logger.info(`Starting batch upload of ${files.length} files`) - const presignedData = await fetchBatchPresignedData(files) + const presignedData = await fetchBatchPresignedData(files, options.workspaceId) const settled = await runWithConcurrency( files, diff --git a/apps/sim/app/workspace/[workspaceId]/providers/provider-models-loader.tsx b/apps/sim/app/workspace/[workspaceId]/providers/provider-models-loader.tsx index f2563a2b37c..0622fafa858 100644 --- a/apps/sim/app/workspace/[workspaceId]/providers/provider-models-loader.tsx +++ b/apps/sim/app/workspace/[workspaceId]/providers/provider-models-loader.tsx @@ -5,10 +5,13 @@ import { createLogger } from '@sim/logger' import { useParams } from 'next/navigation' import { useProviderModels } from '@/hooks/queries/providers' import { + updateBasetenProviderModels, updateFireworksProviderModels, updateLiteLLMProviderModels, + updateOllamaCloudProviderModels, updateOllamaProviderModels, updateOpenRouterProviderModels, + updateTogetherProviderModels, updateVLLMProviderModels, } from '@/providers/utils' import { type ProviderName, useProvidersStore } from '@/stores/providers' @@ -31,6 +34,8 @@ function useSyncProvider(provider: ProviderName, workspaceId?: string) { try { if (provider === 'ollama') { updateOllamaProviderModels(data.models) + } else if (provider === 'ollama-cloud') { + void updateOllamaCloudProviderModels(data.models) } else if (provider === 'vllm') { updateVLLMProviderModels(data.models) } else if (provider === 'litellm') { @@ -42,6 +47,10 @@ function useSyncProvider(provider: ProviderName, workspaceId?: string) { } } else if (provider === 'fireworks') { void updateFireworksProviderModels(data.models) + } else if (provider === 'together') { + void updateTogetherProviderModels(data.models) + } else if (provider === 'baseten') { + void updateBasetenProviderModels(data.models) } } catch (syncError) { logger.warn(`Failed to sync provider definitions for ${provider}`, syncError as Error) @@ -63,9 +72,12 @@ export function ProviderModelsLoader() { useSyncProvider('base') useSyncProvider('ollama') + useSyncProvider('ollama-cloud', workspaceId) useSyncProvider('vllm') useSyncProvider('litellm') useSyncProvider('openrouter') useSyncProvider('fireworks', workspaceId) + useSyncProvider('together', workspaceId) + useSyncProvider('baseten', workspaceId) return null } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx index 998f1c5dcf9..d289eea58dd 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx @@ -17,6 +17,7 @@ import { } from '@/components/emcn' import { AnthropicIcon, + BasetenIcon, BrandfetchIcon, ExaAIIcon, FindymailIcon, @@ -29,12 +30,14 @@ import { JinaAIIcon, LinkupIcon, MistralIcon, + OllamaIcon, OpenAIIcon, ParallelIcon, PeopleDataLabsIcon, PerplexityIcon, ProspeoIcon, SerperIcon, + TogetherIcon, WizaIcon, } from '@/components/icons' import { Input } from '@/components/ui' @@ -91,6 +94,27 @@ const PROVIDERS: { description: 'LLM calls', placeholder: 'Enter your Fireworks API key', }, + { + id: 'together', + name: 'Together AI', + icon: TogetherIcon, + description: 'LLM calls', + placeholder: 'Enter your Together AI API key', + }, + { + id: 'baseten', + name: 'Baseten', + icon: BasetenIcon, + description: 'LLM calls', + placeholder: 'Enter your Baseten API key', + }, + { + id: 'ollama-cloud', + name: 'Ollama Cloud', + icon: OllamaIcon, + description: 'LLM calls', + placeholder: 'Enter your Ollama API key', + }, { id: 'falai', name: 'Fal.ai', diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx index bd683c62515..12f5bb10fb8 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx @@ -12,7 +12,7 @@ import { } from '@/components/emcn' import { ChevronDown, Plus } from '@/components/emcn/icons' import type { Filter, FilterRule } from '@/lib/table' -import { COMPARISON_OPERATORS } from '@/lib/table/query-builder/constants' +import { COMPARISON_OPERATORS, VALUELESS_OPERATORS } from '@/lib/table/query-builder/constants' import { filterRulesToFilter, filterToRules } from '@/lib/table/query-builder/converters' const OPERATOR_LABELS = Object.fromEntries( @@ -71,7 +71,9 @@ export function TableFilter({ columns, filter, onApply, onClose }: TableFilterPr }, []) const handleApply = useCallback(() => { - const validRules = rulesRef.current.filter((r) => r.column && r.value) + const validRules = rulesRef.current.filter( + (r) => r.column && (r.value || VALUELESS_OPERATORS.has(r.operator)) + ) onApply(filterRulesToFilter(validRules)) }, [onApply]) @@ -197,16 +199,20 @@ const FilterRuleRow = memo(function FilterRuleRow({ -
+ ) : ( + onUpdate(rule.id, 'value', e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') onApply() + }} + placeholder='Enter a value' + className='h-[28px] flex-1 rounded-[5px] border border-[var(--border)] bg-transparent px-2 text-[var(--text-secondary)] text-xs outline-none placeholder:text-[var(--text-subtle)] hover-hover:border-[var(--border-1)] focus:border-[var(--border-1)]' + /> + )}