Compare commits

...

15 Commits

Author SHA1 Message Date
Waleed
115f04e989 v0.5.97: oidc discovery for copilot mcp 2026-02-21 02:06:25 -08:00
Waleed
42020c3ae2 fix(mcp): use getBaseUrl for OAuth discovery metadata URLs (#3283)
* fix(mcp): use getBaseUrl for OAuth discovery metadata URLs

* fix(mcp): remove unused request params from discovery route handlers
2026-02-21 01:57:07 -08:00
Waleed
34d92fae89 v0.5.96: sim oauth provider, slack ephemeral message tool and blockkit support 2026-02-20 18:22:20 -08:00
Waleed
a98463a486 fix(copilot): handle negated operation conditions in block config extraction (#3282)
* fix(copilot): handle negated operation conditions in block config extraction

* fix(copilot): simplify condition evaluation to single matchesOperation call
2026-02-20 18:08:55 -08:00
Waleed
765a481864 fix(trigger): handle Slack reaction_added/reaction_removed event payloads (#3280)
* fix(trigger): handle Slack reaction_added/reaction_removed event payloads

* fix(trigger): use oldest param for conversations.history consistency

* fix oldest param

* fix(trigger): use reactions.get API to fetch message text for thread replies
2026-02-20 17:23:06 -08:00
Waleed
a1400caea0 fix(logs): replace initialData with placeholderData to fix stale log details (#3279) 2026-02-20 17:01:52 -08:00
Waleed
2fc2e12cb2 feat(slack): added ephemeral message send tool, updated ci, updated docs (#3278)
* feat(slack): added ephemeral message send tool, updated ci, updated docs

* added block kit support

* upgrade turborepo

* added wandConfig for slack block kit

* fix generation type
2026-02-20 16:53:10 -08:00
Waleed
3fa4bb4c12 feat(auth): add OAuth 2.1 provider for MCP connector support (#3274)
* feat(auth): add OAuth 2.1 provider for MCP connector support

* fix(auth): rename redirect_u_r_ls column to redirect_urls

* chore(db): regenerate oauth migration with correct column naming

* fix(auth): reorder CORS headers and handle missing redirectURI

* fix(auth): redirect to login without stale callbackUrl on account switch

* chore: run lint

* fix(auth): override credentials header on OAuth CORS entries

* fix(auth): preserve OAuth flow when switching accounts on consent page

* fix(auth): add session and user-id checks to authorize-params endpoint

* fix(auth): add expiry check, credentials, MCP CORS, and scope in WWW-Authenticate

* feat(mcp): add tool annotations for Connectors Directory compliance
2026-02-20 15:56:15 -08:00
Waleed
67aa4bb332 v0.5.95: gemini 3.1 pro, cloudflare, dataverse, revenuecat, redis, upstash, algolia tools; isolated-vm robustness improvements, tables backend (#3271)
* feat(tools): advanced fields for youtube, vercel; added cloudflare and dataverse tools (#3257)

* refactor(vercel): mark optional fields as advanced mode

Move optional/power-user fields behind the advanced toggle:
- List Deployments: project filter, target, state
- Create Deployment: project ID override, redeploy from, target
- List Projects: search
- Create/Update Project: framework, build/output/install commands
- Env Vars: variable type
- Webhooks: project IDs filter
- Checks: path, details URL
- Team Members: role filter
- All operations: team ID scope

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(youtube): mark optional params as advanced mode

Hide pagination, sort order, and filter fields behind the advanced
toggle for a cleaner default UX across all YouTube operations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* added advanced fields for vercel and youtube, added cloudflare and dataverse block

* addded desc for dataverse

* add more tools

* ack comment

* more

* ops

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* feat(tables): added tables (#2867)

* updates

* required

* trashy table viewer

* updates

* updates

* filtering ui

* updates

* updates

* updates

* one input mode

* format

* fix lints

* improved errors

* updates

* updates

* chages

* doc strings

* breaking down file

* update comments with ai

* updates

* comments

* changes

* revert

* updates

* dedupe

* updates

* updates

* updates

* refactoring

* renames & refactors

* refactoring

* updates

* undo

* update db

* wand

* updates

* fix comments

* fixes

* simplify comments

* u[dates

* renames

* better comments

* validation

* updates

* updates

* updates

* fix sorting

* fix appearnce

* updating prompt to make it user sort

* rm

* updates

* rename

* comments

* clean comments

* simplicifcaiton

* updates

* updates

* refactor

* reduced type confusion

* undo

* rename

* undo changes

* undo

* simplify

* updates

* updates

* revert

* updates

* db updates

* type fix

* fix

* fix error handling

* updates

* docs

* docs

* updates

* rename

* dedupe

* revert

* uncook

* updates

* fix

* fix

* fix

* fix

* prepare merge

* readd migrations

* add back missed code

* migrate enrichment logic to general abstraction

* address bugbot concerns

* adhere to size limits for tables

* remove conflicting migration

* add back migrations

* fix tables auth

* fix permissive auth

* fix lint

* reran migrations

* migrate to use tanstack query for all server state

* update table-selector

* update names

* added tables to permission groups, updated subblock types

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: waleed <walif6@gmail.com>

* fix(snapshot): changed insert to upsert when concurrent identical child workflows are running (#3259)

* fix(snapshot): changed insert to upsert when concurrent identical child workflows are running

* fixed ci tests failing

* fix(workflows): disallow duplicate workflow names at the same folder level (#3260)

* feat(tools): added redis, upstash, algolia, and revenuecat (#3261)

* feat(tools): added redis, upstash, algolia, and revenuecat

* ack comment

* feat(models): add gemini-3.1-pro-preview and update gemini-3-pro thinking levels (#3263)

* fix(audit-log): lazily resolve actor name/email when missing (#3262)

* fix(blocks): move type coercions from tools.config.tool to tools.config.params (#3264)

* fix(blocks): move type coercions from tools.config.tool to tools.config.params

Number() coercions in tools.config.tool ran at serialization time before
variable resolution, destroying dynamic references like <block.result.count>
by converting them to NaN/null. Moved all coercions to tools.config.params
which runs at execution time after variables are resolved.

Fixed in 15 blocks: exa, arxiv, sentry, incidentio, wikipedia, ahrefs,
posthog, elasticsearch, dropbox, hunter, lemlist, spotify, youtube, grafana,
parallel. Also added mode: 'advanced' to optional exa fields.

Closes #3258

* fix(blocks): address PR review — move remaining param mutations from tool() to params()

- Moved field mappings from tool() to params() in grafana, posthog,
  lemlist, spotify, dropbox (same dynamic reference bug)
- Fixed parallel.ts excerpts/full_content boolean logic
- Fixed parallel.ts search_queries empty case (must set undefined)
- Fixed elasticsearch.ts timeout not included when already ends with 's'
- Restored dropbox.ts tool() switch for proper default fallback

* fix(blocks): restore field renames to tool() for serialization-time validation

Field renames (e.g. personalApiKey→apiKey) must be in tool() because
validateRequiredFieldsBeforeExecution calls selectToolId()→tool() then
checks renamed field names on params. Only type coercions (Number(),
boolean) stay in params() to avoid destroying dynamic variable references.

* improvement(resolver): resovled empty sentinel to not pass through unexecuted valid refs to text inputs (#3266)

* fix(blocks): add required constraint for serviceDeskId in JSM block (#3268)

* fix(blocks): add required constraint for serviceDeskId in JSM block

* fix(blocks): rename custom field values to request field values in JSM create request

* fix(trigger): add isolated-vm support to trigger.dev container builds (#3269)

Scheduled workflow executions running in trigger.dev containers were
failing to spawn isolated-vm workers because the native module wasn't
available in the container. This caused loop condition evaluation to
silently fail and exit after one iteration.

- Add isolated-vm to build.external and additionalPackages in trigger config
- Include isolated-vm-worker.cjs via additionalFiles for child process spawning
- Add fallback path resolution for worker file in trigger.dev environment

* fix(tables): hide tables from sidebar and block registry (#3270)

* fix(tables): hide tables from sidebar and block registry

* fix(trigger): add isolated-vm support to trigger.dev container builds (#3269)

Scheduled workflow executions running in trigger.dev containers were
failing to spawn isolated-vm workers because the native module wasn't
available in the container. This caused loop condition evaluation to
silently fail and exit after one iteration.

- Add isolated-vm to build.external and additionalPackages in trigger config
- Include isolated-vm-worker.cjs via additionalFiles for child process spawning
- Add fallback path resolution for worker file in trigger.dev environment

* lint

* fix(trigger): update node version to align with main app (#3272)

* fix(build): fix corrupted sticky disk cache on blacksmith (#3273)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Lakee Sivaraya <71339072+lakeesiv@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
2026-02-20 13:43:07 -08:00
Waleed
1b8d666c93 fix(build): fix corrupted sticky disk cache on blacksmith (#3273) 2026-02-20 13:03:23 -08:00
Waleed
71942cb53c fix(trigger): update node version to align with main app (#3272) 2026-02-20 12:32:14 -08:00
Waleed
15ace5e63f v0.5.94: vercel integration, folder insertion, migrated tracking redirects to rewrites 2026-02-18 16:53:34 -08:00
Waleed
fdca73679d v0.5.93: NextJS config changes, MCP and Blocks whitelisting, copilot keyboard shortcuts, audit logs 2026-02-18 12:10:05 -08:00
Waleed
da46a387c9 v0.5.92: shortlinks, copilot scrolling stickiness, pagination 2026-02-17 15:13:21 -08:00
Waleed
b7e377ec4b v0.5.91: docs i18n, turborepo upgrade 2026-02-16 00:36:05 -08:00
46 changed files with 13158 additions and 124 deletions

View File

@@ -146,7 +146,7 @@ jobs:
create-ghcr-manifests:
name: Create GHCR Manifests
runs-on: blacksmith-8vcpu-ubuntu-2404
runs-on: blacksmith-2vcpu-ubuntu-2404
needs: [build-amd64, build-ghcr-arm64]
if: github.ref == 'refs/heads/main'
strategy:

View File

@@ -110,7 +110,7 @@ jobs:
RESEND_API_KEY: 'dummy_key_for_ci_only'
AWS_REGION: 'us-west-2'
ENCRYPTION_KEY: '7cf672e460e430c1fba707575c2b0e2ad5a99dddf9b7b7e3b5646e630861db1c' # dummy key for CI only
run: bun run build
run: bunx turbo run build --filter=sim
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5

View File

@@ -116,7 +116,7 @@ Create a new service request in Jira Service Management
| `summary` | string | Yes | Summary/title for the service request |
| `description` | string | No | Description for the service request |
| `raiseOnBehalfOf` | string | No | Account ID of customer to raise request on behalf of |
| `requestFieldValues` | json | No | Custom field values as key-value pairs \(overrides summary/description if provided\) |
| `requestFieldValues` | json | No | Request field values as key-value pairs \(overrides summary/description if provided\) |
| `requestParticipants` | string | No | Comma-separated account IDs to add as request participants |
| `channel` | string | No | Channel the request originates from \(e.g., portal, email\) |

View File

@@ -1,6 +1,6 @@
---
title: Slack
description: Send, update, delete messages, add reactions in Slack or trigger workflows from Slack events
description: Send, update, delete messages, send ephemeral messages, add reactions in Slack or trigger workflows from Slack events
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
@@ -59,7 +59,7 @@ If you encounter issues with the Slack integration, contact us at [help@sim.ai](
## Usage Instructions
Integrate Slack into the workflow. Can send, update, and delete messages, create canvases, read messages, and add reactions. Requires Bot Token instead of OAuth in advanced mode. Can be used in trigger mode to trigger a workflow when a message is sent to a channel.
Integrate Slack into the workflow. Can send, update, and delete messages, send ephemeral messages visible only to a specific user, create canvases, read messages, and add reactions. Requires Bot Token instead of OAuth in advanced mode. Can be used in trigger mode to trigger a workflow when a message is sent to a channel.
@@ -80,6 +80,7 @@ Send messages to Slack channels or direct messages. Supports Slack mrkdwn format
| `dmUserId` | string | No | Slack user ID for direct messages \(e.g., U1234567890\) |
| `text` | string | Yes | Message text to send \(supports Slack mrkdwn formatting\) |
| `threadTs` | string | No | Thread timestamp to reply to \(creates thread reply\) |
| `blocks` | json | No | Block Kit layout blocks as a JSON array. When provided, text becomes the fallback notification text. |
| `files` | file[] | No | Files to attach to the message |
#### Output
@@ -146,6 +147,29 @@ Send messages to Slack channels or direct messages. Supports Slack mrkdwn format
| `fileCount` | number | Number of files uploaded \(when files are attached\) |
| `files` | file[] | Files attached to the message |
### `slack_ephemeral_message`
Send an ephemeral message visible only to a specific user in a channel. Optionally reply in a thread. The message does not persist across sessions.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `authMethod` | string | No | Authentication method: oauth or bot_token |
| `botToken` | string | No | Bot token for Custom Bot |
| `channel` | string | Yes | Slack channel ID \(e.g., C1234567890\) |
| `user` | string | Yes | User ID who will see the ephemeral message \(e.g., U1234567890\). Must be a member of the channel. |
| `text` | string | Yes | Message text to send \(supports Slack mrkdwn formatting\) |
| `threadTs` | string | No | Thread timestamp to reply in. When provided, the ephemeral message appears as a thread reply. |
| `blocks` | json | No | Block Kit layout blocks as a JSON array. When provided, text becomes the fallback notification text. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `messageTs` | string | Timestamp of the ephemeral message \(cannot be used with chat.update\) |
| `channel` | string | Channel ID where the ephemeral message was sent |
### `slack_canvas`
Create and share Slack canvases in channels. Canvases are collaborative documents within Slack.
@@ -682,6 +706,7 @@ Update a message previously sent by the bot in Slack
| `channel` | string | Yes | Channel ID where the message was posted \(e.g., C1234567890\) |
| `timestamp` | string | Yes | Timestamp of the message to update \(e.g., 1405894322.002768\) |
| `text` | string | Yes | New message text \(supports Slack mrkdwn formatting\) |
| `blocks` | json | No | Block Kit layout blocks as a JSON array. When provided, text becomes the fallback notification text. |
#### Output

View File

@@ -0,0 +1,275 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { ArrowLeftRight } from 'lucide-react'
import Image from 'next/image'
import { useRouter, useSearchParams } from 'next/navigation'
import { Button } from '@/components/emcn'
import { signOut, useSession } from '@/lib/auth/auth-client'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { BrandedButton } from '@/app/(auth)/components/branded-button'
const SCOPE_DESCRIPTIONS: Record<string, string> = {
openid: 'Verify your identity',
profile: 'Access your basic profile information',
email: 'View your email address',
offline_access: 'Maintain access when you are not actively using the app',
'mcp:tools': 'Use Sim workflows and tools on your behalf',
} as const
interface ClientInfo {
clientId: string
name: string
icon: string
}
export default function OAuthConsentPage() {
const router = useRouter()
const searchParams = useSearchParams()
const { data: session } = useSession()
const consentCode = searchParams.get('consent_code')
const clientId = searchParams.get('client_id')
const scope = searchParams.get('scope')
const [clientInfo, setClientInfo] = useState<ClientInfo | null>(null)
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const scopes = scope?.split(' ').filter(Boolean) ?? []
useEffect(() => {
if (!clientId) {
setLoading(false)
setError('The authorization request is missing a required client identifier.')
return
}
fetch(`/api/auth/oauth2/client/${clientId}`, { credentials: 'include' })
.then(async (res) => {
if (!res.ok) return
const data = await res.json()
setClientInfo(data)
})
.catch(() => {})
.finally(() => {
setLoading(false)
})
}, [clientId])
const handleConsent = useCallback(
async (accept: boolean) => {
if (!consentCode) {
setError('The authorization request is missing a required consent code.')
return
}
setSubmitting(true)
try {
const res = await fetch('/api/auth/oauth2/consent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ accept, consent_code: consentCode }),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
setError(
(body as Record<string, string> | null)?.message ??
'The consent request could not be processed. Please try again.'
)
setSubmitting(false)
return
}
const data = (await res.json()) as { redirectURI?: string }
if (data.redirectURI) {
window.location.href = data.redirectURI
} else {
setError('The server did not return a redirect. Please try again.')
setSubmitting(false)
}
} catch {
setError('Something went wrong. Please try again.')
setSubmitting(false)
}
},
[consentCode]
)
const handleSwitchAccount = useCallback(async () => {
if (!consentCode) return
const res = await fetch(`/api/auth/oauth2/authorize-params?consent_code=${consentCode}`, {
credentials: 'include',
})
if (!res.ok) {
setError('Unable to switch accounts. Please re-initiate the connection.')
return
}
const params = (await res.json()) as Record<string, string | null>
const authorizeUrl = new URL('/api/auth/oauth2/authorize', window.location.origin)
for (const [key, value] of Object.entries(params)) {
if (value) authorizeUrl.searchParams.set(key, value)
}
await signOut({
fetchOptions: {
onSuccess: () => {
window.location.href = authorizeUrl.toString()
},
},
})
}, [consentCode])
if (loading) {
return (
<div className='flex flex-col items-center justify-center'>
<div className='space-y-1 text-center'>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
Authorize Application
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
Loading application details...
</p>
</div>
</div>
)
}
if (error) {
return (
<div className='flex flex-col items-center justify-center'>
<div className='space-y-1 text-center'>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
Authorization Error
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
{error}
</p>
</div>
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
<BrandedButton onClick={() => router.push('/')}>Return to Home</BrandedButton>
</div>
</div>
)
}
const clientName = clientInfo?.name ?? clientId
return (
<div className='flex flex-col items-center justify-center'>
<div className='mb-6 flex items-center gap-4'>
{clientInfo?.icon ? (
<Image
src={clientInfo.icon}
alt={clientName ?? 'Application'}
width={48}
height={48}
className='rounded-[10px]'
unoptimized
/>
) : (
<div className='flex h-12 w-12 items-center justify-center rounded-[10px] bg-muted font-medium text-[18px] text-muted-foreground'>
{(clientName ?? '?').charAt(0).toUpperCase()}
</div>
)}
<ArrowLeftRight className='h-5 w-5 text-muted-foreground' />
<Image
src='/new/logo/colorized-bg.svg'
alt='Sim'
width={48}
height={48}
className='rounded-[10px]'
/>
</div>
<div className='space-y-1 text-center'>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
Authorize Application
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
<span className='font-medium text-foreground'>{clientName}</span> is requesting access to
your account
</p>
</div>
{session?.user && (
<div
className={`${inter.className} mt-5 flex items-center gap-3 rounded-lg border px-4 py-3`}
>
{session.user.image ? (
<Image
src={session.user.image}
alt={session.user.name ?? 'User'}
width={32}
height={32}
className='rounded-full'
unoptimized
/>
) : (
<div className='flex h-8 w-8 items-center justify-center rounded-full bg-muted font-medium text-[13px] text-muted-foreground'>
{(session.user.name ?? session.user.email ?? '?').charAt(0).toUpperCase()}
</div>
)}
<div className='min-w-0'>
{session.user.name && (
<p className='truncate font-medium text-[14px]'>{session.user.name}</p>
)}
<p className='truncate text-[13px] text-muted-foreground'>{session.user.email}</p>
</div>
<button
type='button'
onClick={handleSwitchAccount}
className='ml-auto text-[13px] text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline'
>
Switch
</button>
</div>
)}
{scopes.length > 0 && (
<div className={`${inter.className} mt-5 w-full max-w-[410px]`}>
<div className='rounded-lg border p-4'>
<p className='mb-3 font-medium text-[14px]'>This will allow the application to:</p>
<ul className='space-y-2'>
{scopes.map((s) => (
<li
key={s}
className='flex items-start gap-2 font-normal text-[13px] text-muted-foreground'
>
<span className='mt-0.5 text-green-500'>&#10003;</span>
<span>{SCOPE_DESCRIPTIONS[s] ?? s}</span>
</li>
))}
</ul>
</div>
</div>
)}
<div className={`${inter.className} mt-6 flex w-full max-w-[410px] gap-3`}>
<Button
variant='outline'
size='md'
className='px-6 py-2'
disabled={submitting}
onClick={() => handleConsent(false)}
>
Deny
</Button>
<BrandedButton
fullWidth
showArrow={false}
loading={submitting}
loadingText='Authorizing'
onClick={() => handleConsent(true)}
>
Allow
</BrandedButton>
</div>
</div>
)
}

View File

@@ -1,6 +1,6 @@
import type { NextRequest, NextResponse } from 'next/server'
import type { NextResponse } from 'next/server'
import { createMcpAuthorizationServerMetadataResponse } from '@/lib/mcp/oauth-discovery'
export async function GET(request: NextRequest): Promise<NextResponse> {
return createMcpAuthorizationServerMetadataResponse(request)
export async function GET(): Promise<NextResponse> {
return createMcpAuthorizationServerMetadataResponse()
}

View File

@@ -1,6 +1,6 @@
import type { NextRequest, NextResponse } from 'next/server'
import type { NextResponse } from 'next/server'
import { createMcpAuthorizationServerMetadataResponse } from '@/lib/mcp/oauth-discovery'
export async function GET(request: NextRequest): Promise<NextResponse> {
return createMcpAuthorizationServerMetadataResponse(request)
export async function GET(): Promise<NextResponse> {
return createMcpAuthorizationServerMetadataResponse()
}

View File

@@ -1,6 +1,6 @@
import type { NextRequest, NextResponse } from 'next/server'
import type { NextResponse } from 'next/server'
import { createMcpAuthorizationServerMetadataResponse } from '@/lib/mcp/oauth-discovery'
export async function GET(request: NextRequest): Promise<NextResponse> {
return createMcpAuthorizationServerMetadataResponse(request)
export async function GET(): Promise<NextResponse> {
return createMcpAuthorizationServerMetadataResponse()
}

View File

@@ -1,6 +1,6 @@
import type { NextRequest, NextResponse } from 'next/server'
import type { NextResponse } from 'next/server'
import { createMcpProtectedResourceMetadataResponse } from '@/lib/mcp/oauth-discovery'
export async function GET(request: NextRequest): Promise<NextResponse> {
return createMcpProtectedResourceMetadataResponse(request)
export async function GET(): Promise<NextResponse> {
return createMcpProtectedResourceMetadataResponse()
}

View File

@@ -1,6 +1,6 @@
import type { NextRequest, NextResponse } from 'next/server'
import type { NextResponse } from 'next/server'
import { createMcpProtectedResourceMetadataResponse } from '@/lib/mcp/oauth-discovery'
export async function GET(request: NextRequest): Promise<NextResponse> {
return createMcpProtectedResourceMetadataResponse(request)
export async function GET(): Promise<NextResponse> {
return createMcpProtectedResourceMetadataResponse()
}

View File

@@ -23,7 +23,8 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
pathname.startsWith('/chat') ||
pathname.startsWith('/studio') ||
pathname.startsWith('/resume') ||
pathname.startsWith('/form')
pathname.startsWith('/form') ||
pathname.startsWith('/oauth')
return (
<NextThemesProvider

View File

@@ -0,0 +1,59 @@
import { db } from '@sim/db'
import { verification } from '@sim/db/schema'
import { and, eq, gt } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
/**
* Returns the original OAuth authorize parameters stored in the verification record
* for a given consent code. Used by the consent page to reconstruct the authorize URL
* when switching accounts.
*/
export async function GET(request: NextRequest) {
const session = await getSession()
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const consentCode = request.nextUrl.searchParams.get('consent_code')
if (!consentCode) {
return NextResponse.json({ error: 'consent_code is required' }, { status: 400 })
}
const [record] = await db
.select({ value: verification.value })
.from(verification)
.where(and(eq(verification.identifier, consentCode), gt(verification.expiresAt, new Date())))
.limit(1)
if (!record) {
return NextResponse.json({ error: 'Invalid or expired consent code' }, { status: 404 })
}
const data = JSON.parse(record.value) as {
clientId: string
redirectURI: string
scope: string[]
userId: string
codeChallenge: string
codeChallengeMethod: string
state: string | null
nonce: string | null
}
if (data.userId !== session.user.id) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
return NextResponse.json({
client_id: data.clientId,
redirect_uri: data.redirectURI,
scope: data.scope.join(' '),
code_challenge: data.codeChallenge,
code_challenge_method: data.codeChallengeMethod,
state: data.state,
nonce: data.nonce,
response_type: 'code',
})
}

View File

@@ -1,6 +1,6 @@
import type { NextRequest, NextResponse } from 'next/server'
import type { NextResponse } from 'next/server'
import { createMcpAuthorizationServerMetadataResponse } from '@/lib/mcp/oauth-discovery'
export async function GET(request: NextRequest): Promise<NextResponse> {
return createMcpAuthorizationServerMetadataResponse(request)
export async function GET(): Promise<NextResponse> {
return createMcpAuthorizationServerMetadataResponse()
}

View File

@@ -1,6 +1,6 @@
import type { NextRequest, NextResponse } from 'next/server'
import type { NextResponse } from 'next/server'
import { createMcpProtectedResourceMetadataResponse } from '@/lib/mcp/oauth-discovery'
export async function GET(request: NextRequest): Promise<NextResponse> {
return createMcpProtectedResourceMetadataResponse(request)
export async function GET(): Promise<NextResponse> {
return createMcpProtectedResourceMetadataResponse()
}

View File

@@ -16,6 +16,7 @@ import { userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { validateOAuthAccessToken } from '@/lib/auth/oauth-token'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import {
ORCHESTRATION_TIMEOUT_MS,
@@ -31,6 +32,7 @@ import {
import { DIRECT_TOOL_DEFS, SUBAGENT_TOOL_DEFS } from '@/lib/copilot/tools/mcp/definitions'
import { env } from '@/lib/core/config/env'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { getBaseUrl } from '@/lib/core/utils/urls'
import {
authorizeWorkflowByWorkspacePermission,
resolveWorkflowIdForUser,
@@ -384,12 +386,14 @@ function buildMcpServer(abortSignal?: AbortSignal): Server {
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
...(tool.annotations && { annotations: tool.annotations }),
}))
const subagentTools = SUBAGENT_TOOL_DEFS.map((tool) => ({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
...(tool.annotations && { annotations: tool.annotations }),
}))
const result: ListToolsResult = {
@@ -402,27 +406,51 @@ function buildMcpServer(abortSignal?: AbortSignal): Server {
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
const headers = (extra.requestInfo?.headers || {}) as HeaderMap
const apiKeyHeader = readHeader(headers, 'x-api-key')
const authorizationHeader = readHeader(headers, 'authorization')
if (!apiKeyHeader) {
return {
content: [
{
type: 'text' as const,
text: 'AUTHENTICATION ERROR: No Copilot API key provided. The user must set their Copilot API key in the x-api-key header. They can generate one in the Sim app under Settings → Copilot. Do NOT retry — this will fail until the key is configured.',
},
],
isError: true,
let authResult: CopilotKeyAuthResult = { success: false }
if (authorizationHeader?.startsWith('Bearer ')) {
const token = authorizationHeader.slice(7)
const oauthResult = await validateOAuthAccessToken(token)
if (oauthResult.success && oauthResult.userId) {
if (!oauthResult.scopes?.includes('mcp:tools')) {
return {
content: [
{
type: 'text' as const,
text: 'AUTHENTICATION ERROR: OAuth token is missing the required "mcp:tools" scope. Re-authorize with the correct scopes.',
},
],
isError: true,
}
}
authResult = { success: true, userId: oauthResult.userId }
} else {
return {
content: [
{
type: 'text' as const,
text: `AUTHENTICATION ERROR: ${oauthResult.error ?? 'Invalid OAuth access token'} Do NOT retry — re-authorize via OAuth.`,
},
],
isError: true,
}
}
} else if (apiKeyHeader) {
authResult = await authenticateCopilotApiKey(apiKeyHeader)
}
const authResult = await authenticateCopilotApiKey(apiKeyHeader)
if (!authResult.success || !authResult.userId) {
logger.warn('MCP copilot key auth failed', { method: request.method })
const errorMsg = apiKeyHeader
? `AUTHENTICATION ERROR: ${authResult.error} Do NOT retry — this will fail until the user fixes their Copilot API key.`
: 'AUTHENTICATION ERROR: No authentication provided. Provide a Bearer token (OAuth 2.1) or an x-api-key header. Generate a Copilot API key in Settings → Copilot.'
logger.warn('MCP copilot auth failed', { method: request.method })
return {
content: [
{
type: 'text' as const,
text: `AUTHENTICATION ERROR: ${authResult.error} Do NOT retry — this will fail until the user fixes their Copilot API key.`,
text: errorMsg,
},
],
isError: true,
@@ -512,6 +540,20 @@ export async function GET() {
}
export async function POST(request: NextRequest) {
const hasAuth = request.headers.has('authorization') || request.headers.has('x-api-key')
if (!hasAuth) {
const origin = getBaseUrl().replace(/\/$/, '')
const resourceMetadataUrl = `${origin}/.well-known/oauth-protected-resource/api/mcp/copilot`
return new NextResponse(JSON.stringify({ error: 'unauthorized' }), {
status: 401,
headers: {
'WWW-Authenticate': `Bearer resource_metadata="${resourceMetadataUrl}", scope="mcp:tools"`,
'Content-Type': 'application/json',
},
})
}
try {
let parsedBody: unknown
@@ -532,6 +574,19 @@ export async function POST(request: NextRequest) {
}
}
export async function OPTIONS() {
return new NextResponse(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS, DELETE',
'Access-Control-Allow-Headers':
'Content-Type, Authorization, X-API-Key, X-Requested-With, Accept',
'Access-Control-Max-Age': '86400',
},
})
}
export async function DELETE(request: NextRequest) {
void request
return NextResponse.json(createError(0, -32000, 'Method not allowed.'), { status: 405 })

View File

@@ -0,0 +1,96 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
export const dynamic = 'force-dynamic'
const logger = createLogger('SlackSendEphemeralAPI')
const SlackSendEphemeralSchema = z.object({
accessToken: z.string().min(1, 'Access token is required'),
channel: z.string().min(1, 'Channel ID is required'),
user: z.string().min(1, 'User ID is required'),
text: z.string().min(1, 'Message text is required'),
thread_ts: z.string().optional().nullable(),
blocks: z.array(z.record(z.unknown())).optional().nullable(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(`[${requestId}] Unauthorized Slack ephemeral send attempt: ${authResult.error}`)
return NextResponse.json(
{
success: false,
error: authResult.error || 'Authentication required',
},
{ status: 401 }
)
}
logger.info(
`[${requestId}] Authenticated Slack ephemeral send request via ${authResult.authType}`,
{ userId: authResult.userId }
)
const body = await request.json()
const validatedData = SlackSendEphemeralSchema.parse(body)
logger.info(`[${requestId}] Sending ephemeral message`, {
channel: validatedData.channel,
user: validatedData.user,
threadTs: validatedData.thread_ts ?? undefined,
})
const response = await fetch('https://slack.com/api/chat.postEphemeral', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${validatedData.accessToken}`,
},
body: JSON.stringify({
channel: validatedData.channel,
user: validatedData.user,
text: validatedData.text,
...(validatedData.thread_ts && { thread_ts: validatedData.thread_ts }),
...(validatedData.blocks &&
validatedData.blocks.length > 0 && { blocks: validatedData.blocks }),
}),
})
const data = await response.json()
if (!data.ok) {
logger.error(`[${requestId}] Slack API error:`, data.error)
return NextResponse.json(
{ success: false, error: data.error || 'Failed to send ephemeral message' },
{ status: 400 }
)
}
logger.info(`[${requestId}] Ephemeral message sent successfully`)
return NextResponse.json({
success: true,
output: {
messageTs: data.message_ts,
channel: validatedData.channel,
},
})
} catch (error) {
logger.error(`[${requestId}] Error sending ephemeral message:`, error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
},
{ status: 500 }
)
}
}

View File

@@ -17,6 +17,7 @@ const SlackSendMessageSchema = z
userId: z.string().optional().nullable(),
text: z.string().min(1, 'Message text is required'),
thread_ts: z.string().optional().nullable(),
blocks: z.array(z.record(z.unknown())).optional().nullable(),
files: RawFileInputArraySchema.optional().nullable(),
})
.refine((data) => data.channel || data.userId, {
@@ -63,6 +64,7 @@ export async function POST(request: NextRequest) {
userId: validatedData.userId ?? undefined,
text: validatedData.text,
threadTs: validatedData.thread_ts ?? undefined,
blocks: validatedData.blocks ?? undefined,
files: validatedData.files ?? undefined,
},
requestId,

View File

@@ -13,6 +13,7 @@ const SlackUpdateMessageSchema = z.object({
channel: z.string().min(1, 'Channel is required'),
timestamp: z.string().min(1, 'Message timestamp is required'),
text: z.string().min(1, 'Message text is required'),
blocks: z.array(z.record(z.unknown())).optional().nullable(),
})
export async function POST(request: NextRequest) {
@@ -57,6 +58,8 @@ export async function POST(request: NextRequest) {
channel: validatedData.channel,
ts: validatedData.timestamp,
text: validatedData.text,
...(validatedData.blocks &&
validatedData.blocks.length > 0 && { blocks: validatedData.blocks }),
}),
})

View File

@@ -11,7 +11,8 @@ export async function postSlackMessage(
accessToken: string,
channel: string,
text: string,
threadTs?: string | null
threadTs?: string | null,
blocks?: unknown[] | null
): Promise<{ ok: boolean; ts?: string; channel?: string; message?: any; error?: string }> {
const response = await fetch('https://slack.com/api/chat.postMessage', {
method: 'POST',
@@ -23,6 +24,7 @@ export async function postSlackMessage(
channel,
text,
...(threadTs && { thread_ts: threadTs }),
...(blocks && blocks.length > 0 && { blocks }),
}),
})
@@ -220,6 +222,7 @@ export interface SlackMessageParams {
userId?: string
text: string
threadTs?: string | null
blocks?: unknown[] | null
files?: any[] | null
}
@@ -242,7 +245,7 @@ export async function sendSlackMessage(
}
error?: string
}> {
const { accessToken, text, threadTs, files } = params
const { accessToken, text, threadTs, blocks, files } = params
let { channel } = params
if (!channel && params.userId) {
@@ -258,7 +261,7 @@ export async function sendSlackMessage(
if (!files || files.length === 0) {
logger.info(`[${requestId}] No files, using chat.postMessage`)
const data = await postSlackMessage(accessToken, channel, text, threadTs)
const data = await postSlackMessage(accessToken, channel, text, threadTs, blocks)
if (!data.ok) {
logger.error(`[${requestId}] Slack API error:`, data.error)
@@ -282,7 +285,7 @@ export async function sendSlackMessage(
if (fileIds.length === 0) {
logger.warn(`[${requestId}] No valid files to upload, sending text-only message`)
const data = await postSlackMessage(accessToken, channel, text, threadTs)
const data = await postSlackMessage(accessToken, channel, text, threadTs, blocks)
if (!data.ok) {
return { success: false, error: data.error || 'Failed to send message' }

View File

@@ -208,9 +208,10 @@ export default function Logs() {
const selectedLog = useMemo(() => {
if (!selectedLogFromList) return null
if (!activeLogQuery.data || isPreviewOpen) return selectedLogFromList
if (!activeLogQuery.data || isPreviewOpen || activeLogQuery.isPlaceholderData)
return selectedLogFromList
return { ...selectedLogFromList, ...activeLogQuery.data }
}, [selectedLogFromList, activeLogQuery.data, isPreviewOpen])
}, [selectedLogFromList, activeLogQuery.data, activeLogQuery.isPlaceholderData, isPreviewOpen])
const handleLogHover = useCallback(
(log: WorkflowLog) => {
@@ -650,7 +651,7 @@ export default function Logs() {
hasActiveFilters={filtersActive}
/>
{isPreviewOpen && activeLogQuery.data?.executionId && (
{isPreviewOpen && !activeLogQuery.isPlaceholderData && activeLogQuery.data?.executionId && (
<ExecutionSnapshot
executionId={activeLogQuery.data.executionId}
traceSpans={activeLogQuery.data.executionData?.traceSpans}

View File

@@ -9,10 +9,10 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
type: 'slack',
name: 'Slack',
description:
'Send, update, delete messages, add reactions in Slack or trigger workflows from Slack events',
'Send, update, delete messages, send ephemeral messages, add reactions in Slack or trigger workflows from Slack events',
authMode: AuthMode.OAuth,
longDescription:
'Integrate Slack into the workflow. Can send, update, and delete messages, create canvases, read messages, and add reactions. Requires Bot Token instead of OAuth in advanced mode. Can be used in trigger mode to trigger a workflow when a message is sent to a channel.',
'Integrate Slack into the workflow. Can send, update, and delete messages, send ephemeral messages visible only to a specific user, create canvases, read messages, and add reactions. Requires Bot Token instead of OAuth in advanced mode. Can be used in trigger mode to trigger a workflow when a message is sent to a channel.',
docsLink: 'https://docs.sim.ai/tools/slack',
category: 'tools',
bgColor: '#611f69',
@@ -25,6 +25,7 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
type: 'dropdown',
options: [
{ label: 'Send Message', id: 'send' },
{ label: 'Send Ephemeral Message', id: 'ephemeral' },
{ label: 'Create Canvas', id: 'canvas' },
{ label: 'Read Messages', id: 'read' },
{ label: 'Get Message', id: 'get_message' },
@@ -116,15 +117,21 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
placeholder: 'Select Slack channel',
mode: 'basic',
dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] },
condition: {
field: 'operation',
value: ['list_channels', 'list_users', 'get_user'],
not: true,
and: {
field: 'destinationType',
value: 'dm',
condition: (values?: Record<string, unknown>) => {
const op = values?.operation as string
if (op === 'ephemeral') {
return { field: 'operation', value: 'ephemeral' }
}
return {
field: 'operation',
value: ['list_channels', 'list_users', 'get_user'],
not: true,
},
and: {
field: 'destinationType',
value: 'dm',
not: true,
},
}
},
required: true,
},
@@ -135,15 +142,21 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
canonicalParamId: 'channel',
placeholder: 'Enter Slack channel ID (e.g., C1234567890)',
mode: 'advanced',
condition: {
field: 'operation',
value: ['list_channels', 'list_users', 'get_user'],
not: true,
and: {
field: 'destinationType',
value: 'dm',
condition: (values?: Record<string, unknown>) => {
const op = values?.operation as string
if (op === 'ephemeral') {
return { field: 'operation', value: 'ephemeral' }
}
return {
field: 'operation',
value: ['list_channels', 'list_users', 'get_user'],
not: true,
},
and: {
field: 'destinationType',
value: 'dm',
not: true,
},
}
},
required: true,
},
@@ -175,6 +188,31 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
},
required: true,
},
{
id: 'ephemeralUser',
title: 'Target User',
type: 'short-input',
placeholder: 'User ID who will see the message (e.g., U1234567890)',
condition: {
field: 'operation',
value: 'ephemeral',
},
required: true,
},
{
id: 'messageFormat',
title: 'Message Format',
type: 'dropdown',
options: [
{ label: 'Plain Text', id: 'text' },
{ label: 'Block Kit', id: 'blocks' },
],
value: () => 'text',
condition: {
field: 'operation',
value: ['send', 'ephemeral', 'update'],
},
},
{
id: 'text',
title: 'Message',
@@ -182,9 +220,77 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
placeholder: 'Enter your message (supports Slack mrkdwn)',
condition: {
field: 'operation',
value: 'send',
value: ['send', 'ephemeral'],
and: { field: 'messageFormat', value: 'blocks', not: true },
},
required: {
field: 'operation',
value: ['send', 'ephemeral'],
and: { field: 'messageFormat', value: 'blocks', not: true },
},
},
{
id: 'blocks',
title: 'Block Kit Blocks',
type: 'code',
language: 'json',
placeholder: 'JSON array of Block Kit blocks',
condition: {
field: 'operation',
value: ['send', 'ephemeral', 'update'],
and: { field: 'messageFormat', value: 'blocks' },
},
required: {
field: 'operation',
value: ['send', 'ephemeral', 'update'],
and: { field: 'messageFormat', value: 'blocks' },
},
wandConfig: {
enabled: true,
maintainHistory: true,
prompt: `You are an expert at Slack Block Kit.
Generate ONLY a valid JSON array of Block Kit blocks based on the user's request.
The output MUST be a JSON array starting with [ and ending with ].
Current blocks: {context}
Available block types for messages:
- "section": Displays text with an optional accessory element. Text uses { "type": "mrkdwn", "text": "..." } or { "type": "plain_text", "text": "..." }.
- "header": Large text header. Text must be plain_text.
- "divider": A horizontal rule separator. No fields needed besides type.
- "image": Displays an image. Requires "image_url" and "alt_text".
- "context": Contextual info with an "elements" array of image and text objects.
- "actions": Interactive elements like buttons. Each button needs "type": "button", a "text" object, and an "action_id".
- "rich_text": Structured rich text with "elements" array of rich_text_section objects.
Example output:
[
{
"type": "header",
"text": { "type": "plain_text", "text": "Order Confirmation" }
},
{
"type": "section",
"text": { "type": "mrkdwn", "text": "Your order *#1234* has been confirmed." }
},
{ "type": "divider" },
{
"type": "actions",
"elements": [
{
"type": "button",
"text": { "type": "plain_text", "text": "View Order" },
"action_id": "view_order",
"url": "https://example.com/orders/1234"
}
]
}
]
You can reference workflow variables using angle brackets, e.g., <blockName.output>.
Do not include any explanations, markdown formatting, or other text outside the JSON array.`,
placeholder: 'Describe the Block Kit layout you want to create...',
},
required: true,
},
{
id: 'threadTs',
@@ -193,7 +299,7 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
placeholder: 'Reply to thread (e.g., 1405894322.002768)',
condition: {
field: 'operation',
value: 'send',
value: ['send', 'ephemeral'],
},
required: false,
},
@@ -456,8 +562,13 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
condition: {
field: 'operation',
value: 'update',
and: { field: 'messageFormat', value: 'blocks', not: true },
},
required: {
field: 'operation',
value: 'update',
and: { field: 'messageFormat', value: 'blocks', not: true },
},
required: true,
},
// Delete Message specific fields
{
@@ -499,6 +610,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
tools: {
access: [
'slack_message',
'slack_ephemeral_message',
'slack_canvas',
'slack_message_reader',
'slack_get_message',
@@ -517,6 +629,8 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
switch (params.operation) {
case 'send':
return 'slack_message'
case 'ephemeral':
return 'slack_ephemeral_message'
case 'canvas':
return 'slack_canvas'
case 'read':
@@ -554,13 +668,16 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
destinationType,
channel,
dmUserId,
messageFormat,
text,
title,
content,
limit,
oldest,
files,
blocks,
threadTs,
ephemeralUser,
updateTimestamp,
updateText,
deleteTimestamp,
@@ -602,10 +719,13 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
switch (operation) {
case 'send': {
baseParams.text = text
baseParams.text = messageFormat === 'blocks' && !text ? ' ' : text
if (threadTs) {
baseParams.threadTs = threadTs
}
if (blocks) {
baseParams.blocks = blocks
}
// files is the canonical param from attachmentFiles (basic) or files (advanced)
const normalizedFiles = normalizeFileInput(files)
if (normalizedFiles) {
@@ -614,6 +734,18 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
break
}
case 'ephemeral': {
baseParams.text = messageFormat === 'blocks' && !text ? ' ' : text
baseParams.user = ephemeralUser ? String(ephemeralUser).trim() : ''
if (threadTs) {
baseParams.threadTs = threadTs
}
if (blocks) {
baseParams.blocks = blocks
}
break
}
case 'canvas':
baseParams.title = title
baseParams.content = content
@@ -680,7 +812,10 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
case 'update':
baseParams.timestamp = updateTimestamp
baseParams.text = updateText
baseParams.text = messageFormat === 'blocks' && !updateText ? ' ' : updateText
if (blocks) {
baseParams.blocks = blocks
}
break
case 'delete':
@@ -699,6 +834,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
},
inputs: {
operation: { type: 'string', description: 'Operation to perform' },
messageFormat: { type: 'string', description: 'Message format: text or blocks' },
authMethod: { type: 'string', description: 'Authentication method' },
destinationType: { type: 'string', description: 'Destination type (channel or dm)' },
credential: { type: 'string', description: 'Slack access token' },
@@ -731,6 +867,9 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
// List Users inputs
includeDeleted: { type: 'string', description: 'Include deactivated users (true/false)' },
userLimit: { type: 'string', description: 'Maximum number of users to return' },
// Ephemeral message inputs
ephemeralUser: { type: 'string', description: 'User ID who will see the ephemeral message' },
blocks: { type: 'json', description: 'Block Kit layout blocks as a JSON array' },
// Get User inputs
userId: { type: 'string', description: 'User ID to look up' },
// Get Message inputs
@@ -758,6 +897,12 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
},
files: { type: 'file[]', description: 'Files attached to the message' },
// slack_ephemeral_message outputs (ephemeral operation)
messageTs: {
type: 'string',
description: 'Timestamp of the ephemeral message (cannot be used to update or delete)',
},
// slack_canvas outputs
canvas_id: { type: 'string', description: 'Canvas identifier for created canvases' },
title: { type: 'string', description: 'Canvas title' },

View File

@@ -3,7 +3,6 @@ import {
type QueryClient,
useInfiniteQuery,
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import { getEndDateFromTimeRange, getStartDateFromTimeRange } from '@/lib/logs/filters'
import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser'
@@ -159,27 +158,13 @@ interface UseLogDetailOptions {
}
export function useLogDetail(logId: string | undefined, options?: UseLogDetailOptions) {
const queryClient = useQueryClient()
return useQuery({
queryKey: logKeys.detail(logId),
queryFn: () => fetchLogDetail(logId as string),
enabled: Boolean(logId) && (options?.enabled ?? true),
refetchInterval: options?.refetchInterval ?? false,
staleTime: 30 * 1000,
initialData: () => {
if (!logId) return undefined
const listQueries = queryClient.getQueriesData<{
pages: { logs: WorkflowLog[] }[]
}>({
queryKey: logKeys.lists(),
})
for (const [, data] of listQueries) {
const match = data?.pages?.flatMap((p) => p.logs).find((l) => l.id === logId)
if (match) return match
}
return undefined
},
initialDataUpdatedAt: 0,
placeholderData: keepPreviousData,
})
}

View File

@@ -11,6 +11,8 @@ import {
customSession,
emailOTP,
genericOAuth,
jwt,
oidcProvider,
oneTimeToken,
organization,
} from 'better-auth/plugins'
@@ -80,6 +82,8 @@ export const auth = betterAuth({
trustedOrigins: [
getBaseUrl(),
...(env.NEXT_PUBLIC_SOCKET_URL ? [env.NEXT_PUBLIC_SOCKET_URL] : []),
'https://claude.ai',
'https://claude.com',
].filter(Boolean),
database: drizzleAdapter(db, {
provider: 'pg',
@@ -542,6 +546,21 @@ export const auth = betterAuth({
},
plugins: [
nextCookies(),
jwt({
jwks: {
keyPairConfig: { alg: 'RS256' },
},
disableSettingJwtHeader: true,
}),
oidcProvider({
loginPage: '/login',
consentPage: '/oauth/consent',
requirePKCE: true,
allowPlainCodeChallengeMethod: false,
allowDynamicClientRegistration: true,
useJWTPlugin: true,
scopes: ['openid', 'profile', 'email', 'offline_access', 'mcp:tools'],
}),
oneTimeToken({
expiresIn: 24 * 60 * 60, // 24 hours - Socket.IO handles connection persistence with heartbeats
}),

View File

@@ -0,0 +1,51 @@
import { db } from '@sim/db'
import { oauthAccessToken } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, gt } from 'drizzle-orm'
const logger = createLogger('OAuthToken')
interface OAuthTokenValidationResult {
success: boolean
userId?: string
scopes?: string[]
error?: string
}
/**
* Validates an OAuth 2.1 access token by looking it up in the oauthAccessToken table.
* Returns the associated userId and scopes if the token is valid and not expired.
*/
export async function validateOAuthAccessToken(token: string): Promise<OAuthTokenValidationResult> {
try {
const [record] = await db
.select({
userId: oauthAccessToken.userId,
scopes: oauthAccessToken.scopes,
accessTokenExpiresAt: oauthAccessToken.accessTokenExpiresAt,
})
.from(oauthAccessToken)
.where(
and(
eq(oauthAccessToken.accessToken, token),
gt(oauthAccessToken.accessTokenExpiresAt, new Date())
)
)
.limit(1)
if (!record) {
return { success: false, error: 'Invalid or expired OAuth access token' }
}
if (!record.userId) {
return { success: false, error: 'OAuth token has no associated user' }
}
const scopes = record.scopes.split(' ').filter(Boolean)
return { success: true, userId: record.userId, scopes }
} catch (error) {
logger.error('OAuth access token validation failed', { error })
return { success: false, error: 'Token validation error' }
}
}

View File

@@ -1,8 +1,16 @@
export type ToolAnnotations = {
readOnlyHint?: boolean
destructiveHint?: boolean
idempotentHint?: boolean
openWorldHint?: boolean
}
export type DirectToolDef = {
name: string
description: string
inputSchema: { type: 'object'; properties?: Record<string, unknown>; required?: string[] }
toolId: string
annotations?: ToolAnnotations
}
export type SubagentToolDef = {
@@ -10,6 +18,7 @@ export type SubagentToolDef = {
description: string
inputSchema: { type: 'object'; properties?: Record<string, unknown>; required?: string[] }
agentId: string
annotations?: ToolAnnotations
}
/**
@@ -26,6 +35,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
type: 'object',
properties: {},
},
annotations: { readOnlyHint: true },
},
{
name: 'list_workflows',
@@ -45,6 +55,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
},
},
},
annotations: { readOnlyHint: true },
},
{
name: 'list_folders',
@@ -61,6 +72,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
},
required: ['workspaceId'],
},
annotations: { readOnlyHint: true },
},
{
name: 'get_workflow',
@@ -77,6 +89,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
},
required: ['workflowId'],
},
annotations: { readOnlyHint: true },
},
{
name: 'create_workflow',
@@ -105,6 +118,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
},
required: ['name'],
},
annotations: { destructiveHint: false },
},
{
name: 'create_folder',
@@ -129,6 +143,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
},
required: ['name'],
},
annotations: { destructiveHint: false },
},
{
name: 'rename_workflow',
@@ -148,6 +163,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
},
required: ['workflowId', 'name'],
},
annotations: { destructiveHint: false, idempotentHint: true },
},
{
name: 'move_workflow',
@@ -168,6 +184,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
},
required: ['workflowId'],
},
annotations: { destructiveHint: false, idempotentHint: true },
},
{
name: 'move_folder',
@@ -189,6 +206,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
},
required: ['folderId'],
},
annotations: { destructiveHint: false, idempotentHint: true },
},
{
name: 'run_workflow',
@@ -214,6 +232,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
},
required: ['workflowId'],
},
annotations: { destructiveHint: false, openWorldHint: true },
},
{
name: 'run_workflow_until_block',
@@ -243,6 +262,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
},
required: ['workflowId', 'stopAfterBlockId'],
},
annotations: { destructiveHint: false, openWorldHint: true },
},
{
name: 'run_from_block',
@@ -276,6 +296,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
},
required: ['workflowId', 'startBlockId'],
},
annotations: { destructiveHint: false, openWorldHint: true },
},
{
name: 'run_block',
@@ -309,6 +330,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
},
required: ['workflowId', 'blockId'],
},
annotations: { destructiveHint: false, openWorldHint: true },
},
{
name: 'get_deployed_workflow_state',
@@ -325,6 +347,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
},
required: ['workflowId'],
},
annotations: { readOnlyHint: true },
},
{
name: 'generate_api_key',
@@ -346,6 +369,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
},
required: ['name'],
},
annotations: { destructiveHint: false },
},
]
@@ -397,6 +421,7 @@ WORKFLOW:
},
required: ['request', 'workflowId'],
},
annotations: { destructiveHint: false, openWorldHint: true },
},
{
name: 'sim_discovery',
@@ -422,6 +447,7 @@ DO NOT USE (use direct tools instead):
},
required: ['request'],
},
annotations: { readOnlyHint: true },
},
{
name: 'sim_plan',
@@ -456,6 +482,7 @@ IMPORTANT: Pass the returned plan EXACTLY to sim_edit - do not modify or summari
},
required: ['request', 'workflowId'],
},
annotations: { readOnlyHint: true },
},
{
name: 'sim_edit',
@@ -491,6 +518,7 @@ After sim_edit completes, you can test immediately with sim_test, or deploy with
},
required: ['workflowId'],
},
annotations: { destructiveHint: false, openWorldHint: true },
},
{
name: 'sim_deploy',
@@ -524,6 +552,7 @@ ALSO CAN:
},
required: ['request', 'workflowId'],
},
annotations: { destructiveHint: false, openWorldHint: true },
},
{
name: 'sim_test',
@@ -547,6 +576,7 @@ Supports full and partial execution:
},
required: ['request', 'workflowId'],
},
annotations: { destructiveHint: false, openWorldHint: true },
},
{
name: 'sim_debug',
@@ -562,6 +592,7 @@ Supports full and partial execution:
},
required: ['error', 'workflowId'],
},
annotations: { readOnlyHint: true },
},
{
name: 'sim_auth',
@@ -576,6 +607,7 @@ Supports full and partial execution:
},
required: ['request'],
},
annotations: { destructiveHint: false, openWorldHint: true },
},
{
name: 'sim_knowledge',
@@ -590,6 +622,7 @@ Supports full and partial execution:
},
required: ['request'],
},
annotations: { destructiveHint: false },
},
{
name: 'sim_custom_tool',
@@ -604,6 +637,7 @@ Supports full and partial execution:
},
required: ['request'],
},
annotations: { destructiveHint: false },
},
{
name: 'sim_info',
@@ -619,6 +653,7 @@ Supports full and partial execution:
},
required: ['request'],
},
annotations: { readOnlyHint: true },
},
{
name: 'sim_workflow',
@@ -634,6 +669,7 @@ Supports full and partial execution:
},
required: ['request'],
},
annotations: { destructiveHint: false },
},
{
name: 'sim_research',
@@ -648,6 +684,7 @@ Supports full and partial execution:
},
required: ['request'],
},
annotations: { readOnlyHint: true, openWorldHint: true },
},
{
name: 'sim_superagent',
@@ -662,6 +699,7 @@ Supports full and partial execution:
},
required: ['request'],
},
annotations: { destructiveHint: true, openWorldHint: true },
},
{
name: 'sim_platform',
@@ -676,5 +714,6 @@ Supports full and partial execution:
},
required: ['request'],
},
annotations: { readOnlyHint: true },
},
]

View File

@@ -135,12 +135,13 @@ interface OutputFieldSchema {
function matchesOperation(condition: any, operation: string): boolean {
if (!condition) return false
const cond = typeof condition === 'function' ? condition() : condition
const cond = typeof condition === 'function' ? condition({ operation }) : condition
if (!cond) return false
if (cond.field === 'operation' && !cond.not) {
if (cond.field === 'operation') {
const values = Array.isArray(cond.value) ? cond.value : [cond.value]
return values.includes(operation)
const included = values.includes(operation)
return cond.not ? !included : included
}
return false
@@ -173,18 +174,10 @@ function extractInputsFromSubBlocks(
// 1. Have no condition (common parameters)
// 2. Have a condition matching the operation
if (operation) {
const condition = typeof sb.condition === 'function' ? sb.condition() : sb.condition
if (condition) {
if (condition.field === 'operation' && !condition.not) {
// This is an operation-specific field
const values = Array.isArray(condition.value) ? condition.value : [condition.value]
if (!values.includes(operation)) {
continue // Skip if doesn't match our operation
}
} else if (!matchesOperation(condition, operation)) {
// Other condition that doesn't match
continue
}
const condition =
typeof sb.condition === 'function' ? sb.condition({ operation }) : sb.condition
if (condition && !matchesOperation(condition, operation)) {
continue
}
}

View File

@@ -1,24 +1,27 @@
import { type NextRequest, NextResponse } from 'next/server'
import { NextResponse } from 'next/server'
import { getBaseUrl } from '@/lib/core/utils/urls'
function getOrigin(request: NextRequest): string {
return request.nextUrl.origin
function getOrigin(): string {
return getBaseUrl().replace(/\/$/, '')
}
export function createMcpAuthorizationServerMetadataResponse(request: NextRequest): NextResponse {
const origin = getOrigin(request)
export function createMcpAuthorizationServerMetadataResponse(): NextResponse {
const origin = getOrigin()
const resource = `${origin}/api/mcp/copilot`
return NextResponse.json(
{
issuer: resource,
token_endpoint: `${origin}/api/auth/oauth/token`,
token_endpoint_auth_methods_supported: ['none'],
issuer: origin,
authorization_endpoint: `${origin}/api/auth/oauth2/authorize`,
token_endpoint: `${origin}/api/auth/oauth2/token`,
registration_endpoint: `${origin}/api/auth/oauth2/register`,
jwks_uri: `${origin}/api/auth/jwks`,
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post', 'none'],
grant_types_supported: ['authorization_code', 'refresh_token'],
response_types_supported: ['code'],
code_challenge_methods_supported: ['S256'],
scopes_supported: ['mcp:tools'],
scopes_supported: ['openid', 'profile', 'email', 'offline_access', 'mcp:tools'],
resource,
// Non-standard extension for API-key-only clients.
x_sim_auth: {
type: 'api_key',
header: 'x-api-key',
@@ -32,10 +35,10 @@ export function createMcpAuthorizationServerMetadataResponse(request: NextReques
)
}
export function createMcpProtectedResourceMetadataResponse(request: NextRequest): NextResponse {
const origin = getOrigin(request)
export function createMcpProtectedResourceMetadataResponse(): NextResponse {
const origin = getOrigin()
const resource = `${origin}/api/mcp/copilot`
const authorizationServerIssuer = `${origin}/api/mcp/copilot`
const authorizationServerIssuer = origin
return NextResponse.json(
{

View File

@@ -679,6 +679,55 @@ async function downloadSlackFiles(
return downloaded
}
const SLACK_REACTION_EVENTS = new Set(['reaction_added', 'reaction_removed'])
/**
* Fetches the text of a reacted-to message from Slack using the reactions.get API.
* Unlike conversations.history, reactions.get works for both top-level messages and
* thread replies, since it looks up the item directly by channel + timestamp.
* Requires the bot token to have the reactions:read scope.
*/
async function fetchSlackMessageText(
channel: string,
messageTs: string,
botToken: string
): Promise<string> {
try {
const params = new URLSearchParams({
channel,
timestamp: messageTs,
})
const response = await fetch(`https://slack.com/api/reactions.get?${params}`, {
headers: { Authorization: `Bearer ${botToken}` },
})
const data = (await response.json()) as {
ok: boolean
error?: string
type?: string
message?: { text?: string }
}
if (!data.ok) {
logger.warn('Slack reactions.get failed — message text unavailable', {
channel,
messageTs,
error: data.error,
})
return ''
}
return data.message?.text ?? ''
} catch (error) {
logger.warn('Error fetching Slack message text', {
channel,
messageTs,
error: error instanceof Error ? error.message : String(error),
})
return ''
}
}
/**
* Format webhook input based on provider
*/
@@ -953,6 +1002,23 @@ export async function formatWebhookInput(
})
}
const eventType: string = rawEvent?.type || body?.type || 'unknown'
const isReactionEvent = SLACK_REACTION_EVENTS.has(eventType)
// Reaction events nest channel/ts inside event.item
const channel: string = isReactionEvent
? rawEvent?.item?.channel || ''
: rawEvent?.channel || ''
const messageTs: string = isReactionEvent
? rawEvent?.item?.ts || ''
: rawEvent?.ts || rawEvent?.event_ts || ''
// For reaction events, attempt to fetch the original message text
let text: string = rawEvent?.text || ''
if (isReactionEvent && channel && messageTs && botToken) {
text = await fetchSlackMessageText(channel, messageTs, botToken)
}
const rawFiles: any[] = rawEvent?.files ?? []
const hasFiles = rawFiles.length > 0
@@ -965,16 +1031,18 @@ export async function formatWebhookInput(
return {
event: {
event_type: rawEvent?.type || body?.type || 'unknown',
channel: rawEvent?.channel || '',
event_type: eventType,
channel,
channel_name: '',
user: rawEvent?.user || '',
user_name: '',
text: rawEvent?.text || '',
timestamp: rawEvent?.ts || rawEvent?.event_ts || '',
text,
timestamp: messageTs,
thread_ts: rawEvent?.thread_ts || '',
team_id: body?.team_id || rawEvent?.team || '',
event_id: body?.event_id || '',
reaction: rawEvent?.reaction || '',
item_user: rawEvent?.item_user || '',
hasFiles,
files,
},

View File

@@ -121,6 +121,14 @@ const nextConfig: NextConfig = {
],
async headers() {
return [
{
source: '/.well-known/:path*',
headers: [
{ key: 'Access-Control-Allow-Origin', value: '*' },
{ key: 'Access-Control-Allow-Methods', value: 'GET, OPTIONS' },
{ key: 'Access-Control-Allow-Headers', value: 'Content-Type, Accept' },
],
},
{
// API routes CORS headers
source: '/api/:path*',
@@ -137,7 +145,52 @@ const nextConfig: NextConfig = {
{
key: 'Access-Control-Allow-Headers',
value:
'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-API-Key',
'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-API-Key, Authorization',
},
],
},
{
source: '/api/auth/oauth2/:path*',
headers: [
{ key: 'Access-Control-Allow-Credentials', value: 'false' },
{ key: 'Access-Control-Allow-Origin', value: '*' },
{ key: 'Access-Control-Allow-Methods', value: 'GET, POST, OPTIONS' },
{
key: 'Access-Control-Allow-Headers',
value: 'Content-Type, Authorization, Accept',
},
],
},
{
source: '/api/auth/jwks',
headers: [
{ key: 'Access-Control-Allow-Credentials', value: 'false' },
{ key: 'Access-Control-Allow-Origin', value: '*' },
{ key: 'Access-Control-Allow-Methods', value: 'GET, OPTIONS' },
{ key: 'Access-Control-Allow-Headers', value: 'Content-Type, Accept' },
],
},
{
source: '/api/auth/.well-known/:path*',
headers: [
{ key: 'Access-Control-Allow-Credentials', value: 'false' },
{ key: 'Access-Control-Allow-Origin', value: '*' },
{ key: 'Access-Control-Allow-Methods', value: 'GET, OPTIONS' },
{ key: 'Access-Control-Allow-Headers', value: 'Content-Type, Accept' },
],
},
{
source: '/api/mcp/copilot',
headers: [
{ key: 'Access-Control-Allow-Credentials', value: 'false' },
{ key: 'Access-Control-Allow-Origin', value: '*' },
{
key: 'Access-Control-Allow-Methods',
value: 'GET, POST, OPTIONS, DELETE',
},
{
key: 'Access-Control-Allow-Headers',
value: 'Content-Type, Authorization, X-API-Key, X-Requested-With, Accept',
},
],
},

View File

@@ -0,0 +1,12 @@
<svg width="400" height="400" viewBox="0 0 400 400" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="400" height="400" fill="#0B0B0B"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M196.822 182.761C196.822 186.348 195.403 189.792 192.884 192.328L192.523 192.692C190.006 195.236 186.586 196.658 183.024 196.658H102.445C95.0246 196.658 89 202.718 89 210.191V297.332C89 304.806 95.0246 310.866 102.445 310.866H188.962C196.383 310.866 202.4 304.806 202.4 297.332V215.745C202.4 212.419 203.71 209.228 206.047 206.874C208.377 204.527 211.546 203.207 214.849 203.207H296.777C304.198 203.207 310.214 197.148 310.214 189.674V102.533C310.214 95.0596 304.198 89 296.777 89H210.26C202.839 89 196.822 95.0596 196.822 102.533V182.761ZM223.078 107.55H283.952C288.289 107.55 291.796 111.089 291.796 115.45V176.757C291.796 181.118 288.289 184.658 283.952 184.658H223.078C218.748 184.658 215.233 181.118 215.233 176.757V115.45C215.233 111.089 218.748 107.55 223.078 107.55Z" fill="#33C482"/>
<path d="M296.878 218.57H232.554C224.756 218.57 218.434 224.937 218.434 232.791V296.784C218.434 304.638 224.756 311.005 232.554 311.005H296.878C304.677 311.005 310.999 304.638 310.999 296.784V232.791C310.999 224.937 304.677 218.57 296.878 218.57Z" fill="#33C482"/>
<path d="M296.878 218.27H232.554C224.756 218.27 218.434 224.636 218.434 232.491V296.483C218.434 304.337 224.756 310.703 232.554 310.703H296.878C304.677 310.703 310.999 304.337 310.999 296.483V232.491C310.999 224.636 304.677 218.27 296.878 218.27Z" fill="url(#paint0_linear_2686_11143)" fill-opacity="0.2"/>
<defs>
<linearGradient id="paint0_linear_2686_11143" x1="218.434" y1="218.27" x2="274.629" y2="274.334" gradientUnits="userSpaceOnUse">
<stop/>
<stop offset="1" stop-opacity="0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,11 @@
<svg width="222" height="222" viewBox="0 0 222 222" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M107.822 93.7612C107.822 97.3481 106.403 100.792 103.884 103.328L103.523 103.692C101.006 106.236 97.5855 107.658 94.0236 107.658H13.4455C6.02456 107.658 0 113.718 0 121.191V208.332C0 215.806 6.02456 221.866 13.4455 221.866H99.9622C107.383 221.866 113.4 215.806 113.4 208.332V126.745C113.4 123.419 114.71 120.228 117.047 117.874C119.377 115.527 122.546 114.207 125.849 114.207H207.777C215.198 114.207 221.214 108.148 221.214 100.674V13.5333C221.214 6.05956 215.198 0 207.777 0H121.26C113.839 0 107.822 6.05956 107.822 13.5333V93.7612ZM134.078 18.55H194.952C199.289 18.55 202.796 22.0893 202.796 26.4503V87.7574C202.796 92.1178 199.289 95.6577 194.952 95.6577H134.078C129.748 95.6577 126.233 92.1178 126.233 87.7574V26.4503C126.233 22.0893 129.748 18.55 134.078 18.55Z" fill="#33C482"/>
<path d="M207.878 129.57H143.554C135.756 129.57 129.434 135.937 129.434 143.791V207.784C129.434 215.638 135.756 222.005 143.554 222.005H207.878C215.677 222.005 221.999 215.638 221.999 207.784V143.791C221.999 135.937 215.677 129.57 207.878 129.57Z" fill="#33C482"/>
<path d="M207.878 129.27H143.554C135.756 129.27 129.434 135.636 129.434 143.491V207.483C129.434 215.337 135.756 221.703 143.554 221.703H207.878C215.677 221.703 221.999 215.337 221.999 207.483V143.491C221.999 135.636 215.677 129.27 207.878 129.27Z" fill="url(#paint0_linear_2888_11298)" fill-opacity="0.2"/>
<defs>
<linearGradient id="paint0_linear_2888_11298" x1="129.434" y1="129.27" x2="185.629" y2="185.334" gradientUnits="userSpaceOnUse">
<stop/>
<stop offset="1" stop-opacity="0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,11 @@
<svg width="222" height="222" viewBox="0 0 222 222" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M107.822 93.7612C107.822 97.3481 106.403 100.792 103.884 103.328L103.523 103.692C101.006 106.236 97.5855 107.658 94.0236 107.658H13.4455C6.02456 107.658 0 113.718 0 121.191V208.332C0 215.806 6.02456 221.866 13.4455 221.866H99.9622C107.383 221.866 113.4 215.806 113.4 208.332V126.745C113.4 123.419 114.71 120.228 117.047 117.874C119.377 115.527 122.546 114.207 125.849 114.207H207.777C215.198 114.207 221.214 108.148 221.214 100.674V13.5333C221.214 6.05956 215.198 0 207.777 0H121.26C113.839 0 107.822 6.05956 107.822 13.5333V93.7612ZM134.078 18.55H194.952C199.289 18.55 202.796 22.0893 202.796 26.4503V87.7574C202.796 92.1178 199.289 95.6577 194.952 95.6577H134.078C129.748 95.6577 126.233 92.1178 126.233 87.7574V26.4503C126.233 22.0893 129.748 18.55 134.078 18.55Z" fill="white"/>
<path d="M207.882 129.57H143.558C135.76 129.57 129.438 135.937 129.438 143.791V207.784C129.438 215.638 135.76 222.005 143.558 222.005H207.882C215.681 222.005 222.003 215.638 222.003 207.784V143.791C222.003 135.937 215.681 129.57 207.882 129.57Z" fill="white"/>
<path d="M207.882 129.27H143.557C135.759 129.27 129.438 135.636 129.438 143.491V207.483C129.438 215.337 135.759 221.703 143.557 221.703H207.882C215.681 221.703 222.003 215.337 222.003 207.483V143.491C222.003 135.636 215.681 129.27 207.882 129.27Z" fill="url(#paint0_linear_2888_11298)" fill-opacity="0.2"/>
<defs>
<linearGradient id="paint0_linear_2888_11298" x1="129.438" y1="129.27" x2="185.633" y2="185.334" gradientUnits="userSpaceOnUse">
<stop/>
<stop offset="1" stop-opacity="0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -1541,6 +1541,7 @@ import {
slackCanvasTool,
slackDeleteMessageTool,
slackDownloadTool,
slackEphemeralMessageTool,
slackGetMessageTool,
slackGetThreadTool,
slackGetUserTool,
@@ -2216,6 +2217,7 @@ export const tools: Record<string, ToolConfig> = {
slack_get_thread: slackGetThreadTool,
slack_canvas: slackCanvasTool,
slack_download: slackDownloadTool,
slack_ephemeral_message: slackEphemeralMessageTool,
slack_update_message: slackUpdateMessageTool,
slack_delete_message: slackDeleteMessageTool,
slack_add_reaction: slackAddReactionTool,

View File

@@ -0,0 +1,114 @@
import type {
SlackEphemeralMessageParams,
SlackEphemeralMessageResponse,
} from '@/tools/slack/types'
import type { ToolConfig } from '@/tools/types'
export const slackEphemeralMessageTool: ToolConfig<
SlackEphemeralMessageParams,
SlackEphemeralMessageResponse
> = {
id: 'slack_ephemeral_message',
name: 'Slack Ephemeral Message',
description:
'Send an ephemeral message visible only to a specific user in a channel. Optionally reply in a thread. The message does not persist across sessions.',
version: '1.0.0',
oauth: {
required: true,
provider: 'slack',
},
params: {
authMethod: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Authentication method: oauth or bot_token',
},
botToken: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Bot token for Custom Bot',
},
accessToken: {
type: 'string',
required: false,
visibility: 'hidden',
description: 'OAuth access token or bot token for Slack API',
},
channel: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Slack channel ID (e.g., C1234567890)',
},
user: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description:
'User ID who will see the ephemeral message (e.g., U1234567890). Must be a member of the channel.',
},
text: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Message text to send (supports Slack mrkdwn formatting)',
},
threadTs: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Thread timestamp to reply in. When provided, the ephemeral message appears as a thread reply.',
},
blocks: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description:
'Block Kit layout blocks as a JSON array. When provided, text becomes the fallback notification text.',
},
},
request: {
url: '/api/tools/slack/send-ephemeral',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params: SlackEphemeralMessageParams) => ({
accessToken: params.accessToken || params.botToken,
channel: params.channel,
user: params.user?.trim(),
text: params.text,
thread_ts: params.threadTs || undefined,
blocks:
typeof params.blocks === 'string' ? JSON.parse(params.blocks) : params.blocks || undefined,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!data.success) {
throw new Error(data.error || 'Failed to send ephemeral message')
}
return {
success: true,
output: data.output,
}
},
outputs: {
messageTs: {
type: 'string',
description: 'Timestamp of the ephemeral message (cannot be used with chat.update)',
},
channel: {
type: 'string',
description: 'Channel ID where the ephemeral message was sent',
},
},
}

View File

@@ -2,6 +2,7 @@ import { slackAddReactionTool } from '@/tools/slack/add_reaction'
import { slackCanvasTool } from '@/tools/slack/canvas'
import { slackDeleteMessageTool } from '@/tools/slack/delete_message'
import { slackDownloadTool } from '@/tools/slack/download'
import { slackEphemeralMessageTool } from '@/tools/slack/ephemeral_message'
import { slackGetMessageTool } from '@/tools/slack/get_message'
import { slackGetThreadTool } from '@/tools/slack/get_thread'
import { slackGetUserTool } from '@/tools/slack/get_user'
@@ -17,6 +18,7 @@ export {
slackCanvasTool,
slackMessageReaderTool,
slackDownloadTool,
slackEphemeralMessageTool,
slackUpdateMessageTool,
slackDeleteMessageTool,
slackAddReactionTool,

View File

@@ -63,6 +63,13 @@ export const slackMessageTool: ToolConfig<SlackMessageParams, SlackMessageRespon
visibility: 'user-or-llm',
description: 'Thread timestamp to reply to (creates thread reply)',
},
blocks: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description:
'Block Kit layout blocks as a JSON array. When provided, text becomes the fallback notification text.',
},
files: {
type: 'file[]',
required: false,
@@ -85,6 +92,10 @@ export const slackMessageTool: ToolConfig<SlackMessageParams, SlackMessageRespon
userId: isDM ? params.dmUserId : params.userId,
text: params.text,
thread_ts: params.threadTs || undefined,
blocks:
typeof params.blocks === 'string'
? JSON.parse(params.blocks)
: params.blocks || undefined,
files: params.files || null,
}
},

View File

@@ -517,6 +517,7 @@ export interface SlackMessageParams extends SlackBaseParams {
userId?: string
text: string
threadTs?: string
blocks?: string
files?: UserFile[]
}
@@ -546,6 +547,7 @@ export interface SlackUpdateMessageParams extends SlackBaseParams {
channel: string
timestamp: string
text: string
blocks?: string
}
export interface SlackDeleteMessageParams extends SlackBaseParams {
@@ -584,6 +586,14 @@ export interface SlackGetMessageParams extends SlackBaseParams {
timestamp: string
}
export interface SlackEphemeralMessageParams extends SlackBaseParams {
channel: string
user: string
text: string
threadTs?: string
blocks?: string
}
export interface SlackGetThreadParams extends SlackBaseParams {
channel: string
threadTs: string
@@ -831,6 +841,13 @@ export interface SlackGetMessageResponse extends ToolResponse {
}
}
export interface SlackEphemeralMessageResponse extends ToolResponse {
output: {
messageTs: string
channel: string
}
}
export interface SlackGetThreadResponse extends ToolResponse {
output: {
parentMessage: SlackMessage
@@ -853,5 +870,6 @@ export type SlackResponse =
| SlackListMembersResponse
| SlackListUsersResponse
| SlackGetUserResponse
| SlackEphemeralMessageResponse
| SlackGetMessageResponse
| SlackGetThreadResponse

View File

@@ -53,6 +53,13 @@ export const slackUpdateMessageTool: ToolConfig<
visibility: 'user-or-llm',
description: 'New message text (supports Slack mrkdwn formatting)',
},
blocks: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description:
'Block Kit layout blocks as a JSON array. When provided, text becomes the fallback notification text.',
},
},
request: {
@@ -66,6 +73,8 @@ export const slackUpdateMessageTool: ToolConfig<
channel: params.channel,
timestamp: params.timestamp,
text: params.text,
blocks:
typeof params.blocks === 'string' ? JSON.parse(params.blocks) : params.blocks || undefined,
}),
},

View File

@@ -4,7 +4,7 @@ import { env } from './lib/core/config/env'
export default defineConfig({
project: env.TRIGGER_PROJECT_ID!,
runtime: 'node',
runtime: 'node-22',
logLevel: 'log',
maxDuration: 5400,
retries: {

View File

@@ -67,8 +67,8 @@ export const slackWebhookTrigger: TriggerConfig = {
'Go to <a href="https://api.slack.com/apps" target="_blank" rel="noopener noreferrer" class="text-muted-foreground underline transition-colors hover:text-muted-foreground/80">Slack Apps page</a>',
'If you don\'t have an app:<br><ul class="mt-1 ml-5 list-disc"><li>Create an app from scratch</li><li>Give it a name and select your workspace</li></ul>',
'Go to "Basic Information", find the "Signing Secret", and paste it in the field above.',
'Go to "OAuth & Permissions" and add bot token scopes:<br><ul class="mt-1 ml-5 list-disc"><li><code>app_mentions:read</code> - For viewing messages that tag your bot with an @</li><li><code>chat:write</code> - To send messages to channels your bot is a part of</li><li><code>files:read</code> - To access files and images shared in messages</li></ul>',
'Go to "Event Subscriptions":<br><ul class="mt-1 ml-5 list-disc"><li>Enable events</li><li>Under "Subscribe to Bot Events", add <code>app_mention</code> to listen to messages that mention your bot</li><li>Paste the Webhook URL above into the "Request URL" field</li></ul>',
'Go to "OAuth & Permissions" and add bot token scopes:<br><ul class="mt-1 ml-5 list-disc"><li><code>app_mentions:read</code> - For viewing messages that tag your bot with an @</li><li><code>chat:write</code> - To send messages to channels your bot is a part of</li><li><code>files:read</code> - To access files and images shared in messages</li><li><code>reactions:read</code> - For listening to emoji reactions and fetching reacted-to message text</li></ul>',
'Go to "Event Subscriptions":<br><ul class="mt-1 ml-5 list-disc"><li>Enable events</li><li>Under "Subscribe to Bot Events", add <code>app_mention</code> to listen to messages that mention your bot</li><li>For reaction events, also add <code>reaction_added</code> and/or <code>reaction_removed</code></li><li>Paste the Webhook URL above into the "Request URL" field</li></ul>',
'Go to "Install App" in the left sidebar and install the app into your desired Slack workspace and channel.',
'Copy the "Bot User OAuth Token" (starts with <code>xoxb-</code>) and paste it in the Bot Token field above to enable file downloads.',
'Save changes in both Slack and here.',
@@ -128,6 +128,16 @@ export const slackWebhookTrigger: TriggerConfig = {
type: 'string',
description: 'Unique event identifier',
},
reaction: {
type: 'string',
description:
'Emoji reaction name (e.g., thumbsup). Present for reaction_added/reaction_removed events',
},
item_user: {
type: 'string',
description:
'User ID of the original message author. Present for reaction_added/reaction_removed events',
},
hasFiles: {
type: 'boolean',
description: 'Whether the message has file attachments',

View File

@@ -124,7 +124,7 @@ RUN --mount=type=cache,target=/root/.cache/pip \
# Create .next/cache directory with correct ownership
RUN mkdir -p apps/sim/.next/cache && \
chown -R nextjs:nodejs /app
chown -R nextjs:nodejs apps/sim/.next/cache
# Switch to non-root user
USER nextjs

View File

@@ -0,0 +1,57 @@
CREATE TABLE "jwks" (
"id" text PRIMARY KEY NOT NULL,
"public_key" text NOT NULL,
"private_key" text NOT NULL,
"created_at" timestamp NOT NULL
);
--> statement-breakpoint
CREATE TABLE "oauth_access_token" (
"id" text PRIMARY KEY NOT NULL,
"access_token" text NOT NULL,
"refresh_token" text NOT NULL,
"access_token_expires_at" timestamp NOT NULL,
"refresh_token_expires_at" timestamp NOT NULL,
"client_id" text NOT NULL,
"user_id" text,
"scopes" text NOT NULL,
"created_at" timestamp NOT NULL,
"updated_at" timestamp NOT NULL,
CONSTRAINT "oauth_access_token_access_token_unique" UNIQUE("access_token"),
CONSTRAINT "oauth_access_token_refresh_token_unique" UNIQUE("refresh_token")
);
--> statement-breakpoint
CREATE TABLE "oauth_application" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"icon" text,
"metadata" text,
"client_id" text NOT NULL,
"client_secret" text,
"redirect_urls" text NOT NULL,
"type" text NOT NULL,
"disabled" boolean DEFAULT false,
"user_id" text,
"created_at" timestamp NOT NULL,
"updated_at" timestamp NOT NULL,
CONSTRAINT "oauth_application_client_id_unique" UNIQUE("client_id")
);
--> statement-breakpoint
CREATE TABLE "oauth_consent" (
"id" text PRIMARY KEY NOT NULL,
"client_id" text NOT NULL,
"user_id" text NOT NULL,
"scopes" text NOT NULL,
"created_at" timestamp NOT NULL,
"updated_at" timestamp NOT NULL,
"consent_given" boolean NOT NULL
);
--> statement-breakpoint
ALTER TABLE "oauth_access_token" ADD CONSTRAINT "oauth_access_token_client_id_oauth_application_client_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."oauth_application"("client_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "oauth_access_token" ADD CONSTRAINT "oauth_access_token_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "oauth_application" ADD CONSTRAINT "oauth_application_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "oauth_consent" ADD CONSTRAINT "oauth_consent_client_id_oauth_application_client_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."oauth_application"("client_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "oauth_consent" ADD CONSTRAINT "oauth_consent_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "oauth_access_token_access_token_idx" ON "oauth_access_token" USING btree ("access_token");--> statement-breakpoint
CREATE INDEX "oauth_access_token_refresh_token_idx" ON "oauth_access_token" USING btree ("refresh_token");--> statement-breakpoint
CREATE INDEX "oauth_application_client_id_idx" ON "oauth_application" USING btree ("client_id");--> statement-breakpoint
CREATE INDEX "oauth_consent_user_client_idx" ON "oauth_consent" USING btree ("user_id","client_id");

File diff suppressed because it is too large Load Diff

View File

@@ -1093,6 +1093,13 @@
"when": 1771528429740,
"tag": "0156_easy_odin",
"breakpoints": true
},
{
"idx": 157,
"version": "7",
"when": 1771621587420,
"tag": "0157_exotic_dormammu",
"breakpoints": true
}
]
}

View File

@@ -2334,3 +2334,73 @@ export const userTableRows = pgTable(
),
})
)
export const oauthApplication = pgTable(
'oauth_application',
{
id: text('id').primaryKey(),
name: text('name').notNull(),
icon: text('icon'),
metadata: text('metadata'),
clientId: text('client_id').notNull().unique(),
clientSecret: text('client_secret'),
redirectURLs: text('redirect_urls').notNull(),
type: text('type').notNull(),
disabled: boolean('disabled').default(false),
userId: text('user_id').references(() => user.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
},
(table) => ({
clientIdIdx: index('oauth_application_client_id_idx').on(table.clientId),
})
)
export const oauthAccessToken = pgTable(
'oauth_access_token',
{
id: text('id').primaryKey(),
accessToken: text('access_token').notNull().unique(),
refreshToken: text('refresh_token').notNull().unique(),
accessTokenExpiresAt: timestamp('access_token_expires_at').notNull(),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at').notNull(),
clientId: text('client_id')
.notNull()
.references(() => oauthApplication.clientId, { onDelete: 'cascade' }),
userId: text('user_id').references(() => user.id, { onDelete: 'cascade' }),
scopes: text('scopes').notNull(),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
},
(table) => ({
accessTokenIdx: index('oauth_access_token_access_token_idx').on(table.accessToken),
refreshTokenIdx: index('oauth_access_token_refresh_token_idx').on(table.refreshToken),
})
)
export const oauthConsent = pgTable(
'oauth_consent',
{
id: text('id').primaryKey(),
clientId: text('client_id')
.notNull()
.references(() => oauthApplication.clientId, { onDelete: 'cascade' }),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
scopes: text('scopes').notNull(),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
consentGiven: boolean('consent_given').notNull(),
},
(table) => ({
userClientIdx: index('oauth_consent_user_client_idx').on(table.userId, table.clientId),
})
)
export const jwks = pgTable('jwks', {
id: text('id').primaryKey(),
publicKey: text('public_key').notNull(),
privateKey: text('private_key').notNull(),
createdAt: timestamp('created_at').notNull(),
})

View File

@@ -1,5 +1,5 @@
{
"$schema": "https://v2-8-0.turborepo.dev/schema.json",
"$schema": "https://v2-8-10.turborepo.dev/schema.json",
"envMode": "loose",
"tasks": {
"build": {