Compare commits

...

64 Commits

Author SHA1 Message Date
Waleed
4d1a9a3f22 v0.5.36: hitl improvements, opengraph, slack fixes, one-click unsubscribe, auth checks, new db indexes 2025-12-19 01:27:49 -08:00
Waleed
656a6b8abd fix(sanitization): added more input sanitization to tool routes (#2475)
* fix(sanitization): added more input sanitization to tool routes

* ack PR comments
2025-12-19 01:27:20 -08:00
Waleed
889b44c90a improvement(db): added missing indexes for common access patterns (#2473) 2025-12-19 00:46:10 -08:00
Waleed
3a33ec929f fix(authentication): added auth checks for various routes, mysql and postgres query validation, csp improvements (#2472) 2025-12-19 00:44:52 -08:00
Waleed
24356d99ec fix(unsubscribe): add one-click unsubscribe (#2467)
* fix(unsubscribe): add one-click unsubscribe

* ack Pr comments
2025-12-18 21:16:24 -08:00
Waleed
6de1c04517 feat(i18n): update translations (#2470) 2025-12-18 21:01:51 -08:00
Adam Gough
38be2b76c4 fix(slack): respect message limit, remove duplicate canonical representations (#2469)
* fix(slack): respect message limit, remove duplicate canonical representations

* removed comment

* updated docs script

---------

Co-authored-by: aadamgough <adam@sim.ai>
2025-12-18 20:37:14 -08:00
Waleed
a2f14cab54 feat(og): add opengraph images for templates, blogs, and updated existing opengraph image for all other pages (#2466)
* feat(og): add opengraph images for templates, blogs, and updated existing opengraph image for all other pages

* added to workspace templates page as well

* ack PR comments
2025-12-18 19:15:06 -08:00
Priyanshu Solanki
474762d6fb improvement(hitl): show resume url in tag dropdown within hitl block (#2464)
* fixed the human in the loop url resolution:

* greptilecomments

* greptilecomments

---------

Co-authored-by: Pbonmars-20031006@users.noreply.github.com
2025-12-18 19:43:37 -07:00
Waleed
0005c3e465 feat(i18n): update translations (#2463)
Co-authored-by: aadamgough <aadamgough@users.noreply.github.com>
2025-12-18 18:18:31 -08:00
Adam Gough
fc40b4f7af fix(tools): improved slack output ux and jira params (#2462)
* fixed slack output

* updated jira

* removed comment

* change team uuid
2025-12-18 17:56:10 -08:00
Vikhyath Mondreti
eb07a080fb v0.5.35: helm updates, copilot improvements, 404 for docs, salesforce fixes, subflow resize clamping 2025-12-18 16:23:19 -08:00
Priyanshu Solanki
2a7f51a2f6 adding clamps for subflow drag and drops of blocks (#2460)
Co-authored-by: priyanshu.solanki <priyanshu.solanki@saviynt.com>
2025-12-18 16:57:58 -07:00
Waleed
90c3c43607 fix(blog): add back unoptimized tag, fix styling (#2461) 2025-12-18 15:55:47 -08:00
Siddharth Ganesan
83d813a7cc improvement(copilot): add edge handle validation to copilot edit workflow (#2448)
* Add edge handle validation

* Clean

* Fix lint

* Fix empty target handle
2025-12-18 15:40:00 -08:00
Vikhyath Mondreti
811c736705 fix failing lint from os contributor (#2459) 2025-12-18 15:03:31 -08:00
Vikhyath Mondreti
c6757311af Merge branch 'main' into staging 2025-12-18 14:58:48 -08:00
div
b5b12ba2d1 fix(teams): webhook notifications crash (#2426)
* fix(docs): clarify working directory for drizzle migration (#2375)

* fix(landing): prevent url encoding for spaces for footer links (#2376)

* fix: handle empty body.value in Teams webhook notification parser (#2425)

* Update directory path for migration command

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Waleed <walif6@gmail.com>
Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>
Co-authored-by: icecrasher321 <icecrasher321@users.noreply.github.com>
Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
Co-authored-by: mosa <mosaxiv@gmail.com>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
Co-authored-by: Adam Gough <77861281+aadamgough@users.noreply.github.com>
Co-authored-by: Shivam <shivamprajapati035@gmail.com>
Co-authored-by: Gaurav Chadha <65453826+Chadha93@users.noreply.github.com>
Co-authored-by: root <root@Delta.localdomain>
2025-12-18 14:57:27 -08:00
Waleed
0d30676e34 fix(blog): revert back to using next image tags in blog (#2458) 2025-12-18 13:51:58 -08:00
Waleed
36bdccb449 fix(ui): fixed visibility issue on reset passowrd page (#2456) 2025-12-18 13:24:32 -08:00
Waleed
f45730a89e improvement(helm): added SSO and cloud storage variables to helm charts (#2454)
* improvement(helm): added SSO and cloud storage variables to helm charts

* consolidated sf types
2025-12-18 13:12:21 -08:00
Vikhyath Mondreti
04cd837e9c fix(notifs): inactivity polling filters, consolidate trigger types, minor consistency issue with filter parsing (#2452)
* fix(notifs-slac): display name for account

* fix inactivity polling check

* consolidate trigger types

* remove redundant defaults

* fix
2025-12-18 12:49:58 -08:00
Waleed
c23130a26e Revert "fix(salesforce): updated to more flexible oauth that allows production, developer, and custom domain salesforce orgs (#2441) (#2444)" (#2453)
This reverts commit 9da19e84b7.
2025-12-18 12:46:24 -08:00
Priyanshu Solanki
7575cd6f27 Merge pull request #2451 from simstudioai/improvement/SIM-514-useWebhookUrl-conditioning
improvement(useWebhookUrl): GET api/webhook is called when useWebhookUrl:true
2025-12-18 13:31:06 -07:00
priyanshu.solanki
fbde64f0b0 fixing lint errors 2025-12-18 13:04:25 -07:00
Waleed
25f7ed20f6 feat(docs): added 404 page for the docs (#2450)
* feat(docs): added 404 page for the docs

* added metadata
2025-12-18 11:46:42 -08:00
priyanshu.solanki
261aa3d72d fixing a react component: 2025-12-18 12:39:47 -07:00
Waleed
9da19e84b7 fix(salesforce): updated to more flexible oauth that allows production, developer, and custom domain salesforce orgs (#2441) (#2444)
* fix(oauth): updated oauth providers that had unstable reference IDs leading to duplicate oauth records (#2441)

* fix(oauth): updated oauth providers that had unstable reference IDs leading to duplicate oauth records

* ack PR comments

* ack PR comments

* cleanup salesforce refresh logic

* ack more PR comments
2025-12-18 11:39:28 -08:00
priyanshu.solanki
e83afc0a62 fixing the useWbehookManangement call to only call the loadwebhookorgenerateurl function when the useWebhookurl flag is true 2025-12-18 12:31:18 -07:00
Vikhyath Mondreti
1720fa8749 feat(compare-schema): ci check to make sure schema.ts never goes out of sync with migrations (#2449)
* feat(compare-schema): ci check to make sure schema.ts never goes out of sync with migrations

* test out of sync [do not merge]

* Revert "test out of sync [do not merge]"

This reverts commit 9771f66b84.
2025-12-18 11:25:19 -08:00
Waleed
f3ad7750af fix(auth): added same-origin validation to forget password route, added confirmation for disable auth FF (#2447)
* fix(auth): added same-origin validation to forget password route, added confirmation for disable auth FF

* ack PR comments
2025-12-18 11:07:25 -08:00
Vikhyath Mondreti
78b7643e65 fix(condition): async execution isolated vm error (#2446)
* fix(condition): async execution isolated vm error

* fix tests
2025-12-18 11:02:01 -08:00
Waleed
67cfb21d08 v0.5.34: servicenow, code cleanup, prevent cyclic edge connections, custom tool fixes 2025-12-17 23:39:10 -08:00
Waleed
1d6975db49 v0.5.33: loops, chat fixes, subflow resizing refactor, terminal updates 2025-12-17 15:45:39 -08:00
Waleed
837aabca5e v0.5.32: google sheets fix, schedule input format 2025-12-16 15:41:04 -08:00
Vikhyath Mondreti
f9cfca92bf v0.5.31: add zod as direct dep 2025-12-15 20:40:02 -08:00
Waleed
25afacb25e v0.5.30: vllm fixes, permissions fixes, isolated vms for code execution, tool fixes 2025-12-15 19:38:01 -08:00
Gaurav Chadha
fcf52ac4d5 fix(landing): prevent url encoding for spaces for footer links (#2376) 2025-12-15 10:59:12 -08:00
Shivam
842200bcf2 fix(docs): clarify working directory for drizzle migration (#2375) 2025-12-15 10:58:27 -08:00
Waleed
a0fb889644 v0.5.29: chat voice mode, opengraph for docs, option to disable auth 2025-12-13 19:50:06 -08:00
Waleed
f526c36fc0 v0.5.28: tool fixes, sqs, spotify, nextjs update, component playground 2025-12-12 21:05:57 -08:00
Waleed
e24f31cbce v0.5.27: sidebar updates, ssrf patches, gpt-5.2, stagehand fixes 2025-12-11 14:45:25 -08:00
Waleed
3fbd57caf1 v0.5.26: tool fixes, templates and knowledgebase fixes, deployment versions in logs 2025-12-11 00:52:13 -08:00
Vikhyath Mondreti
b5da61377c v0.5.25: minor ui improvements, copilot billing fix 2025-12-10 18:32:27 -08:00
Waleed
18b7032494 v0.5.24: agent tool and UX improvements, redis service overhaul (#2291)
* feat(folders): add the ability to create a folder within a folder in popover (#2287)

* fix(agent): filter out empty params to ensure LLM can set tool params at runtime (#2288)

* fix(mcp): added backfill effect to add missing descriptions for mcp tools (#2290)

* fix(redis): cleanup access pattern across callsites (#2289)

* fix(redis): cleanup access pattern across callsites

* swap redis command to be non blocking

* improvement(log-details): polling, trace spans (#2292)

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
2025-12-10 13:09:21 -08:00
Waleed
b7bbef8620 v0.5.23: kb, logs, general ui improvements, token bucket rate limits, docs, mcp, autolayout improvements (#2286)
* fix(mcp): prevent redundant MCP server discovery calls at runtime, use cached tool schema instead (#2273)

* fix(mcp): prevent redundant MCP server discovery calls at runtime, use cached tool schema instead

* added backfill, added loading state for tools in settings > mcp

* fix tool inp

* feat(rate-limiter): token bucket algorithm  (#2270)

* fix(ratelimit): make deployed chat rate limited

* improvement(rate-limiter): use token bucket algo

* update docs

* fix

* fix type

* fix db rate limiter

* address greptile comments

* feat(i18n): update translations (#2275)

Co-authored-by: icecrasher321 <icecrasher321@users.noreply.github.com>

* fix(tools): updated kalshi and polymarket tools to accurately reflect outputs (#2274)

* feat(i18n): update translations (#2276)

Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>

* fix(autolayout): align by handle (#2277)

* fix(autolayout): align by handle

* use shared constants everywhere

* cleanup

* fix(copilot): fix custom tools (#2278)

* Fix title custom tool

* Checkpoitn (broken)

* Fix custom tool flash

* Edit workflow returns null fix

* Works

* Fix lint

* fix(ime): prevent form submission during IME composition steps (#2279)

* fix(ui): prevent form submission during IME composition steps

* chore(gitignore): add IntelliJ IDE files to .gitignore

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Waleed <walif6@gmail.com>
Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>

* feat(ui): logs, kb, emcn (#2207)

* feat(kb): emcn alignment; sidebar: popover primary; settings-modal: expand

* feat: EMCN breadcrumb; improvement(KB): UI

* fix: hydration error

* improvement(KB): UI

* feat: emcn modal sizing, KB tags; refactor: deleted old sidebar

* feat(logs): UI

* fix: add documents modal name

* feat: logs, emcn, cursorrules; refactor: logs

* feat: dashboard

* feat: notifications; improvement: logs details

* fixed random rectangle on canvas

* fixed the name of the file to align

* fix build

---------

Co-authored-by: waleed <walif6@gmail.com>

* fix(creds): glitch allowing multiple credentials in an integration (#2282)

* improvement: custom tools modal, logs-details (#2283)

* fix(docs): fix copy page button and header hook (#2284)

* improvement(chat): add the ability to download files from the deployed chat (#2280)

* added teams download and chat download file

* Removed comments

* removed comments

* component structure and download all

* removed comments

* cleanup code

* fix empty files case

* small fix

* fix(container): resize heuristic improvement (#2285)

* estimate block height for resize based on subblocks

* fix hydration error

* make more conservative

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: icecrasher321 <icecrasher321@users.noreply.github.com>
Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>
Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
Co-authored-by: mosa <mosaxiv@gmail.com>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
Co-authored-by: Adam Gough <77861281+aadamgough@users.noreply.github.com>
2025-12-10 00:57:58 -08:00
Waleed
52edbea659 v0.5.22: rss feed trigger, sftp tool, billing fixes, 413 surfacing, copilot improvements 2025-12-09 10:27:36 -08:00
Vikhyath Mondreti
d480057fd3 fix(migration): migration got removed by force push (#2253) 2025-12-08 14:08:12 -08:00
Waleed
c27c233da0 v0.5.21: google groups, virtualized code viewer, ui, autolayout, docs improvements 2025-12-08 13:10:50 -08:00
Waleed
ebef5f3a27 v0.5.20: google slides, ui fixes, subflow resizing improvements 2025-12-06 15:36:09 -08:00
Vikhyath Mondreti
12c4c2d44f v0.5.19: copilot fix 2025-12-05 15:27:31 -08:00
Vikhyath Mondreti
929a352edb fix(build): added trigger.dev sdk mock to tests (#2216) 2025-12-05 14:26:50 -08:00
Vikhyath Mondreti
6cd078b0fe v0.5.18: ui fixes, nextjs16, workspace notifications, admin APIs, loading improvements, new slack tools 2025-12-05 14:03:09 -08:00
Waleed
31874939ee v0.5.17: modals, billing fixes, bun update, zoom, dropbox, kalshi, polymarket, datadog, ahrefs, gitlab, shopify, ssh, wordpress integrations 2025-12-04 13:29:46 -08:00
Waleed
e157ce5fbc v0.5.16: MCP fixes, code refactors, jira fixes, new mistral models 2025-12-02 22:02:11 -08:00
Vikhyath Mondreti
774e5d585c v0.5.15: add tools, revert subblock prop change 2025-12-01 13:52:12 -08:00
Vikhyath Mondreti
54cc93743f v0.5.14: fix issue with teams, google selectors + cleanup code 2025-12-01 12:39:39 -08:00
Waleed
8c32ad4c0d v0.5.13: polling fixes, generic agent search tool, status page, smtp, sendgrid, linkedin, more tools (#2148)
* feat(tools): added smtp, sendgrid, mailgun, linkedin, fixed permissions in context menu (#2133)

* feat(tools): added twilio sendgrid integration

* feat(tools): added smtp, sendgrid, mailgun, fixed permissions in context menu

* added top level mocks for sporadically failing tests

* incr type safety

* fix(team-plans): track departed member usage so value not lost (#2118)

* fix(team-plans): track departed member usage so value not lost

* reset usage to 0 when they leave team

* prep merge with stagig

* regen migrations

* fix org invite + ws selection'

---------

Co-authored-by: Waleed <walif6@gmail.com>

* feat(i18n): update translations (#2134)

Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>

* feat(creators): add verification for creators (#2135)

* feat(tools): added apify block/tools  (#2136)

* feat(tools): added apify

* cleanup

* feat(i18n): update translations (#2137)

Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>

* feat(env): added more optional env var examples (#2138)

* feat(statuspage): added statuspage, updated list of tools in footer, renamed routes (#2139)

* feat(statuspage): added statuspage, updated list of tools in footer, renamed routes

* ack PR comments

* feat(tools): add generic search tool (#2140)

* feat(i18n): update translations (#2141)

* fix(sdks): bump sdk versions (#2142)

* fix(webhooks): count test webhooks towards usage limit (#2143)

* fix(bill): add requestId to webhook processing (#2144)

* improvement(subflow): remove all associated edges when moving a block into a subflow (#2145)

* improvement(subflow): remove all associated edges when moving a block into a subflow

* ack PR comments

* fix(polling): mark webhook failed on webhook trigger errors (#2146)

* fix(deps): declare core transient deps explicitly (#2147)

* fix(deps): declare core transient deps explicitly

* ack PR comments

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>
2025-12-01 10:15:36 -08:00
Waleed
1d08796853 v0.5.12: memory optimizations, sentry, incidentio, posthog, zendesk, pylon, intercom, mailchimp, loading optimizations (#2132)
* fix(memory-util): fixed unbounded array of gmail/outlook pollers causing high memory util, added missing db indexes/removed unused ones, auto-disable schedules/webhooks after 10 consecutive failures (#2115)

* fix(memory-util): fixed unbounded array of gmail/outlook pollers causing high memory util, added missing db indexes/removed unused ones, auto-disable schedules/webhooks after 10 consecutive failures

* ack PR comments

* ack

* improvement(teams-plan): seats increase simplification + not triggering checkout session (#2117)

* improvement(teams-plan): seats increase simplification + not triggering checkout session

* cleanup via helper

* feat(tools): added sentry, incidentio, and posthog tools (#2116)

* feat(tools): added sentry, incidentio, and posthog tools

* update docs

* fixed docs to use native fumadocs for llms.txt and copy markdown, fixed tool issues

* cleanup

* enhance error extractor, fixed posthog tools

* docs enhancements, cleanup

* added more incident io ops, remove zustand/shallow in favor of zustand/react/shallow

* fix type errors

* remove unnecessary comments

* added vllm to docs

* feat(i18n): update translations (#2120)

* feat(i18n): update translations

* fix build

---------

Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>

* improvement(workflow-execution): perf improvements to passing workflow state + decrypted env vars (#2119)

* improvement(execution): load workflow state once instead of 2-3 times

* decrypt only in get helper

* remove comments

* remove comments

* feat(models): host google gemini models (#2122)

* feat(models): host google gemini models

* remove unused primary key

* feat(i18n): update translations (#2123)

Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>

* feat(tools): added zendesk, pylon, intercom, & mailchimp (#2126)

* feat(tools): added zendesk, pylon, intercom, & mailchimp

* finish zendesk and pylon

* updated docs

* feat(i18n): update translations (#2129)

* feat(i18n): update translations

* fixed build

---------

Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>

* fix(permissions): add client-side permissions validation to prevent unauthorized actions, upgraded custom tool modal (#2130)

* fix(permissions): add client-side permissions validation to prevent unauthorized actions, upgraded custom tool modal

* fix failing test

* fix test

* cleanup

* fix(custom-tools): add composite index on custom tool names & workspace id (#2131)

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>
2025-11-28 16:08:06 -08:00
Waleed
ebcd243942 v0.5.11: stt, videogen, vllm, billing fixes, new models 2025-11-25 01:14:12 -08:00
Waleed
b7e814b721 v0.5.10: copilot upgrade, preprocessor, logs search, UI, code hygiene 2025-11-21 12:04:34 -08:00
Waleed
842ef27ed9 v0.5.9: add backwards compatibility for agent messages array 2025-11-20 11:19:42 -08:00
Vikhyath Mondreti
31c34b2ea3 v0.5.8: notifications, billing, ui changes, store loading state machine 2025-11-20 01:32:32 -08:00
Vikhyath Mondreti
8f0ef58056 v0.5.7: combobox selectors, usage indicator, workflow loading race condition, other improvements 2025-11-17 21:25:51 -08:00
181 changed files with 19988 additions and 2014 deletions

View File

@@ -48,6 +48,19 @@ jobs:
ENCRYPTION_KEY: '7cf672e460e430c1fba707575c2b0e2ad5a99dddf9b7b7e3b5646e630861db1c' # dummy key for CI only
run: bun run test
- name: Check schema and migrations are in sync
working-directory: packages/db
run: |
bunx drizzle-kit generate --config=./drizzle.config.ts
if [ -n "$(git status --porcelain ./migrations)" ]; then
echo "❌ Schema and migrations are out of sync!"
echo "Run 'cd packages/db && bunx drizzle-kit generate' and commit the new migrations."
git status --porcelain ./migrations
git diff ./migrations
exit 1
fi
echo "✅ Schema and migrations are in sync"
- name: Build application
env:
NODE_OPTIONS: '--no-warnings'

View File

@@ -188,6 +188,7 @@ DATABASE_URL="postgresql://postgres:your_password@localhost:5432/simstudio"
Then run the migrations:
```bash
cd packages/db # Required so drizzle picks correct .env file
bunx drizzle-kit migrate --config=./drizzle.config.ts
```

View File

@@ -0,0 +1,23 @@
import { DocsBody, DocsPage } from 'fumadocs-ui/page'
export const metadata = {
title: 'Page Not Found',
}
export default function NotFound() {
return (
<DocsPage>
<DocsBody>
<div className='flex min-h-[60vh] flex-col items-center justify-center text-center'>
<h1 className='mb-4 bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] bg-clip-text font-bold text-8xl text-transparent'>
404
</h1>
<h2 className='mb-2 font-semibold text-2xl text-foreground'>Page Not Found</h2>
<p className='text-muted-foreground'>
The page you're looking for doesn't exist or has been moved.
</p>
</div>
</DocsBody>
</DocsPage>
)
}

View File

@@ -6,7 +6,10 @@ import { source } from '@/lib/source'
export const revalidate = false
export async function GET(_req: NextRequest, { params }: { params: Promise<{ slug?: string[] }> }) {
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ slug?: string[] }> }
) {
const { slug } = await params
let lang: (typeof i18n.languages)[number] = i18n.defaultLanguage

View File

@@ -120,117 +120,117 @@ import {
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>
export const blockTypeToIconMap: Record<string, IconComponent> = {
zoom: ZoomIcon,
zep: ZepIcon,
zendesk: ZendeskIcon,
youtube: YouTubeIcon,
x: xIcon,
wordpress: WordpressIcon,
wikipedia: WikipediaIcon,
whatsapp: WhatsAppIcon,
webflow: WebflowIcon,
wealthbox: WealthboxIcon,
vision: EyeIcon,
video_generator: VideoIcon,
typeform: TypeformIcon,
twilio_voice: TwilioIcon,
twilio_sms: TwilioIcon,
tts: TTSIcon,
trello: TrelloIcon,
translate: TranslateIcon,
thinking: BrainIcon,
telegram: TelegramIcon,
tavily: TavilyIcon,
supabase: SupabaseIcon,
stt: STTIcon,
stripe: StripeIcon,
stagehand: StagehandIcon,
ssh: SshIcon,
sqs: SQSIcon,
spotify: SpotifyIcon,
smtp: SmtpIcon,
slack: SlackIcon,
shopify: ShopifyIcon,
sharepoint: MicrosoftSharepointIcon,
sftp: SftpIcon,
servicenow: ServiceNowIcon,
serper: SerperIcon,
sentry: SentryIcon,
sendgrid: SendgridIcon,
search: SearchIcon,
salesforce: SalesforceIcon,
s3: S3Icon,
resend: ResendIcon,
reddit: RedditIcon,
rds: RDSIcon,
qdrant: QdrantIcon,
posthog: PosthogIcon,
postgresql: PostgresIcon,
polymarket: PolymarketIcon,
pipedrive: PipedriveIcon,
pinecone: PineconeIcon,
perplexity: PerplexityIcon,
parallel_ai: ParallelIcon,
outlook: OutlookIcon,
openai: OpenAIIcon,
onedrive: MicrosoftOneDriveIcon,
notion: NotionIcon,
neo4j: Neo4jIcon,
mysql: MySQLIcon,
mongodb: MongoDBIcon,
mistral_parse: MistralIcon,
microsoft_teams: MicrosoftTeamsIcon,
microsoft_planner: MicrosoftPlannerIcon,
microsoft_excel: MicrosoftExcelIcon,
memory: BrainIcon,
mem0: Mem0Icon,
mailgun: MailgunIcon,
mailchimp: MailchimpIcon,
linkup: LinkupIcon,
linkedin: LinkedInIcon,
linear: LinearIcon,
knowledge: PackageSearchIcon,
kalshi: KalshiIcon,
jira: JiraIcon,
jina: JinaAIIcon,
intercom: IntercomIcon,
incidentio: IncidentioIcon,
image_generator: ImageIcon,
hunter: HunterIOIcon,
huggingface: HuggingFaceIcon,
hubspot: HubspotIcon,
grafana: GrafanaIcon,
google_vault: GoogleVaultIcon,
google_slides: GoogleSlidesIcon,
google_sheets: GoogleSheetsIcon,
google_groups: GoogleGroupsIcon,
google_forms: GoogleFormsIcon,
google_drive: GoogleDriveIcon,
google_docs: GoogleDocsIcon,
google_calendar: GoogleCalendarIcon,
google_search: GoogleIcon,
gmail: GmailIcon,
gitlab: GitLabIcon,
github: GithubIcon,
firecrawl: FirecrawlIcon,
file: DocumentIcon,
exa: ExaAIIcon,
elevenlabs: ElevenLabsIcon,
elasticsearch: ElasticsearchIcon,
dynamodb: DynamoDBIcon,
duckduckgo: DuckDuckGoIcon,
dropbox: DropboxIcon,
discord: DiscordIcon,
datadog: DatadogIcon,
cursor: CursorIcon,
confluence: ConfluenceIcon,
clay: ClayIcon,
calendly: CalendlyIcon,
browser_use: BrowserUseIcon,
asana: AsanaIcon,
arxiv: ArxivIcon,
apollo: ApolloIcon,
apify: ApifyIcon,
airtable: AirtableIcon,
ahrefs: AhrefsIcon,
airtable: AirtableIcon,
apify: ApifyIcon,
apollo: ApolloIcon,
arxiv: ArxivIcon,
asana: AsanaIcon,
browser_use: BrowserUseIcon,
calendly: CalendlyIcon,
clay: ClayIcon,
confluence: ConfluenceIcon,
cursor: CursorIcon,
datadog: DatadogIcon,
discord: DiscordIcon,
dropbox: DropboxIcon,
duckduckgo: DuckDuckGoIcon,
dynamodb: DynamoDBIcon,
elasticsearch: ElasticsearchIcon,
elevenlabs: ElevenLabsIcon,
exa: ExaAIIcon,
file: DocumentIcon,
firecrawl: FirecrawlIcon,
github: GithubIcon,
gitlab: GitLabIcon,
gmail: GmailIcon,
google_calendar: GoogleCalendarIcon,
google_docs: GoogleDocsIcon,
google_drive: GoogleDriveIcon,
google_forms: GoogleFormsIcon,
google_groups: GoogleGroupsIcon,
google_search: GoogleIcon,
google_sheets: GoogleSheetsIcon,
google_slides: GoogleSlidesIcon,
google_vault: GoogleVaultIcon,
grafana: GrafanaIcon,
hubspot: HubspotIcon,
huggingface: HuggingFaceIcon,
hunter: HunterIOIcon,
image_generator: ImageIcon,
incidentio: IncidentioIcon,
intercom: IntercomIcon,
jina: JinaAIIcon,
jira: JiraIcon,
kalshi: KalshiIcon,
knowledge: PackageSearchIcon,
linear: LinearIcon,
linkedin: LinkedInIcon,
linkup: LinkupIcon,
mailchimp: MailchimpIcon,
mailgun: MailgunIcon,
mem0: Mem0Icon,
memory: BrainIcon,
microsoft_excel: MicrosoftExcelIcon,
microsoft_planner: MicrosoftPlannerIcon,
microsoft_teams: MicrosoftTeamsIcon,
mistral_parse: MistralIcon,
mongodb: MongoDBIcon,
mysql: MySQLIcon,
neo4j: Neo4jIcon,
notion: NotionIcon,
onedrive: MicrosoftOneDriveIcon,
openai: OpenAIIcon,
outlook: OutlookIcon,
parallel_ai: ParallelIcon,
perplexity: PerplexityIcon,
pinecone: PineconeIcon,
pipedrive: PipedriveIcon,
polymarket: PolymarketIcon,
postgresql: PostgresIcon,
posthog: PosthogIcon,
qdrant: QdrantIcon,
rds: RDSIcon,
reddit: RedditIcon,
resend: ResendIcon,
s3: S3Icon,
salesforce: SalesforceIcon,
search: SearchIcon,
sendgrid: SendgridIcon,
sentry: SentryIcon,
serper: SerperIcon,
servicenow: ServiceNowIcon,
sftp: SftpIcon,
sharepoint: MicrosoftSharepointIcon,
shopify: ShopifyIcon,
slack: SlackIcon,
smtp: SmtpIcon,
spotify: SpotifyIcon,
sqs: SQSIcon,
ssh: SshIcon,
stagehand: StagehandIcon,
stripe: StripeIcon,
stt: STTIcon,
supabase: SupabaseIcon,
tavily: TavilyIcon,
telegram: TelegramIcon,
thinking: BrainIcon,
translate: TranslateIcon,
trello: TrelloIcon,
tts: TTSIcon,
twilio_sms: TwilioIcon,
twilio_voice: TwilioIcon,
typeform: TypeformIcon,
video_generator: VideoIcon,
vision: EyeIcon,
wealthbox: WealthboxIcon,
webflow: WebflowIcon,
whatsapp: WhatsAppIcon,
wikipedia: WikipediaIcon,
wordpress: WordpressIcon,
x: xIcon,
youtube: YouTubeIcon,
zendesk: ZendeskIcon,
zep: ZepIcon,
zoom: ZoomIcon,
}

View File

@@ -90,14 +90,20 @@ Ein Jira-Issue erstellen
| Parameter | Typ | Erforderlich | Beschreibung |
| --------- | ---- | -------- | ----------- |
| `domain` | string | Ja | Ihre Jira-Domain (z.B. ihrfirma.atlassian.net) |
| `domain` | string | Ja | Ihre Jira-Domain \(z.B. ihrfirma.atlassian.net\) |
| `projectId` | string | Ja | Projekt-ID für das Issue |
| `summary` | string | Ja | Zusammenfassung für das Issue |
| `description` | string | Nein | Beschreibung für das Issue |
| `priority` | string | Nein | Priorität für das Issue |
| `assignee` | string | Nein | Bearbeiter für das Issue |
| `cloudId` | string | Nein | Jira Cloud-ID für die Instanz. Wenn nicht angegeben, wird sie anhand der Domain abgerufen. |
| `issueType` | string | Ja | Art des zu erstellenden Issues (z.B. Task, Story) |
| `priority` | string | Nein | Prioritäts-ID oder -Name für das Issue \(z.B. "10000" oder "High"\) |
| `assignee` | string | Nein | Account-ID des Bearbeiters für das Issue |
| `cloudId` | string | Nein | Jira Cloud-ID für die Instanz. Wenn nicht angegeben, wird sie über die Domain abgerufen. |
| `issueType` | string | Ja | Typ des zu erstellenden Issues \(z.B. Task, Story\) |
| `labels` | array | Nein | Labels für das Issue \(Array von Label-Namen\) |
| `duedate` | string | Nein | Fälligkeitsdatum für das Issue \(Format: YYYY-MM-DD\) |
| `reporter` | string | Nein | Account-ID des Melders für das Issue |
| `environment` | string | Nein | Umgebungsinformationen für das Issue |
| `customFieldId` | string | Nein | Benutzerdefinierte Feld-ID \(z.B. customfield_10001\) |
| `customFieldValue` | string | Nein | Wert für das benutzerdefinierte Feld |
#### Ausgabe
@@ -107,6 +113,7 @@ Ein Jira-Issue erstellen
| `issueKey` | string | Erstellter Issue-Key \(z.B. PROJ-123\) |
| `summary` | string | Issue-Zusammenfassung |
| `url` | string | URL zum erstellten Issue |
| `assigneeId` | string | Account-ID des zugewiesenen Benutzers \(falls zugewiesen\) |
### `jira_bulk_read`
@@ -520,6 +527,30 @@ Einen Beobachter von einem Jira-Issue entfernen
| `issueKey` | string | Issue-Key |
| `watcherAccountId` | string | Account-ID des entfernten Beobachters |
### `jira_get_users`
Jira-Benutzer abrufen. Wenn eine Account-ID angegeben wird, wird ein einzelner Benutzer zurückgegeben. Andernfalls wird eine Liste aller Benutzer zurückgegeben.
#### Eingabe
| Parameter | Typ | Erforderlich | Beschreibung |
| --------- | ---- | -------- | ----------- |
| `domain` | string | Ja | Ihre Jira-Domain \(z.B. ihrfirma.atlassian.net\) |
| `accountId` | string | Nein | Optionale Account-ID, um einen bestimmten Benutzer abzurufen. Wenn nicht angegeben, werden alle Benutzer zurückgegeben. |
| `startAt` | number | Nein | Der Index des ersten zurückzugebenden Benutzers \(für Paginierung, Standard: 0\) |
| `maxResults` | number | Nein | Maximale Anzahl der zurückzugebenden Benutzer \(Standard: 50\) |
| `cloudId` | string | Nein | Jira Cloud-ID für die Instanz. Wenn nicht angegeben, wird sie anhand der Domain abgerufen. |
#### Ausgabe
| Parameter | Typ | Beschreibung |
| --------- | ---- | ----------- |
| `ts` | string | Zeitstempel der Operation |
| `users` | json | Array von Benutzern mit accountId, displayName, emailAddress, active-Status und avatarUrls |
| `total` | number | Gesamtanzahl der zurückgegebenen Benutzer |
| `startAt` | number | Startindex für Paginierung |
| `maxResults` | number | Maximale Ergebnisse pro Seite |
## Hinweise
- Kategorie: `tools`

View File

@@ -109,12 +109,12 @@ Lesen Sie die neuesten Nachrichten aus Slack-Kanälen. Rufen Sie den Konversatio
| Parameter | Typ | Erforderlich | Beschreibung |
| --------- | ---- | -------- | ----------- |
| `authMethod` | string | Nein | Authentifizierungsmethode: oauth oder bot_token |
| `botToken` | string | Nein | Bot-Token für benutzerdefinierten Bot |
| `botToken` | string | Nein | Bot-Token für Custom Bot |
| `channel` | string | Nein | Slack-Kanal, aus dem Nachrichten gelesen werden sollen \(z.B. #general\) |
| `userId` | string | Nein | Benutzer-ID für DM-Konversation \(z.B. U1234567890\) |
| `limit` | number | Nein | Anzahl der abzurufenden Nachrichten \(Standard: 10, max: 100\) |
| `oldest` | string | Nein | Beginn des Zeitraums \(Zeitstempel\) |
| `latest` | string | Nein | Ende des Zeitraums \(Zeitstempel\) |
| `limit` | number | Nein | Anzahl der abzurufenden Nachrichten \(Standard: 10, max: 15\) |
| `oldest` | string | Nein | Beginn des Zeitbereichs \(Zeitstempel\) |
| `latest` | string | Nein | Ende des Zeitbereichs \(Zeitstempel\) |
#### Ausgabe

View File

@@ -97,10 +97,16 @@ Write a Jira issue
| `projectId` | string | Yes | Project ID for the issue |
| `summary` | string | Yes | Summary for the issue |
| `description` | string | No | Description for the issue |
| `priority` | string | No | Priority for the issue |
| `assignee` | string | No | Assignee for the issue |
| `priority` | string | No | Priority ID or name for the issue \(e.g., "10000" or "High"\) |
| `assignee` | string | No | Assignee account ID for the issue |
| `cloudId` | string | No | Jira Cloud ID for the instance. If not provided, it will be fetched using the domain. |
| `issueType` | string | Yes | Type of issue to create \(e.g., Task, Story\) |
| `labels` | array | No | Labels for the issue \(array of label names\) |
| `duedate` | string | No | Due date for the issue \(format: YYYY-MM-DD\) |
| `reporter` | string | No | Reporter account ID for the issue |
| `environment` | string | No | Environment information for the issue |
| `customFieldId` | string | No | Custom field ID \(e.g., customfield_10001\) |
| `customFieldValue` | string | No | Value for the custom field |
#### Output
@@ -110,6 +116,7 @@ Write a Jira issue
| `issueKey` | string | Created issue key \(e.g., PROJ-123\) |
| `summary` | string | Issue summary |
| `url` | string | URL to the created issue |
| `assigneeId` | string | Account ID of the assigned user \(if assigned\) |
### `jira_bulk_read`
@@ -523,6 +530,30 @@ Remove a watcher from a Jira issue
| `issueKey` | string | Issue key |
| `watcherAccountId` | string | Removed watcher account ID |
### `jira_get_users`
Get Jira users. If an account ID is provided, returns a single user. Otherwise, returns a list of all users.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
| `accountId` | string | No | Optional account ID to get a specific user. If not provided, returns all users. |
| `startAt` | number | No | The index of the first user to return \(for pagination, default: 0\) |
| `maxResults` | number | No | Maximum number of users to return \(default: 50\) |
| `cloudId` | string | No | Jira Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | Timestamp of the operation |
| `users` | json | Array of users with accountId, displayName, emailAddress, active status, and avatarUrls |
| `total` | number | Total number of users returned |
| `startAt` | number | Pagination start index |
| `maxResults` | number | Maximum results per page |
## Notes

View File

@@ -114,7 +114,7 @@ Read the latest messages from Slack channels. Retrieve conversation history with
| `botToken` | string | No | Bot token for Custom Bot |
| `channel` | string | No | Slack channel to read messages from \(e.g., #general\) |
| `userId` | string | No | User ID for DM conversation \(e.g., U1234567890\) |
| `limit` | number | No | Number of messages to retrieve \(default: 10, max: 100\) |
| `limit` | number | No | Number of messages to retrieve \(default: 10, max: 15\) |
| `oldest` | string | No | Start of time range \(timestamp\) |
| `latest` | string | No | End of time range \(timestamp\) |

View File

@@ -89,24 +89,31 @@ Escribir una incidencia de Jira
#### Entrada
| Parámetro | Tipo | Obligatorio | Descripción |
| --------- | ---- | -------- | ----------- |
| --------- | ---- | ----------- | ----------- |
| `domain` | string | Sí | Tu dominio de Jira \(p. ej., tuempresa.atlassian.net\) |
| `projectId` | string | Sí | ID del proyecto para la incidencia |
| `summary` | string | Sí | Resumen de la incidencia |
| `description` | string | No | Descripción de la incidencia |
| `priority` | string | No | Prioridad de la incidencia |
| `assignee` | string | No | Asignado para la incidencia |
| `cloudId` | string | No | ID de Jira Cloud para la instancia. Si no se proporciona, se obtendrá utilizando el dominio. |
| `priority` | string | No | ID o nombre de prioridad para la incidencia \(p. ej., "10000" o "Alta"\) |
| `assignee` | string | No | ID de cuenta del asignado para la incidencia |
| `cloudId` | string | No | ID de Jira Cloud para la instancia. Si no se proporciona, se obtendrá usando el dominio. |
| `issueType` | string | Sí | Tipo de incidencia a crear \(p. ej., Tarea, Historia\) |
| `labels` | array | No | Etiquetas para la incidencia \(array de nombres de etiquetas\) |
| `duedate` | string | No | Fecha de vencimiento para la incidencia \(formato: AAAA-MM-DD\) |
| `reporter` | string | No | ID de cuenta del informador para la incidencia |
| `environment` | string | No | Información del entorno para la incidencia |
| `customFieldId` | string | No | ID del campo personalizado \(p. ej., customfield_10001\) |
| `customFieldValue` | string | No | Valor para el campo personalizado |
#### Salida
| Parámetro | Tipo | Descripción |
| --------- | ---- | ----------- |
| `ts` | string | Marca de tiempo de la operación |
| `issueKey` | string | Clave de la incidencia creada (p. ej., PROJ-123) |
| `issueKey` | string | Clave de la incidencia creada \(p. ej., PROJ-123\) |
| `summary` | string | Resumen de la incidencia |
| `url` | string | URL de la incidencia creada |
| `assigneeId` | string | ID de cuenta del usuario asignado \(si está asignado\) |
### `jira_bulk_read`
@@ -520,6 +527,30 @@ Eliminar un observador de una incidencia de Jira
| `issueKey` | string | Clave de incidencia |
| `watcherAccountId` | string | ID de cuenta del observador eliminado |
### `jira_get_users`
Obtener usuarios de Jira. Si se proporciona un ID de cuenta, devuelve un solo usuario. De lo contrario, devuelve una lista de todos los usuarios.
#### Entrada
| Parámetro | Tipo | Obligatorio | Descripción |
| --------- | ---- | ----------- | ----------- |
| `domain` | string | Sí | Tu dominio de Jira \(p. ej., tuempresa.atlassian.net\) |
| `accountId` | string | No | ID de cuenta opcional para obtener un usuario específico. Si no se proporciona, devuelve todos los usuarios. |
| `startAt` | number | No | El índice del primer usuario a devolver \(para paginación, predeterminado: 0\) |
| `maxResults` | number | No | Número máximo de usuarios a devolver \(predeterminado: 50\) |
| `cloudId` | string | No | ID de Jira Cloud para la instancia. Si no se proporciona, se obtendrá usando el dominio. |
#### Salida
| Parámetro | Tipo | Descripción |
| --------- | ---- | ----------- |
| `ts` | string | Marca de tiempo de la operación |
| `users` | json | Array de usuarios con accountId, displayName, emailAddress, estado activo y avatarUrls |
| `total` | number | Número total de usuarios devueltos |
| `startAt` | number | Índice de inicio de paginación |
| `maxResults` | number | Máximo de resultados por página |
## Notas
- Categoría: `tools`

View File

@@ -111,8 +111,8 @@ Lee los últimos mensajes de los canales de Slack. Recupera el historial de conv
| `authMethod` | string | No | Método de autenticación: oauth o bot_token |
| `botToken` | string | No | Token del bot para Bot personalizado |
| `channel` | string | No | Canal de Slack del que leer mensajes (p. ej., #general) |
| `userId` | string | No | ID de usuario para conversación por MD (p. ej., U1234567890) |
| `limit` | number | No | Número de mensajes a recuperar (predeterminado: 10, máx: 100) |
| `userId` | string | No | ID de usuario para conversación de mensaje directo (p. ej., U1234567890) |
| `limit` | number | No | Número de mensajes a recuperar (predeterminado: 10, máx: 15) |
| `oldest` | string | No | Inicio del rango de tiempo (marca de tiempo) |
| `latest` | string | No | Fin del rango de tiempo (marca de tiempo) |

View File

@@ -89,15 +89,21 @@ Rédiger une demande Jira
#### Entrée
| Paramètre | Type | Obligatoire | Description |
| --------- | ---- | -------- | ----------- |
| `domain` | string | Oui | Votre domaine Jira (ex. : votreentreprise.atlassian.net) |
| `projectId` | string | Oui | ID du projet pour la demande |
| `summary` | string | Oui | Résumé de la demande |
| `description` | string | Non | Description de la demande |
| `priority` | string | Non | Priorité de la demande |
| `assignee` | string | Non | Assigné de la demande |
| `cloudId` | string | Non | ID Jira Cloud pour l'instance. S'il n'est pas fourni, il sera récupéré à l'aide du domaine. |
| `issueType` | string | Oui | Type de demande à créer (ex. : Tâche, Story) |
| --------- | ---- | ----------- | ----------- |
| `domain` | chaîne | Oui | Votre domaine Jira \(ex. : votreentreprise.atlassian.net\) |
| `projectId` | chaîne | Oui | ID du projet pour le ticket |
| `summary` | chaîne | Oui | Résumé du ticket |
| `description` | chaîne | Non | Description du ticket |
| `priority` | chaîne | Non | ID ou nom de la priorité du ticket \(ex. : "10000" ou "Haute"\) |
| `assignee` | chaîne | Non | ID de compte de l'assigné pour le ticket |
| `cloudId` | chaîne | Non | ID Cloud Jira pour l'instance. S'il n'est pas fourni, il sera récupéré à l'aide du domaine. |
| `issueType` | chaîne | Oui | Type de ticket à créer \(ex. : tâche, story\) |
| `labels` | tableau | Non | Étiquettes pour le ticket \(tableau de noms d'étiquettes\) |
| `duedate` | chaîne | Non | Date d'échéance du ticket \(format : AAAA-MM-JJ\) |
| `reporter` | chaîne | Non | ID de compte du rapporteur pour le ticket |
| `environment` | chaîne | Non | Informations d'environnement pour le ticket |
| `customFieldId` | chaîne | Non | ID du champ personnalisé \(ex. : customfield_10001\) |
| `customFieldValue` | chaîne | Non | Valeur pour le champ personnalisé |
#### Sortie
@@ -107,6 +113,7 @@ Rédiger une demande Jira
| `issueKey` | chaîne | Clé du ticket créé \(ex. : PROJ-123\) |
| `summary` | chaîne | Résumé du ticket |
| `url` | chaîne | URL vers le ticket créé |
| `assigneeId` | chaîne | ID de compte de l'utilisateur assigné \(si assigné\) |
### `jira_bulk_read`
@@ -520,7 +527,31 @@ Supprimer un observateur d'un ticket Jira
| `issueKey` | string | Clé du ticket |
| `watcherAccountId` | string | ID du compte observateur supprimé |
## Notes
### `jira_get_users`
Récupère les utilisateurs Jira. Si un ID de compte est fourni, renvoie un seul utilisateur. Sinon, renvoie une liste de tous les utilisateurs.
#### Entrée
| Paramètre | Type | Obligatoire | Description |
| --------- | ---- | ----------- | ----------- |
| `domain` | chaîne | Oui | Votre domaine Jira \(ex. : votreentreprise.atlassian.net\) |
| `accountId` | chaîne | Non | ID de compte optionnel pour obtenir un utilisateur spécifique. S'il n'est pas fourni, renvoie tous les utilisateurs. |
| `startAt` | nombre | Non | L'index du premier utilisateur à renvoyer \(pour la pagination, par défaut : 0\) |
| `maxResults` | nombre | Non | Nombre maximum d'utilisateurs à renvoyer \(par défaut : 50\) |
| `cloudId` | chaîne | Non | ID Cloud Jira pour l'instance. S'il n'est pas fourni, il sera récupéré à l'aide du domaine. |
#### Sortie
| Paramètre | Type | Description |
| --------- | ---- | ----------- |
| `ts` | chaîne | Horodatage de l'opération |
| `users` | json | Tableau d'utilisateurs avec accountId, displayName, emailAddress, statut actif et avatarUrls |
| `total` | nombre | Nombre total d'utilisateurs renvoyés |
| `startAt` | nombre | Index de début de pagination |
| `maxResults` | nombre | Nombre maximum de résultats par page |
## Remarques
- Catégorie : `tools`
- Type : `jira`

View File

@@ -107,14 +107,14 @@ Lisez les derniers messages des canaux Slack. Récupérez l'historique des conve
#### Entrée
| Paramètre | Type | Obligatoire | Description |
| --------- | ---- | ---------- | ----------- |
| --------- | ---- | ----------- | ----------- |
| `authMethod` | chaîne | Non | Méthode d'authentification : oauth ou bot_token |
| `botToken` | chaîne | Non | Jeton du bot pour Bot personnalisé |
| `channel` | chaîne | Non | Canal Slack pour lire les messages \(ex. : #general\) |
| `userId` | chaîne | Non | ID utilisateur pour la conversation en MP \(ex. : U1234567890\) |
| `limit` | nombre | Non | Nombre de messages à récupérer \(par défaut : 10, max : 100\) |
| `oldest` | chaîne | Non | Début de la plage temporelle \(horodatage\) |
| `latest` | chaîne | Non | Fin de la plage temporelle \(horodatage\) |
| `channel` | chaîne | Non | Canal Slack depuis lequel lire les messages \(ex. : #general\) |
| `userId` | chaîne | Non | ID utilisateur pour la conversation en message direct \(ex. : U1234567890\) |
| `limit` | nombre | Non | Nombre de messages à récupérer \(par défaut : 10, max : 15\) |
| `oldest` | chaîne | Non | Début de la plage horaire \(horodatage\) |
| `latest` | chaîne | Non | Fin de la plage horaire \(horodatage\) |
#### Sortie

View File

@@ -94,10 +94,16 @@ Jira課題を作成する
| `projectId` | string | はい | 課題のプロジェクトID |
| `summary` | string | はい | 課題の要約 |
| `description` | string | いいえ | 課題の説明 |
| `priority` | string | いいえ | 課題の優先度 |
| `assignee` | string | いいえ | 課題の担当者 |
| `priority` | string | いいえ | 課題の優先度IDまたは名前「10000」または「高」 |
| `assignee` | string | いいえ | 課題の担当者アカウントID |
| `cloudId` | string | いいえ | インスタンスのJira Cloud ID。提供されない場合、ドメインを使用して取得されます。 |
| `issueType` | string | はい | 作成する課題のタイプ(例:タスク、ストーリー) |
| `labels` | array | いいえ | 課題のラベル(ラベル名の配列) |
| `duedate` | string | いいえ | 課題の期限形式YYYY-MM-DD |
| `reporter` | string | いいえ | 課題の報告者アカウントID |
| `environment` | string | いいえ | 課題の環境情報 |
| `customFieldId` | string | いいえ | カスタムフィールドIDcustomfield_10001 |
| `customFieldValue` | string | いいえ | カスタムフィールドの値 |
#### 出力
@@ -106,7 +112,8 @@ Jira課題を作成する
| `ts` | string | 操作のタイムスタンプ |
| `issueKey` | string | 作成された課題キーPROJ-123 |
| `summary` | string | 課題の要約 |
| `url` | string | 作成された課題のURL |
| `url` | string | 作成された課題のURL |
| `assigneeId` | string | 割り当てられたユーザーのアカウントID割り当てられている場合 |
### `jira_bulk_read`
@@ -520,7 +527,31 @@ Jira課題からウォッチャーを削除する
| `issueKey` | string | 課題キー |
| `watcherAccountId` | string | 削除されたウォッチャーのアカウントID |
## 注意事項
### `jira_get_users`
- カテゴリー: `tools`
- タイプ: `jira`
Jiraユーザーを取得します。アカウントIDが提供された場合、単一のユーザーを返します。それ以外の場合、すべてのユーザーのリストを返します。
#### 入力
| パラメータ | 型 | 必須 | 説明 |
| --------- | ---- | -------- | ----------- |
| `domain` | string | はい | あなたのJiraドメインyourcompany.atlassian.net |
| `accountId` | string | いいえ | 特定のユーザーを取得するためのオプションのアカウントID。提供されない場合、すべてのユーザーを返します。 |
| `startAt` | number | いいえ | 返す最初のユーザーのインデックスページネーション用、デフォルト0 |
| `maxResults` | number | いいえ | 返すユーザーの最大数デフォルト50 |
| `cloudId` | string | いいえ | インスタンスのJira Cloud ID。提供されない場合、ドメインを使用して取得されます。 |
#### 出力
| パラメータ | 型 | 説明 |
| --------- | ---- | ----------- |
| `ts` | string | 操作のタイムスタンプ |
| `users` | json | accountId、displayName、emailAddress、activeステータス、avatarUrlsを含むユーザーの配列 |
| `total` | number | 返されたユーザーの総数 |
| `startAt` | number | ページネーション開始インデックス |
| `maxResults` | number | ページあたりの最大結果数 |
## 注記
- カテゴリ:`tools`
- タイプ:`jira`

View File

@@ -110,8 +110,8 @@ Slackチャンネルから最新のメッセージを読み取ります。フィ
| `authMethod` | string | いいえ | 認証方法oauthまたはbot_token |
| `botToken` | string | いいえ | カスタムボット用のボットトークン |
| `channel` | string | いいえ | メッセージを読み取るSlackチャンネル#general |
| `userId` | string | いいえ | DM会話用のユーザーIDU1234567890 |
| `limit` | number | いいえ | 取得するメッセージ数デフォルト10、最大100 |
| `userId` | string | いいえ | DM会話用のユーザーIDU1234567890 |
| `limit` | number | いいえ | 取得するメッセージ数デフォルト10、最大15 |
| `oldest` | string | いいえ | 時間範囲の開始(タイムスタンプ) |
| `latest` | string | いいえ | 時間範囲の終了(タイムスタンプ) |

View File

@@ -91,13 +91,19 @@ Jira 的主要功能包括:
| 参数 | 类型 | 必需 | 描述 |
| --------- | ---- | -------- | ----------- |
| `domain` | 字符串 | 是 | 您的 Jira 域名 \(例如yourcompany.atlassian.net\) |
| `projectId` | 字符串 | 是 | 问题项目 ID |
| `summary` | 字符串 | 是 | 问题摘要 |
| `description` | 字符串 | 否 | 问题描述 |
| `priority` | 字符串 | 否 | 问题优先级 |
| `assignee` | 字符串 | 否 | 问题负责人 |
| `cloudId` | 字符串 | 否 | 实例的 Jira ID。如果未提供将使用域名获取。 |
| `issueType` | 字符串 | 是 | 要创建的问题类型 \(例如:任务、故事\) |
| `projectId` | 字符串 | 是 | 问题所属项目 ID |
| `summary` | 字符串 | 是 | 问题摘要 |
| `description` | 字符串 | 否 | 问题描述 |
| `priority` | 字符串 | 否 | 问题优先级 ID 或名称 \(例如“10000”或“High”\) |
| `assignee` | 字符串 | 否 | 问题负责人账户 ID |
| `cloudId` | 字符串 | 否 | 实例的 Jira Cloud ID。如果未提供将使用域名获取。 |
| `issueType` | 字符串 | 是 | 要创建的问题类型 \(例如:Task、Story\) |
| `labels` | 数组 | 否 | 问题标签 \(标签名称数组\) |
| `duedate` | 字符串 | 否 | 问题截止日期 \(格式YYYY-MM-DD\) |
| `reporter` | 字符串 | 否 | 问题报告人账户 ID |
| `environment` | 字符串 | 否 | 问题环境信息 |
| `customFieldId` | 字符串 | 否 | 自定义字段 ID \(例如customfield_10001\) |
| `customFieldValue` | 字符串 | 否 | 自定义字段的值 |
#### 输出
@@ -107,6 +113,7 @@ Jira 的主要功能包括:
| `issueKey` | 字符串 | 创建的问题键 \(例如PROJ-123\) |
| `summary` | 字符串 | 问题摘要 |
| `url` | 字符串 | 创建的问题的 URL |
| `assigneeId` | 字符串 | 已分配用户的账户 ID如已分配 |
### `jira_bulk_read`
@@ -520,7 +527,31 @@ Jira 的主要功能包括:
| `issueKey` | string | 问题键 |
| `watcherAccountId` | string | 移除的观察者账户 ID |
## 注意事项
### `jira_get_users`
- 类别: `tools`
- 类型: `jira`
获取 Jira 用户。如果提供了账户 ID则返回单个用户否则返回所有用户的列表。
#### 输入
| 参数 | 类型 | 必需 | 描述 |
| --------- | ---- | -------- | ----------- |
| `domain` | 字符串 | 是 | 您的 Jira 域名 \(例如yourcompany.atlassian.net\) |
| `accountId` | 字符串 | 否 | 可选账户 ID用于获取特定用户。如果未提供则返回所有用户。 |
| `startAt` | 数字 | 否 | 要返回的第一个用户的索引 \(用于分页默认值0\) |
| `maxResults` | 数字 | 否 | 要返回的最大用户数 \(默认值50\) |
| `cloudId` | 字符串 | 否 | 实例的 Jira Cloud ID。如果未提供将使用域名获取。 |
#### 输出
| 参数 | 类型 | 描述 |
| --------- | ---- | ----------- |
| `ts` | 字符串 | 操作的时间戳 |
| `users` | json | 用户数组,包含 accountId、displayName、emailAddress、active 状态和 avatarUrls |
| `total` | 数字 | 返回的用户总数 |
| `startAt` | 数字 | 分页起始索引 |
| `maxResults` | 数字 | 每页最大结果数 |
## 备注
- 分类:`tools`
- 类型:`jira`

View File

@@ -109,10 +109,10 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
| `authMethod` | string | 否 | 认证方法oauth 或 bot_token |
| `botToken` | string | 否 | 自定义 Bot 的令牌 |
| `channel` | string | 否 | 要读取消息的 Slack 频道(例如,#general |
| `userId` | string | 否 | DM 话的用户 ID例如U1234567890 |
| `limit` | number | 否 | 要检索的消息数量默认10最大100 |
| `oldest` | string | 否 | 时间范围的开始(时间戳) |
| `latest` | string | 否 | 时间范围结束(时间戳) |
| `userId` | string | 否 | DM 话的用户 ID例如U1234567890 |
| `limit` | number | 否 | 要检索的消息数量默认10最大15 |
| `oldest` | string | 否 | 时间范围始(时间戳) |
| `latest` | string | 否 | 时间范围结束(时间戳) |
#### 输出

View File

@@ -903,7 +903,7 @@ checksums:
content/24: 228a8ece96627883153b826a1cbaa06c
content/25: 53abe061a259c296c82676b4770ddd1b
content/26: 371d0e46b4bd2c23f559b8bc112f6955
content/27: 03e8b10ec08b354de98e360b66b779e3
content/27: 5b9546f77fbafc0741f3fc2548f81c7e
content/28: bcadfc362b69078beee0088e5936c98b
content/29: b82def7d82657f941fbe60df3924eeeb
content/30: 1ca7ee3856805fa1718031c5f75b6ffb
@@ -2521,9 +2521,9 @@ checksums:
content/22: ef92d95455e378abe4d27a1cdc5e1aed
content/23: febd6019055f3754953fd93395d0dbf2
content/24: 371d0e46b4bd2c23f559b8bc112f6955
content/25: 7ef3f388e5ee9346bac54c771d825f40
content/25: caf6acbe2a4495ca055cb9006ce47250
content/26: bcadfc362b69078beee0088e5936c98b
content/27: e0fa91c45aa780fc03e91df77417f893
content/27: 57662dd91f8d1d807377fd48fa0e9142
content/28: b463f54cd5fe2458b5842549fbb5e1ce
content/29: 55f8c724e1a2463bc29a32518a512c73
content/30: 371d0e46b4bd2c23f559b8bc112f6955
@@ -2638,8 +2638,14 @@ checksums:
content/139: 33fde4c3da4584b51f06183b7b192a78
content/140: bcadfc362b69078beee0088e5936c98b
content/141: b7451190f100388d999c183958d787a7
content/142: b3f310d5ef115bea5a8b75bf25d7ea9a
content/143: 4930918f803340baa861bed9cdf789de
content/142: d0f9e799e2e5cc62de60668d35fd846f
content/143: b19069ff19899fe202217e06e002c447
content/144: 371d0e46b4bd2c23f559b8bc112f6955
content/145: 480fd62f8d9cc18467e82f4c3f70beea
content/146: bcadfc362b69078beee0088e5936c98b
content/147: 4e73a65d3b873f3979587e10a0f39e72
content/148: b3f310d5ef115bea5a8b75bf25d7ea9a
content/149: 4930918f803340baa861bed9cdf789de
8f76e389f6226f608571622b015ca6a1:
meta/title: ddfe2191ea61b34d8b7cc1d7c19b94ac
meta/description: 049ff551f2ebabb15cdea0c71bd8e4eb

View File

@@ -573,10 +573,10 @@ export default function LoginPage({
<Dialog open={forgotPasswordOpen} onOpenChange={setForgotPasswordOpen}>
<DialogContent className='auth-card auth-card-shadow max-w-[540px] rounded-[10px] border backdrop-blur-sm'>
<DialogHeader>
<DialogTitle className='auth-text-primary font-semibold text-xl tracking-tight'>
<DialogTitle className='font-semibold text-black text-xl tracking-tight'>
Reset Password
</DialogTitle>
<DialogDescription className='auth-text-secondary text-sm'>
<DialogDescription className='text-muted-foreground text-sm'>
Enter your email address and we'll send you a link to reset your password if your
account exists.
</DialogDescription>

View File

@@ -109,7 +109,7 @@ export default function Footer({ fullWidth = false }: FooterProps) {
{FOOTER_BLOCKS.map((block) => (
<Link
key={block}
href={`https://docs.sim.ai/blocks/${block.toLowerCase().replace(' ', '-')}`}
href={`https://docs.sim.ai/blocks/${block.toLowerCase().replaceAll(' ', '-')}`}
target='_blank'
rel='noopener noreferrer'
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'

View File

@@ -1,8 +1,7 @@
import Image from 'next/image'
import Link from 'next/link'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { getAllPostMeta } from '@/lib/blog/registry'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { PostGrid } from '@/app/(landing)/studio/post-grid'
export const revalidate = 3600
@@ -18,7 +17,6 @@ export default async function StudioIndex({
const all = await getAllPostMeta()
const filtered = tag ? all.filter((p) => p.tags.includes(tag)) : all
// Sort to ensure featured post is first on page 1
const sorted =
pageNum === 1
? filtered.sort((a, b) => {
@@ -63,69 +61,7 @@ export default async function StudioIndex({
</div> */}
{/* Grid layout for consistent rows */}
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6 lg:grid-cols-3'>
{posts.map((p, i) => {
return (
<Link key={p.slug} href={`/studio/${p.slug}`} className='group flex flex-col'>
<div className='flex h-full flex-col overflow-hidden rounded-xl border border-gray-200 transition-colors duration-300 hover:border-gray-300'>
<Image
src={p.ogImage}
alt={p.title}
width={800}
height={450}
className='h-48 w-full object-cover'
sizes='(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw'
loading='lazy'
unoptimized
/>
<div className='flex flex-1 flex-col p-4'>
<div className='mb-2 text-gray-600 text-xs'>
{new Date(p.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</div>
<h3 className='shine-text mb-1 font-medium text-lg leading-tight'>{p.title}</h3>
<p className='mb-3 line-clamp-3 flex-1 text-gray-700 text-sm'>{p.description}</p>
<div className='flex items-center gap-2'>
<div className='-space-x-1.5 flex'>
{(p.authors && p.authors.length > 0 ? p.authors : [p.author])
.slice(0, 3)
.map((author, idx) => (
<Avatar key={idx} className='size-4 border border-white'>
<AvatarImage src={author?.avatarUrl} alt={author?.name} />
<AvatarFallback className='border border-white bg-gray-100 text-[10px] text-gray-600'>
{author?.name.slice(0, 2)}
</AvatarFallback>
</Avatar>
))}
</div>
<span className='text-gray-600 text-xs'>
{(p.authors && p.authors.length > 0 ? p.authors : [p.author])
.slice(0, 2)
.map((a) => a?.name)
.join(', ')}
{(p.authors && p.authors.length > 0 ? p.authors : [p.author]).length > 2 && (
<>
{' '}
and{' '}
{(p.authors && p.authors.length > 0 ? p.authors : [p.author]).length - 2}{' '}
other
{(p.authors && p.authors.length > 0 ? p.authors : [p.author]).length - 2 >
1
? 's'
: ''}
</>
)}
</span>
</div>
</div>
</div>
</Link>
)
})}
</div>
<PostGrid posts={posts} />
{totalPages > 1 && (
<div className='mt-10 flex items-center justify-center gap-3'>

View File

@@ -0,0 +1,90 @@
'use client'
import Image from 'next/image'
import Link from 'next/link'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
interface Author {
id: string
name: string
avatarUrl?: string
url?: string
}
interface Post {
slug: string
title: string
description: string
date: string
ogImage: string
author: Author
authors?: Author[]
featured?: boolean
}
export function PostGrid({ posts }: { posts: Post[] }) {
return (
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6 lg:grid-cols-3'>
{posts.map((p, index) => (
<Link key={p.slug} href={`/studio/${p.slug}`} className='group flex flex-col'>
<div className='flex h-full flex-col overflow-hidden rounded-xl border border-gray-200 transition-colors duration-300 hover:border-gray-300'>
{/* Image container with fixed aspect ratio to prevent layout shift */}
<div className='relative aspect-video w-full overflow-hidden'>
<Image
src={p.ogImage}
alt={p.title}
sizes='(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw'
unoptimized
priority={index < 6}
loading={index < 6 ? undefined : 'lazy'}
fill
style={{ objectFit: 'cover' }}
/>
</div>
<div className='flex flex-1 flex-col p-4'>
<div className='mb-2 text-gray-600 text-xs'>
{new Date(p.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</div>
<h3 className='shine-text mb-1 font-medium text-lg leading-tight'>{p.title}</h3>
<p className='mb-3 line-clamp-3 flex-1 text-gray-700 text-sm'>{p.description}</p>
<div className='flex items-center gap-2'>
<div className='-space-x-1.5 flex'>
{(p.authors && p.authors.length > 0 ? p.authors : [p.author])
.slice(0, 3)
.map((author, idx) => (
<Avatar key={idx} className='size-4 border border-white'>
<AvatarImage src={author?.avatarUrl} alt={author?.name} />
<AvatarFallback className='border border-white bg-gray-100 text-[10px] text-gray-600'>
{author?.name.slice(0, 2)}
</AvatarFallback>
</Avatar>
))}
</div>
<span className='text-gray-600 text-xs'>
{(p.authors && p.authors.length > 0 ? p.authors : [p.author])
.slice(0, 2)
.map((a) => a?.name)
.join(', ')}
{(p.authors && p.authors.length > 0 ? p.authors : [p.author]).length > 2 && (
<>
{' '}
and {(p.authors && p.authors.length > 0 ? p.authors : [p.author]).length - 2}{' '}
other
{(p.authors && p.authors.length > 0 ? p.authors : [p.author]).length - 2 > 1
? 's'
: ''}
</>
)}
</span>
</div>
</div>
</div>
</Link>
))}
</div>
)
}

View File

@@ -12,6 +12,7 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
pathname === '/' ||
pathname.startsWith('/login') ||
pathname.startsWith('/signup') ||
pathname.startsWith('/reset-password') ||
pathname.startsWith('/sso') ||
pathname.startsWith('/terms') ||
pathname.startsWith('/privacy') ||

View File

@@ -759,3 +759,24 @@ input[type="search"]::-ms-clear {
--surface-elevated: #202020;
}
}
/**
* Remove backticks from inline code in prose (Tailwind Typography default)
*/
.prose code::before,
.prose code::after {
content: none !important;
}
/**
* Remove underlines from heading anchor links in prose
*/
.prose h1 a,
.prose h2 a,
.prose h3 a,
.prose h4 a,
.prose h5 a,
.prose h6 a {
text-decoration: none !important;
color: inherit !important;
}

View File

@@ -32,7 +32,17 @@ export async function GET(request: NextRequest) {
.from(account)
.where(and(...whereConditions))
return NextResponse.json({ accounts })
// Use the user's email as the display name (consistent with credential selector)
const userEmail = session.user.email
const accountsWithDisplayName = accounts.map((acc) => ({
id: acc.id,
accountId: acc.accountId,
providerId: acc.providerId,
displayName: userEmail || acc.providerId,
}))
return NextResponse.json({ accounts: accountsWithDisplayName })
} catch (error) {
logger.error('Failed to fetch accounts', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })

View File

@@ -6,6 +6,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockRequest, setupAuthApiMocks } from '@/app/api/__test-utils__/utils'
vi.mock('@/lib/core/utils/urls', () => ({
getBaseUrl: vi.fn(() => 'https://app.example.com'),
}))
describe('Forget Password API Route', () => {
beforeEach(() => {
vi.resetModules()
@@ -15,7 +19,7 @@ describe('Forget Password API Route', () => {
vi.clearAllMocks()
})
it('should send password reset email successfully', async () => {
it('should send password reset email successfully with same-origin redirectTo', async () => {
setupAuthApiMocks({
operations: {
forgetPassword: { success: true },
@@ -24,7 +28,7 @@ describe('Forget Password API Route', () => {
const req = createMockRequest('POST', {
email: 'test@example.com',
redirectTo: 'https://example.com/reset',
redirectTo: 'https://app.example.com/reset',
})
const { POST } = await import('@/app/api/auth/forget-password/route')
@@ -39,12 +43,36 @@ describe('Forget Password API Route', () => {
expect(auth.auth.api.forgetPassword).toHaveBeenCalledWith({
body: {
email: 'test@example.com',
redirectTo: 'https://example.com/reset',
redirectTo: 'https://app.example.com/reset',
},
method: 'POST',
})
})
it('should reject external redirectTo URL', async () => {
setupAuthApiMocks({
operations: {
forgetPassword: { success: true },
},
})
const req = createMockRequest('POST', {
email: 'test@example.com',
redirectTo: 'https://evil.com/phishing',
})
const { POST } = await import('@/app/api/auth/forget-password/route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.message).toBe('Redirect URL must be a valid same-origin URL')
const auth = await import('@/lib/auth')
expect(auth.auth.api.forgetPassword).not.toHaveBeenCalled()
})
it('should send password reset email without redirectTo', async () => {
setupAuthApiMocks({
operations: {

View File

@@ -1,6 +1,7 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { auth } from '@/lib/auth'
import { isSameOrigin } from '@/lib/core/utils/validation'
import { createLogger } from '@/lib/logs/console/logger'
export const dynamic = 'force-dynamic'
@@ -13,10 +14,15 @@ const forgetPasswordSchema = z.object({
.email('Please provide a valid email address'),
redirectTo: z
.string()
.url('Redirect URL must be a valid URL')
.optional()
.or(z.literal(''))
.transform((val) => (val === '' ? undefined : val)),
.transform((val) => (val === '' || val === undefined ? undefined : val))
.refine(
(val) => val === undefined || (z.string().url().safeParse(val).success && isSameOrigin(val)),
{
message: 'Redirect URL must be a valid same-origin URL',
}
),
})
export async function POST(request: NextRequest) {

View File

@@ -11,7 +11,7 @@ import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('CopilotChatsListAPI')
export async function GET(_req: NextRequest) {
export async function GET(_request: NextRequest) {
try {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {

View File

@@ -38,14 +38,13 @@ export async function GET(
const cloudKey = isCloudPath ? path.slice(1).join('/') : fullPath
const contextParam = request.nextUrl.searchParams.get('context')
const legacyBucketType = request.nextUrl.searchParams.get('bucket')
const context = contextParam || (isCloudPath ? inferContextFromKey(cloudKey) : undefined)
if (context === 'profile-pictures') {
logger.info('Serving public profile picture:', { cloudKey })
if (context === 'profile-pictures' || context === 'og-images') {
logger.info(`Serving public ${context}:`, { cloudKey })
if (isUsingCloudStorage() || isCloudPath) {
return await handleCloudProxyPublic(cloudKey, context, legacyBucketType)
return await handleCloudProxyPublic(cloudKey, context)
}
return await handleLocalFilePublic(fullPath)
}
@@ -182,8 +181,7 @@ async function handleCloudProxy(
async function handleCloudProxyPublic(
cloudKey: string,
context: StorageContext,
legacyBucketType?: string | null
context: StorageContext
): Promise<NextResponse> {
try {
let fileBuffer: Buffer

View File

@@ -1,7 +1,6 @@
import { runs } from '@trigger.dev/sdk'
import { type NextRequest, NextResponse } from 'next/server'
import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service'
import { getSession } from '@/lib/auth'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import { createErrorResponse } from '@/app/api/workflows/utils'
@@ -18,38 +17,44 @@ export async function GET(
try {
logger.debug(`[${requestId}] Getting status for task: ${taskId}`)
// Try session auth first (for web UI)
const session = await getSession()
let authenticatedUserId: string | null = session?.user?.id || null
if (!authenticatedUserId) {
const apiKeyHeader = request.headers.get('x-api-key')
if (apiKeyHeader) {
const authResult = await authenticateApiKeyFromHeader(apiKeyHeader)
if (authResult.success && authResult.userId) {
authenticatedUserId = authResult.userId
if (authResult.keyId) {
await updateApiKeyLastUsed(authResult.keyId).catch((error) => {
logger.warn(`[${requestId}] Failed to update API key last used timestamp:`, {
keyId: authResult.keyId,
error,
})
})
}
}
}
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
logger.warn(`[${requestId}] Unauthorized task status request`)
return createErrorResponse(authResult.error || 'Authentication required', 401)
}
if (!authenticatedUserId) {
return createErrorResponse('Authentication required', 401)
}
const authenticatedUserId = authResult.userId
// Fetch task status from Trigger.dev
const run = await runs.retrieve(taskId)
logger.debug(`[${requestId}] Task ${taskId} status: ${run.status}`)
// Map Trigger.dev status to our format
const payload = run.payload as any
if (payload?.workflowId) {
const { verifyWorkflowAccess } = await import('@/socket-server/middleware/permissions')
const accessCheck = await verifyWorkflowAccess(authenticatedUserId, payload.workflowId)
if (!accessCheck.hasAccess) {
logger.warn(`[${requestId}] User ${authenticatedUserId} denied access to task ${taskId}`, {
workflowId: payload.workflowId,
})
return createErrorResponse('Access denied', 403)
}
logger.debug(`[${requestId}] User ${authenticatedUserId} has access to task ${taskId}`)
} else {
if (payload?.userId && payload.userId !== authenticatedUserId) {
logger.warn(
`[${requestId}] User ${authenticatedUserId} attempted to access task ${taskId} owned by ${payload.userId}`
)
return createErrorResponse('Access denied', 403)
}
if (!payload?.userId) {
logger.warn(
`[${requestId}] Task ${taskId} has no ownership information in payload. Denying access for security.`
)
return createErrorResponse('Access denied', 403)
}
}
const statusMap = {
QUEUED: 'queued',
WAITING_FOR_DEPLOY: 'queued',
@@ -67,7 +72,6 @@ export async function GET(
const mappedStatus = statusMap[run.status as keyof typeof statusMap] || 'unknown'
// Build response based on status
const response: any = {
success: true,
taskId,
@@ -77,21 +81,18 @@ export async function GET(
},
}
// Add completion details if finished
if (mappedStatus === 'completed') {
response.output = run.output // This contains the workflow execution results
response.metadata.completedAt = run.finishedAt
response.metadata.duration = run.durationMs
}
// Add error details if failed
if (mappedStatus === 'failed') {
response.error = run.error
response.metadata.completedAt = run.finishedAt
response.metadata.duration = run.durationMs
}
// Add progress info if still processing
if (mappedStatus === 'processing' || mappedStatus === 'queued') {
response.estimatedDuration = 180000 // 3 minutes max from our config
}
@@ -107,6 +108,3 @@ export async function GET(
return createErrorResponse('Failed to fetch task status', 500)
}
}
// TODO: Implement task cancellation via Trigger.dev API if needed
// export async function DELETE() { ... }

View File

@@ -27,7 +27,7 @@ const UpdateKnowledgeBaseSchema = z.object({
.optional(),
})
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = generateRequestId()
const { id } = await params
@@ -133,7 +133,10 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
}
}
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const requestId = generateRequestId()
const { id } = await params

View File

@@ -1,32 +1,72 @@
import { db } from '@sim/db'
import { workflowExecutionLogs, workflowExecutionSnapshots } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import {
permissions,
workflow,
workflowExecutionLogs,
workflowExecutionSnapshots,
} from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('LogsByExecutionIdAPI')
export async function GET(
_request: NextRequest,
request: NextRequest,
{ params }: { params: Promise<{ executionId: string }> }
) {
const requestId = generateRequestId()
try {
const { executionId } = await params
logger.debug(`Fetching execution data for: ${executionId}`)
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
logger.warn(`[${requestId}] Unauthorized execution data access attempt for: ${executionId}`)
return NextResponse.json(
{ error: authResult.error || 'Authentication required' },
{ status: 401 }
)
}
const authenticatedUserId = authResult.userId
logger.debug(
`[${requestId}] Fetching execution data for: ${executionId} (auth: ${authResult.authType})`
)
// Get the workflow execution log to find the snapshot
const [workflowLog] = await db
.select()
.select({
id: workflowExecutionLogs.id,
workflowId: workflowExecutionLogs.workflowId,
executionId: workflowExecutionLogs.executionId,
stateSnapshotId: workflowExecutionLogs.stateSnapshotId,
trigger: workflowExecutionLogs.trigger,
startedAt: workflowExecutionLogs.startedAt,
endedAt: workflowExecutionLogs.endedAt,
totalDurationMs: workflowExecutionLogs.totalDurationMs,
cost: workflowExecutionLogs.cost,
})
.from(workflowExecutionLogs)
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
.innerJoin(
permissions,
and(
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workflow.workspaceId),
eq(permissions.userId, authenticatedUserId)
)
)
.where(eq(workflowExecutionLogs.executionId, executionId))
.limit(1)
if (!workflowLog) {
logger.warn(`[${requestId}] Execution not found or access denied: ${executionId}`)
return NextResponse.json({ error: 'Workflow execution not found' }, { status: 404 })
}
// Get the workflow state snapshot
const [snapshot] = await db
.select()
.from(workflowExecutionSnapshots)
@@ -34,6 +74,7 @@ export async function GET(
.limit(1)
if (!snapshot) {
logger.warn(`[${requestId}] Workflow state snapshot not found for execution: ${executionId}`)
return NextResponse.json({ error: 'Workflow state snapshot not found' }, { status: 404 })
}
@@ -50,14 +91,14 @@ export async function GET(
},
}
logger.debug(`Successfully fetched execution data for: ${executionId}`)
logger.debug(`[${requestId}] Successfully fetched execution data for: ${executionId}`)
logger.debug(
`Workflow state contains ${Object.keys((snapshot.stateData as any)?.blocks || {}).length} blocks`
`[${requestId}] Workflow state contains ${Object.keys((snapshot.stateData as any)?.blocks || {}).length} blocks`
)
return NextResponse.json(response)
} catch (error) {
logger.error('Error fetching execution data:', error)
logger.error(`[${requestId}] Error fetching execution data:`, error)
return NextResponse.json({ error: 'Failed to fetch execution data' }, { status: 500 })
}
}

View File

@@ -3,8 +3,10 @@ import { memory, workflowBlocks } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import { getWorkflowAccessContext } from '@/lib/workflows/utils'
const logger = createLogger('MemoryByIdAPI')
@@ -65,6 +67,65 @@ const memoryPutBodySchema = z.object({
workflowId: z.string().uuid('Invalid workflow ID format'),
})
/**
* Validates authentication and workflow access for memory operations
* @param request - The incoming request
* @param workflowId - The workflow ID to check access for
* @param requestId - Request ID for logging
* @param action - 'read' for GET, 'write' for PUT/DELETE
* @returns Object with userId if successful, or error response if failed
*/
async function validateMemoryAccess(
request: NextRequest,
workflowId: string,
requestId: string,
action: 'read' | 'write'
): Promise<{ userId: string } | { error: NextResponse }> {
const authResult = await checkHybridAuth(request, {
requireWorkflowId: false,
})
if (!authResult.success || !authResult.userId) {
logger.warn(`[${requestId}] Unauthorized memory ${action} attempt`)
return {
error: NextResponse.json(
{ success: false, error: { message: 'Authentication required' } },
{ status: 401 }
),
}
}
const accessContext = await getWorkflowAccessContext(workflowId, authResult.userId)
if (!accessContext) {
logger.warn(`[${requestId}] Workflow ${workflowId} not found`)
return {
error: NextResponse.json(
{ success: false, error: { message: 'Workflow not found' } },
{ status: 404 }
),
}
}
const { isOwner, workspacePermission } = accessContext
const hasAccess =
action === 'read'
? isOwner || workspacePermission !== null
: isOwner || workspacePermission === 'write' || workspacePermission === 'admin'
if (!hasAccess) {
logger.warn(
`[${requestId}] User ${authResult.userId} denied ${action} access to workflow ${workflowId}`
)
return {
error: NextResponse.json(
{ success: false, error: { message: 'Access denied' } },
{ status: 403 }
),
}
}
return { userId: authResult.userId }
}
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
@@ -101,6 +162,11 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const { workflowId: validatedWorkflowId } = validation.data
const accessCheck = await validateMemoryAccess(request, validatedWorkflowId, requestId, 'read')
if ('error' in accessCheck) {
return accessCheck.error
}
const memories = await db
.select()
.from(memory)
@@ -203,6 +269,11 @@ export async function DELETE(
const { workflowId: validatedWorkflowId } = validation.data
const accessCheck = await validateMemoryAccess(request, validatedWorkflowId, requestId, 'write')
if ('error' in accessCheck) {
return accessCheck.error
}
const existingMemory = await db
.select({ id: memory.id })
.from(memory)
@@ -296,6 +367,11 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
)
}
const accessCheck = await validateMemoryAccess(request, validatedWorkflowId, requestId, 'write')
if ('error' in accessCheck) {
return accessCheck.error
}
const existingMemories = await db
.select()
.from(memory)

View File

@@ -28,7 +28,7 @@ const updateInvitationSchema = z.object({
// Get invitation details
export async function GET(
_req: NextRequest,
_request: NextRequest,
{ params }: { params: Promise<{ id: string; invitationId: string }> }
) {
const { id: organizationId, invitationId } = await params

View File

@@ -1,16 +1,19 @@
import { db } from '@sim/db'
import { templates, user } from '@sim/db/schema'
import { templates } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import { verifySuperUser } from '@/lib/templates/permissions'
const logger = createLogger('TemplateApprovalAPI')
export const revalidate = 0
// POST /api/templates/[id]/approve - Approve a template (super users only)
/**
* POST /api/templates/[id]/approve - Approve a template (super users only)
*/
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = generateRequestId()
const { id } = await params
@@ -22,23 +25,18 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check if user is a super user
const currentUser = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
if (!currentUser[0]?.isSuperUser) {
const { isSuperUser } = await verifySuperUser(session.user.id)
if (!isSuperUser) {
logger.warn(`[${requestId}] Non-super user attempted to approve template: ${id}`)
return NextResponse.json({ error: 'Only super users can approve templates' }, { status: 403 })
}
// Check if template exists
const existingTemplate = await db.select().from(templates).where(eq(templates.id, id)).limit(1)
if (existingTemplate.length === 0) {
logger.warn(`[${requestId}] Template not found for approval: ${id}`)
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
}
// Update template status to approved
await db
.update(templates)
.set({ status: 'approved', updatedAt: new Date() })
@@ -56,9 +54,11 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
}
}
// POST /api/templates/[id]/reject - Reject a template (super users only)
/**
* DELETE /api/templates/[id]/approve - Unapprove a template (super users only)
*/
export async function DELETE(
request: NextRequest,
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const requestId = generateRequestId()
@@ -71,23 +71,18 @@ export async function DELETE(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check if user is a super user
const currentUser = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
if (!currentUser[0]?.isSuperUser) {
const { isSuperUser } = await verifySuperUser(session.user.id)
if (!isSuperUser) {
logger.warn(`[${requestId}] Non-super user attempted to reject template: ${id}`)
return NextResponse.json({ error: 'Only super users can reject templates' }, { status: 403 })
}
// Check if template exists
const existingTemplate = await db.select().from(templates).where(eq(templates.id, id)).limit(1)
if (existingTemplate.length === 0) {
logger.warn(`[${requestId}] Template not found for rejection: ${id}`)
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
}
// Update template status to rejected
await db
.update(templates)
.set({ status: 'rejected', updatedAt: new Date() })

View File

@@ -0,0 +1,142 @@
import { db } from '@sim/db'
import { templates } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { createLogger } from '@/lib/logs/console/logger'
import { verifyTemplateOwnership } from '@/lib/templates/permissions'
import { uploadFile } from '@/lib/uploads/core/storage-service'
import { isValidPng } from '@/lib/uploads/utils/validation'
const logger = createLogger('TemplateOGImageAPI')
/**
* PUT /api/templates/[id]/og-image
* Upload a pre-generated OG image for a template.
* Accepts base64-encoded image data in the request body.
*/
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = generateRequestId()
const { id } = await params
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized OG image upload attempt for template: ${id}`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { authorized, error, status } = await verifyTemplateOwnership(
id,
session.user.id,
'admin'
)
if (!authorized) {
logger.warn(`[${requestId}] User denied permission to upload OG image for template ${id}`)
return NextResponse.json({ error }, { status: status || 403 })
}
const body = await request.json()
const { imageData } = body
if (!imageData || typeof imageData !== 'string') {
return NextResponse.json(
{ error: 'Missing or invalid imageData (expected base64 string)' },
{ status: 400 }
)
}
const base64Data = imageData.includes(',') ? imageData.split(',')[1] : imageData
const imageBuffer = Buffer.from(base64Data, 'base64')
if (!isValidPng(imageBuffer)) {
return NextResponse.json({ error: 'Invalid PNG image data' }, { status: 400 })
}
const maxSize = 5 * 1024 * 1024
if (imageBuffer.length > maxSize) {
return NextResponse.json({ error: 'Image too large. Maximum size is 5MB.' }, { status: 400 })
}
const timestamp = Date.now()
const storageKey = `og-images/templates/${id}/${timestamp}.png`
logger.info(`[${requestId}] Uploading OG image for template ${id}: ${storageKey}`)
const uploadResult = await uploadFile({
file: imageBuffer,
fileName: storageKey,
contentType: 'image/png',
context: 'og-images',
preserveKey: true,
customKey: storageKey,
})
const baseUrl = getBaseUrl()
const ogImageUrl = `${baseUrl}${uploadResult.path}?context=og-images`
await db
.update(templates)
.set({
ogImageUrl,
updatedAt: new Date(),
})
.where(eq(templates.id, id))
logger.info(`[${requestId}] Successfully uploaded OG image for template ${id}: ${ogImageUrl}`)
return NextResponse.json({
success: true,
ogImageUrl,
})
} catch (error: unknown) {
logger.error(`[${requestId}] Error uploading OG image for template ${id}:`, error)
return NextResponse.json({ error: 'Failed to upload OG image' }, { status: 500 })
}
}
/**
* DELETE /api/templates/[id]/og-image
* Remove the OG image for a template.
*/
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const requestId = generateRequestId()
const { id } = await params
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { authorized, error, status } = await verifyTemplateOwnership(
id,
session.user.id,
'admin'
)
if (!authorized) {
logger.warn(`[${requestId}] User denied permission to delete OG image for template ${id}`)
return NextResponse.json({ error }, { status: status || 403 })
}
await db
.update(templates)
.set({
ogImageUrl: null,
updatedAt: new Date(),
})
.where(eq(templates.id, id))
logger.info(`[${requestId}] Removed OG image for template ${id}`)
return NextResponse.json({ success: true })
} catch (error: unknown) {
logger.error(`[${requestId}] Error removing OG image for template ${id}:`, error)
return NextResponse.json({ error: 'Failed to remove OG image' }, { status: 500 })
}
}

View File

@@ -1,16 +1,19 @@
import { db } from '@sim/db'
import { templates, user } from '@sim/db/schema'
import { templates } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import { verifySuperUser } from '@/lib/templates/permissions'
const logger = createLogger('TemplateRejectionAPI')
export const revalidate = 0
// POST /api/templates/[id]/reject - Reject a template (super users only)
/**
* POST /api/templates/[id]/reject - Reject a template (super users only)
*/
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = generateRequestId()
const { id } = await params
@@ -22,23 +25,18 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check if user is a super user
const currentUser = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
if (!currentUser[0]?.isSuperUser) {
const { isSuperUser } = await verifySuperUser(session.user.id)
if (!isSuperUser) {
logger.warn(`[${requestId}] Non-super user attempted to reject template: ${id}`)
return NextResponse.json({ error: 'Only super users can reject templates' }, { status: 403 })
}
// Check if template exists
const existingTemplate = await db.select().from(templates).where(eq(templates.id, id)).limit(1)
if (existingTemplate.length === 0) {
logger.warn(`[${requestId}] Template not found for rejection: ${id}`)
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
}
// Update template status to rejected
await db
.update(templates)
.set({ status: 'rejected', updatedAt: new Date() })

View File

@@ -1,6 +1,6 @@
import { db } from '@sim/db'
import { member, templateCreators, templates, workflow } from '@sim/db/schema'
import { and, eq, or, sql } from 'drizzle-orm'
import { templateCreators, templates, workflow } from '@sim/db/schema'
import { eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
@@ -15,7 +15,6 @@ const logger = createLogger('TemplateByIdAPI')
export const revalidate = 0
// GET /api/templates/[id] - Retrieve a single template by ID
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = generateRequestId()
const { id } = await params
@@ -25,7 +24,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
logger.debug(`[${requestId}] Fetching template: ${id}`)
// Fetch the template by ID with creator info
const result = await db
.select({
template: templates,
@@ -47,12 +45,10 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
creator: creator || undefined,
}
// Only show approved templates to non-authenticated users
if (!session?.user?.id && template.status !== 'approved') {
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
}
// Check if user has starred (only if authenticated)
let isStarred = false
if (session?.user?.id) {
const { templateStars } = await import('@sim/db/schema')
@@ -80,7 +76,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
logger.debug(`[${requestId}] Incremented view count for template: ${id}`)
} catch (viewError) {
// Log the error but don't fail the request
logger.warn(`[${requestId}] Failed to increment view count for template: ${id}`, viewError)
}
}
@@ -138,7 +133,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
const { name, details, creatorId, tags, updateState } = validationResult.data
// Check if template exists
const existingTemplate = await db.select().from(templates).where(eq(templates.id, id)).limit(1)
if (existingTemplate.length === 0) {
@@ -146,32 +140,54 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
}
// No permission check needed - template updates only happen from within the workspace
// where the user is already editing the connected workflow
const template = existingTemplate[0]
if (!template.creatorId) {
logger.warn(`[${requestId}] Template ${id} has no creator, denying update`)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
const { verifyCreatorPermission } = await import('@/lib/templates/permissions')
const { hasPermission, error: permissionError } = await verifyCreatorPermission(
session.user.id,
template.creatorId,
'admin'
)
if (!hasPermission) {
logger.warn(`[${requestId}] User denied permission to update template ${id}`)
return NextResponse.json({ error: permissionError || 'Access denied' }, { status: 403 })
}
// Prepare update data - only include fields that were provided
const updateData: any = {
updatedAt: new Date(),
}
// Only update fields that were provided
if (name !== undefined) updateData.name = name
if (details !== undefined) updateData.details = details
if (tags !== undefined) updateData.tags = tags
if (creatorId !== undefined) updateData.creatorId = creatorId
// Only update the state if explicitly requested and the template has a connected workflow
if (updateState && existingTemplate[0].workflowId) {
// Load the current workflow state from normalized tables
if (updateState && template.workflowId) {
const { verifyWorkflowAccess } = await import('@/socket-server/middleware/permissions')
const { hasAccess: hasWorkflowAccess } = await verifyWorkflowAccess(
session.user.id,
template.workflowId
)
if (!hasWorkflowAccess) {
logger.warn(`[${requestId}] User denied workflow access for state sync on template ${id}`)
return NextResponse.json({ error: 'Access denied to workflow' }, { status: 403 })
}
const { loadWorkflowFromNormalizedTables } = await import('@/lib/workflows/persistence/utils')
const normalizedData = await loadWorkflowFromNormalizedTables(existingTemplate[0].workflowId)
const normalizedData = await loadWorkflowFromNormalizedTables(template.workflowId)
if (normalizedData) {
// Also fetch workflow variables
const [workflowRecord] = await db
.select({ variables: workflow.variables })
.from(workflow)
.where(eq(workflow.id, existingTemplate[0].workflowId))
.where(eq(workflow.id, template.workflowId))
.limit(1)
const currentState = {
@@ -183,17 +199,15 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
lastSaved: Date.now(),
}
// Extract credential requirements from the new state
const requiredCredentials = extractRequiredCredentials(currentState)
// Sanitize the state before storing
const sanitizedState = sanitizeCredentials(currentState)
updateData.state = sanitizedState
updateData.requiredCredentials = requiredCredentials
logger.info(
`[${requestId}] Updating template state and credentials from current workflow: ${existingTemplate[0].workflowId}`
`[${requestId}] Updating template state and credentials from current workflow: ${template.workflowId}`
)
} else {
logger.warn(`[${requestId}] Could not load workflow state for template: ${id}`)
@@ -233,7 +247,6 @@ export async function DELETE(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Fetch template
const existing = await db.select().from(templates).where(eq(templates.id, id)).limit(1)
if (existing.length === 0) {
logger.warn(`[${requestId}] Template not found for delete: ${id}`)
@@ -242,41 +255,21 @@ export async function DELETE(
const template = existing[0]
// Permission: Only admin/owner of creator profile can delete
if (template.creatorId) {
const creatorProfile = await db
.select()
.from(templateCreators)
.where(eq(templateCreators.id, template.creatorId))
.limit(1)
if (!template.creatorId) {
logger.warn(`[${requestId}] Template ${id} has no creator, denying delete`)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
if (creatorProfile.length > 0) {
const creator = creatorProfile[0]
let hasPermission = false
const { verifyCreatorPermission } = await import('@/lib/templates/permissions')
const { hasPermission, error: permissionError } = await verifyCreatorPermission(
session.user.id,
template.creatorId,
'admin'
)
if (creator.referenceType === 'user') {
hasPermission = creator.referenceId === session.user.id
} else if (creator.referenceType === 'organization') {
// For delete, require admin/owner role
const membership = await db
.select()
.from(member)
.where(
and(
eq(member.userId, session.user.id),
eq(member.organizationId, creator.referenceId),
or(eq(member.role, 'admin'), eq(member.role, 'owner'))
)
)
.limit(1)
hasPermission = membership.length > 0
}
if (!hasPermission) {
logger.warn(`[${requestId}] User denied permission to delete template ${id}`)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
}
if (!hasPermission) {
logger.warn(`[${requestId}] User denied permission to delete template ${id}`)
return NextResponse.json({ error: permissionError || 'Access denied' }, { status: 403 })
}
await db.delete(templates).where(eq(templates.id, id))

View File

@@ -1,6 +1,5 @@
import { db } from '@sim/db'
import {
member,
templateCreators,
templateStars,
templates,
@@ -204,51 +203,18 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
// Validate creator profile - required for all templates
const creatorProfile = await db
.select()
.from(templateCreators)
.where(eq(templateCreators.id, data.creatorId))
.limit(1)
const { verifyCreatorPermission } = await import('@/lib/templates/permissions')
const { hasPermission, error: permissionError } = await verifyCreatorPermission(
session.user.id,
data.creatorId,
'member'
)
if (creatorProfile.length === 0) {
logger.warn(`[${requestId}] Creator profile not found: ${data.creatorId}`)
return NextResponse.json({ error: 'Creator profile not found' }, { status: 404 })
if (!hasPermission) {
logger.warn(`[${requestId}] User cannot use creator profile: ${data.creatorId}`)
return NextResponse.json({ error: permissionError || 'Access denied' }, { status: 403 })
}
const creator = creatorProfile[0]
// Verify user has permission to use this creator profile
if (creator.referenceType === 'user') {
if (creator.referenceId !== session.user.id) {
logger.warn(`[${requestId}] User cannot use creator profile: ${data.creatorId}`)
return NextResponse.json(
{ error: 'You do not have permission to use this creator profile' },
{ status: 403 }
)
}
} else if (creator.referenceType === 'organization') {
// Verify user is a member of the organization
const membership = await db
.select()
.from(member)
.where(
and(eq(member.userId, session.user.id), eq(member.organizationId, creator.referenceId))
)
.limit(1)
if (membership.length === 0) {
logger.warn(
`[${requestId}] User not a member of organization for creator: ${data.creatorId}`
)
return NextResponse.json(
{ error: 'You must be a member of the organization to use its creator profile' },
{ status: 403 }
)
}
}
// Create the template
const templateId = uuidv4()
const now = new Date()

View File

@@ -1,6 +1,7 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -108,6 +109,14 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
}
if (folderId) {
const folderIdValidation = validateAlphanumericId(folderId, 'folderId', 50)
if (!folderIdValidation.isValid) {
logger.warn(`[${requestId}] Invalid folderId`, { error: folderIdValidation.error })
return NextResponse.json({ error: folderIdValidation.error }, { status: 400 })
}
}
const qParts: string[] = ['trashed = false']
if (folderId) {
qParts.push(`'${escapeForDriveQuery(folderId)}' in parents`)

View File

@@ -1,6 +1,7 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
@@ -50,6 +51,29 @@ export async function POST(request: NextRequest) {
.map((id) => id.trim())
.filter((id) => id.length > 0)
for (const labelId of labelIds) {
const labelIdValidation = validateAlphanumericId(labelId, 'labelId', 255)
if (!labelIdValidation.isValid) {
logger.warn(`[${requestId}] Invalid label ID: ${labelIdValidation.error}`)
return NextResponse.json(
{
success: false,
error: labelIdValidation.error,
},
{ status: 400 }
)
}
}
const messageIdValidation = validateAlphanumericId(validatedData.messageId, 'messageId', 255)
if (!messageIdValidation.isValid) {
logger.warn(`[${requestId}] Invalid message ID: ${messageIdValidation.error}`)
return NextResponse.json(
{ success: false, error: messageIdValidation.error },
{ status: 400 }
)
}
const gmailResponse = await fetch(
`${GMAIL_API_BASE}/messages/${validatedData.messageId}/modify`,
{

View File

@@ -3,6 +3,7 @@ import { account } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -38,6 +39,12 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
}
const credentialIdValidation = validateAlphanumericId(credentialId, 'credentialId', 255)
if (!credentialIdValidation.isValid) {
logger.warn(`[${requestId}] Invalid credential ID: ${credentialIdValidation.error}`)
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
}
let credentials = await db
.select()
.from(account)

View File

@@ -1,6 +1,7 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
@@ -53,6 +54,29 @@ export async function POST(request: NextRequest) {
.map((id) => id.trim())
.filter((id) => id.length > 0)
for (const labelId of labelIds) {
const labelIdValidation = validateAlphanumericId(labelId, 'labelId', 255)
if (!labelIdValidation.isValid) {
logger.warn(`[${requestId}] Invalid label ID: ${labelIdValidation.error}`)
return NextResponse.json(
{
success: false,
error: labelIdValidation.error,
},
{ status: 400 }
)
}
}
const messageIdValidation = validateAlphanumericId(validatedData.messageId, 'messageId', 255)
if (!messageIdValidation.isValid) {
logger.warn(`[${requestId}] Invalid message ID: ${messageIdValidation.error}`)
return NextResponse.json(
{ success: false, error: messageIdValidation.error },
{ status: 400 }
)
}
const gmailResponse = await fetch(
`${GMAIL_API_BASE}/messages/${validatedData.messageId}/modify`,
{

View File

@@ -1,5 +1,6 @@
import { type NextRequest, NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { validateUUID } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -25,7 +26,6 @@ export async function GET(request: NextRequest) {
logger.info(`[${requestId}] Google Calendar calendars request received`)
try {
// Get the credential ID from the query params
const { searchParams } = new URL(request.url)
const credentialId = searchParams.get('credentialId')
const workflowId = searchParams.get('workflowId') || undefined
@@ -34,12 +34,25 @@ export async function GET(request: NextRequest) {
logger.warn(`[${requestId}] Missing credentialId parameter`)
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
}
const credentialValidation = validateUUID(credentialId, 'credentialId')
if (!credentialValidation.isValid) {
logger.warn(`[${requestId}] Invalid credentialId format`, { credentialId })
return NextResponse.json({ error: credentialValidation.error }, { status: 400 })
}
if (workflowId) {
const workflowValidation = validateUUID(workflowId, 'workflowId')
if (!workflowValidation.isValid) {
logger.warn(`[${requestId}] Invalid workflowId format`, { workflowId })
return NextResponse.json({ error: workflowValidation.error }, { status: 400 })
}
}
const authz = await authorizeCredentialUse(request, { credentialId, workflowId })
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
// Refresh access token if needed using the utility function
const accessToken = await refreshAccessTokenIfNeeded(
credentialId,
authz.credentialOwnerUserId,
@@ -50,7 +63,6 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
}
// Fetch calendars from Google Calendar API
logger.info(`[${requestId}] Fetching calendars from Google Calendar API`)
const calendarResponse = await fetch(
'https://www.googleapis.com/calendar/v3/users/me/calendarList',
@@ -81,7 +93,6 @@ export async function GET(request: NextRequest) {
const data = await calendarResponse.json()
const calendars: CalendarListItem[] = data.items || []
// Sort calendars with primary first, then alphabetically
calendars.sort((a, b) => {
if (a.primary && !b.primary) return -1
if (!a.primary && b.primary) return 1

View File

@@ -20,6 +20,12 @@ export async function POST(request: Request) {
cloudId: providedCloudId,
issueType,
parent,
labels,
duedate,
reporter,
environment,
customFieldId,
customFieldValue,
} = await request.json()
if (!domain) {
@@ -94,17 +100,57 @@ export async function POST(request: Request) {
}
if (priority !== undefined && priority !== null && priority !== '') {
fields.priority = {
name: priority,
const isNumericId = /^\d+$/.test(priority)
fields.priority = isNumericId ? { id: priority } : { name: priority }
}
if (labels !== undefined && labels !== null && Array.isArray(labels) && labels.length > 0) {
fields.labels = labels
}
if (duedate !== undefined && duedate !== null && duedate !== '') {
fields.duedate = duedate
}
if (reporter !== undefined && reporter !== null && reporter !== '') {
fields.reporter = {
id: reporter,
}
}
if (assignee !== undefined && assignee !== null && assignee !== '') {
fields.assignee = {
id: assignee,
if (environment !== undefined && environment !== null && environment !== '') {
fields.environment = {
type: 'doc',
version: 1,
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: environment,
},
],
},
],
}
}
if (
customFieldId !== undefined &&
customFieldId !== null &&
customFieldId !== '' &&
customFieldValue !== undefined &&
customFieldValue !== null &&
customFieldValue !== ''
) {
const fieldId = customFieldId.startsWith('customfield_')
? customFieldId
: `customfield_${customFieldId}`
fields[fieldId] = customFieldValue
}
const body = { fields }
const response = await fetch(url, {
@@ -132,16 +178,47 @@ export async function POST(request: Request) {
}
const responseData = await response.json()
logger.info('Successfully created Jira issue:', responseData.key)
const issueKey = responseData.key || 'unknown'
logger.info('Successfully created Jira issue:', issueKey)
let assigneeId: string | undefined
if (assignee !== undefined && assignee !== null && assignee !== '') {
const assignUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${issueKey}/assignee`
logger.info('Assigning issue to:', assignee)
const assignResponse = await fetch(assignUrl, {
method: 'PUT',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
accountId: assignee,
}),
})
if (!assignResponse.ok) {
const assignErrorText = await assignResponse.text()
logger.warn('Failed to assign issue (issue was created successfully):', {
status: assignResponse.status,
error: assignErrorText,
})
} else {
assigneeId = assignee
logger.info('Successfully assigned issue to:', assignee)
}
}
return NextResponse.json({
success: true,
output: {
ts: new Date().toISOString(),
issueKey: responseData.key || 'unknown',
issueKey: issueKey,
summary: responseData.fields?.summary || 'Issue created',
success: true,
url: `https://${domain}/browse/${responseData.key}`,
url: `https://${domain}/browse/${issueKey}`,
...(assigneeId && { assigneeId }),
},
})
} catch (error: any) {

View File

@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -23,6 +24,12 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Team ID is required' }, { status: 400 })
}
const teamIdValidation = validateMicrosoftGraphId(teamId, 'Team ID')
if (!teamIdValidation.isValid) {
logger.warn('Invalid team ID provided', { teamId, error: teamIdValidation.error })
return NextResponse.json({ error: teamIdValidation.error }, { status: 400 })
}
try {
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
@@ -70,7 +77,6 @@ export async function POST(request: Request) {
endpoint: `https://graph.microsoft.com/v1.0/teams/${teamId}/channels`,
})
// Check for auth errors specifically
if (response.status === 401) {
return NextResponse.json(
{
@@ -93,7 +99,6 @@ export async function POST(request: Request) {
} catch (innerError) {
logger.error('Error during API requests:', innerError)
// Check if it's an authentication error
const errorMessage = innerError instanceof Error ? innerError.message : String(innerError)
if (
errorMessage.includes('auth') ||

View File

@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -7,21 +8,35 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('TeamsChatsAPI')
// Helper function to get chat members and create a meaningful name
/**
* Helper function to get chat members and create a meaningful name
*
* @param chatId - Microsoft Teams chat ID to get display name for
* @param accessToken - Access token for Microsoft Graph API
* @param chatTopic - Optional existing chat topic
* @returns A meaningful display name for the chat
*/
const getChatDisplayName = async (
chatId: string,
accessToken: string,
chatTopic?: string
): Promise<string> => {
try {
// If the chat already has a topic, use it
const chatIdValidation = validateMicrosoftGraphId(chatId, 'chatId')
if (!chatIdValidation.isValid) {
logger.warn('Invalid chat ID in getChatDisplayName', {
error: chatIdValidation.error,
chatId: chatId.substring(0, 50),
})
return `Chat ${chatId.substring(0, 8)}...`
}
if (chatTopic?.trim() && chatTopic !== 'null') {
return chatTopic
}
// Fetch chat members to create a meaningful name
const membersResponse = await fetch(
`https://graph.microsoft.com/v1.0/chats/${chatId}/members`,
`https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(chatId)}/members`,
{
method: 'GET',
headers: {
@@ -35,27 +50,25 @@ const getChatDisplayName = async (
const membersData = await membersResponse.json()
const members = membersData.value || []
// Filter out the current user and get display names
const memberNames = members
.filter((member: any) => member.displayName && member.displayName !== 'Unknown')
.map((member: any) => member.displayName)
.slice(0, 3) // Limit to first 3 names to avoid very long names
.slice(0, 3)
if (memberNames.length > 0) {
if (memberNames.length === 1) {
return memberNames[0] // 1:1 chat
return memberNames[0]
}
if (memberNames.length === 2) {
return memberNames.join(' & ') // 2-person group
return memberNames.join(' & ')
}
return `${memberNames.slice(0, 2).join(', ')} & ${memberNames.length - 2} more` // Larger group
return `${memberNames.slice(0, 2).join(', ')} & ${memberNames.length - 2} more`
}
}
// Fallback: try to get a better name from recent messages
try {
const messagesResponse = await fetch(
`https://graph.microsoft.com/v1.0/chats/${chatId}/messages?$top=10&$orderby=createdDateTime desc`,
`https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(chatId)}/messages?$top=10&$orderby=createdDateTime desc`,
{
method: 'GET',
headers: {
@@ -69,14 +82,12 @@ const getChatDisplayName = async (
const messagesData = await messagesResponse.json()
const messages = messagesData.value || []
// Look for chat rename events
for (const message of messages) {
if (message.eventDetail?.chatDisplayName) {
return message.eventDetail.chatDisplayName
}
}
// Get unique sender names from recent messages as last resort
const senderNames = [
...new Set(
messages
@@ -103,7 +114,6 @@ const getChatDisplayName = async (
)
}
// Final fallback
return `Chat ${chatId.split(':')[0] || chatId.substring(0, 8)}...`
} catch (error) {
logger.warn(
@@ -146,7 +156,6 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Could not retrieve access token' }, { status: 401 })
}
// Now try to fetch the chats
const response = await fetch('https://graph.microsoft.com/v1.0/me/chats', {
method: 'GET',
headers: {
@@ -163,7 +172,6 @@ export async function POST(request: Request) {
endpoint: 'https://graph.microsoft.com/v1.0/me/chats',
})
// Check for auth errors specifically
if (response.status === 401) {
return NextResponse.json(
{
@@ -179,7 +187,6 @@ export async function POST(request: Request) {
const data = await response.json()
// Process chats with enhanced display names
const chats = await Promise.all(
data.value.map(async (chat: any) => ({
id: chat.id,
@@ -193,7 +200,6 @@ export async function POST(request: Request) {
} catch (innerError) {
logger.error('Error during API requests:', innerError)
// Check if it's an authentication error
const errorMessage = innerError instanceof Error ? innerError.message : String(innerError)
if (
errorMessage.includes('auth') ||

View File

@@ -30,23 +30,41 @@ export async function createMongoDBConnection(config: MongoDBConnectionConfig) {
return client
}
/**
* Recursively checks an object for dangerous MongoDB operators
* @param obj - The object to check
* @param dangerousOperators - Array of operator names to block
* @returns true if a dangerous operator is found
*/
function containsDangerousOperator(obj: unknown, dangerousOperators: string[]): boolean {
if (typeof obj !== 'object' || obj === null) return false
for (const key of Object.keys(obj as Record<string, unknown>)) {
if (dangerousOperators.includes(key)) return true
if (
typeof (obj as Record<string, unknown>)[key] === 'object' &&
containsDangerousOperator((obj as Record<string, unknown>)[key], dangerousOperators)
) {
return true
}
}
return false
}
export function validateFilter(filter: string): { isValid: boolean; error?: string } {
try {
const parsed = JSON.parse(filter)
const dangerousOperators = ['$where', '$regex', '$expr', '$function', '$accumulator', '$let']
const dangerousOperators = [
'$where', // Executes arbitrary JavaScript
'$regex', // Can cause ReDoS attacks
'$expr', // Expression evaluation
'$function', // Custom JavaScript functions
'$accumulator', // Custom JavaScript accumulators
'$let', // Variable definitions that could be exploited
]
const checkForDangerousOps = (obj: any): boolean => {
if (typeof obj !== 'object' || obj === null) return false
for (const key of Object.keys(obj)) {
if (dangerousOperators.includes(key)) return true
if (typeof obj[key] === 'object' && checkForDangerousOps(obj[key])) return true
}
return false
}
if (checkForDangerousOps(parsed)) {
if (containsDangerousOperator(parsed, dangerousOperators)) {
return {
isValid: false,
error: 'Filter contains potentially dangerous operators',
@@ -74,29 +92,19 @@ export function validatePipeline(pipeline: string): { isValid: boolean; error?:
}
const dangerousOperators = [
'$where',
'$function',
'$accumulator',
'$let',
'$merge',
'$out',
'$currentOp',
'$listSessions',
'$listLocalSessions',
'$where', // Executes arbitrary JavaScript
'$function', // Custom JavaScript functions
'$accumulator', // Custom JavaScript accumulators
'$let', // Variable definitions that could be exploited
'$merge', // Writes to external collections
'$out', // Writes to external collections
'$currentOp', // Exposes system operation info
'$listSessions', // Exposes session info
'$listLocalSessions', // Exposes local session info
]
const checkPipelineStage = (stage: any): boolean => {
if (typeof stage !== 'object' || stage === null) return false
for (const key of Object.keys(stage)) {
if (dangerousOperators.includes(key)) return true
if (typeof stage[key] === 'object' && checkPipelineStage(stage[key])) return true
}
return false
}
for (const stage of parsed) {
if (checkPipelineStage(stage)) {
if (containsDangerousOperator(stage, dangerousOperators)) {
return {
isValid: false,
error: 'Pipeline contains potentially dangerous operators',

View File

@@ -98,15 +98,45 @@ export function buildDeleteQuery(table: string, where: string) {
return { query, values: [] }
}
/**
* Validates a WHERE clause to prevent SQL injection attacks
* @param where - The WHERE clause string to validate
* @throws {Error} If the WHERE clause contains potentially dangerous patterns
*/
function validateWhereClause(where: string): void {
const dangerousPatterns = [
// DDL and DML injection via stacked queries
/;\s*(drop|delete|insert|update|create|alter|grant|revoke)/i,
/union\s+select/i,
// Union-based injection
/union\s+(all\s+)?select/i,
// File operations
/into\s+outfile/i,
/load_file/i,
/into\s+dumpfile/i,
/load_file\s*\(/i,
// Comment-based injection (can truncate query)
/--/,
/\/\*/,
/\*\//,
// Tautologies - always true/false conditions using backreferences
// Matches OR 'x'='x' or OR x=x (same value both sides) but NOT OR col='value'
/\bor\s+(['"]?)(\w+)\1\s*=\s*\1\2\1/i,
/\bor\s+true\b/i,
/\bor\s+false\b/i,
// AND tautologies (less common but still used in attacks)
/\band\s+(['"]?)(\w+)\1\s*=\s*\1\2\1/i,
/\band\s+true\b/i,
/\band\s+false\b/i,
// Time-based blind injection
/\bsleep\s*\(/i,
/\bbenchmark\s*\(/i,
/\bwaitfor\s+delay/i,
// Stacked queries (any statement after semicolon)
/;\s*\w+/,
// Information schema queries
/information_schema/i,
/mysql\./i,
// System functions and procedures
/\bxp_cmdshell/i,
]
for (const pattern of dangerousPatterns) {

View File

@@ -4,6 +4,7 @@ import { account } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -36,6 +37,12 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
}
const credentialIdValidation = validateMicrosoftGraphId(credentialId, 'credentialId')
if (!credentialIdValidation.isValid) {
logger.warn(`[${requestId}] Invalid credential ID`, { error: credentialIdValidation.error })
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
}
logger.info(`[${requestId}] Fetching credential`, { credentialId })
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)

View File

@@ -4,6 +4,7 @@ import { account } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -33,6 +34,12 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
}
const credentialIdValidation = validateMicrosoftGraphId(credentialId, 'credentialId')
if (!credentialIdValidation.isValid) {
logger.warn(`[${requestId}] Invalid credential ID`, { error: credentialIdValidation.error })
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
}
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (!credentials.length) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
@@ -48,7 +55,6 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
}
// Build URL for OneDrive folders
let url = `https://graph.microsoft.com/v1.0/me/drive/root/children?$filter=folder ne null&$select=id,name,folder,webUrl,createdDateTime,lastModifiedDateTime&$top=50`
if (query) {
@@ -71,7 +77,7 @@ export async function GET(request: NextRequest) {
const data = await response.json()
const folders = (data.value || [])
.filter((item: MicrosoftGraphDriveItem) => item.folder) // Only folders
.filter((item: MicrosoftGraphDriveItem) => item.folder)
.map((folder: MicrosoftGraphDriveItem) => ({
id: folder.id,
name: folder.name,

View File

@@ -2,6 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import * as XLSX from 'xlsx'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import {
@@ -28,9 +29,9 @@ const ExcelValuesSchema = z.union([
const OneDriveUploadSchema = z.object({
accessToken: z.string().min(1, 'Access token is required'),
fileName: z.string().min(1, 'File name is required'),
file: z.any().optional(), // UserFile object (optional for blank Excel creation)
file: z.any().optional(),
folderId: z.string().optional().nullable(),
mimeType: z.string().nullish(), // Accept string, null, or undefined
mimeType: z.string().nullish(),
values: ExcelValuesSchema.optional().nullable(),
})
@@ -62,24 +63,19 @@ export async function POST(request: NextRequest) {
let fileBuffer: Buffer
let mimeType: string
// Check if we're creating a blank Excel file
const isExcelCreation =
validatedData.mimeType ===
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' && !validatedData.file
if (isExcelCreation) {
// Create a blank Excel workbook
const workbook = XLSX.utils.book_new()
const worksheet = XLSX.utils.aoa_to_sheet([[]])
XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1')
// Generate XLSX file as buffer
const xlsxBuffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' })
fileBuffer = Buffer.from(xlsxBuffer)
mimeType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
} else {
// Handle regular file upload
const rawFile = validatedData.file
if (!rawFile) {
@@ -108,7 +104,6 @@ export async function POST(request: NextRequest) {
fileToProcess = rawFile
}
// Convert to UserFile format
let userFile
try {
userFile = processSingleFileToUserFile(fileToProcess, requestId, logger)
@@ -138,7 +133,7 @@ export async function POST(request: NextRequest) {
mimeType = userFile.type || 'application/octet-stream'
}
const maxSize = 250 * 1024 * 1024 // 250MB
const maxSize = 250 * 1024 * 1024
if (fileBuffer.length > maxSize) {
const sizeMB = (fileBuffer.length / (1024 * 1024)).toFixed(2)
logger.warn(`[${requestId}] File too large: ${sizeMB}MB`)
@@ -151,7 +146,6 @@ export async function POST(request: NextRequest) {
)
}
// Ensure file name has an appropriate extension
let fileName = validatedData.fileName
const hasExtension = fileName.includes('.') && fileName.lastIndexOf('.') > 0
@@ -169,6 +163,17 @@ export async function POST(request: NextRequest) {
const folderId = validatedData.folderId?.trim()
if (folderId && folderId !== '') {
const folderIdValidation = validateMicrosoftGraphId(folderId, 'folderId')
if (!folderIdValidation.isValid) {
logger.warn(`[${requestId}] Invalid folder ID`, { error: folderIdValidation.error })
return NextResponse.json(
{
success: false,
error: folderIdValidation.error,
},
{ status: 400 }
)
}
uploadUrl = `${MICROSOFT_GRAPH_BASE}/me/drive/items/${encodeURIComponent(folderId)}:/${encodeURIComponent(fileName)}:/content`
} else {
uploadUrl = `${MICROSOFT_GRAPH_BASE}/me/drive/root:/${encodeURIComponent(fileName)}:/content`
@@ -197,14 +202,12 @@ export async function POST(request: NextRequest) {
const fileData = await uploadResponse.json()
// If this is an Excel creation and values were provided, write them using the Excel API
let excelWriteResult: any | undefined
const shouldWriteExcelContent =
isExcelCreation && Array.isArray(excelValues) && excelValues.length > 0
if (shouldWriteExcelContent) {
try {
// Create a workbook session to ensure reliability and persistence of changes
let workbookSessionId: string | undefined
const sessionResp = await fetch(
`${MICROSOFT_GRAPH_BASE}/me/drive/items/${encodeURIComponent(fileData.id)}/workbook/createSession`,
@@ -223,7 +226,6 @@ export async function POST(request: NextRequest) {
workbookSessionId = sessionData?.id
}
// Determine the first worksheet name
let sheetName = 'Sheet1'
try {
const listUrl = `${MICROSOFT_GRAPH_BASE}/me/drive/items/${encodeURIComponent(
@@ -272,7 +274,6 @@ export async function POST(request: NextRequest) {
return paddedRow
})
// Compute concise end range from A1 and matrix size (no network round-trip)
const indexToColLetters = (index: number): string => {
let n = index
let s = ''
@@ -313,7 +314,6 @@ export async function POST(request: NextRequest) {
statusText: excelWriteResponse?.statusText,
error: errorText,
})
// Do not fail the entire request; return upload success with write error details
excelWriteResult = {
success: false,
error: `Excel write failed: ${excelWriteResponse?.statusText || 'unknown'}`,
@@ -321,7 +321,6 @@ export async function POST(request: NextRequest) {
}
} else {
const writeData = await excelWriteResponse.json()
// The Range PATCH returns a Range object; log address and values length
const addr = writeData.address || writeData.addressLocal
const v = writeData.values || []
excelWriteResult = {
@@ -333,7 +332,6 @@ export async function POST(request: NextRequest) {
}
}
// Attempt to close the workbook session if one was created
if (workbookSessionId) {
try {
const closeResp = await fetch(

View File

@@ -3,6 +3,7 @@ import { account } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -29,8 +30,13 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
}
const credentialIdValidation = validateAlphanumericId(credentialId, 'credentialId')
if (!credentialIdValidation.isValid) {
logger.warn('Invalid credentialId format', { error: credentialIdValidation.error })
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
}
try {
// Ensure we have a session for permission checks
const sessionUserId = session?.user?.id || ''
if (!sessionUserId) {
@@ -38,7 +44,6 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
// Resolve the credential owner to support collaborator-owned credentials
const creds = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (!creds.length) {
logger.warn('Credential not found', { credentialId })
@@ -79,7 +84,6 @@ export async function GET(request: Request) {
endpoint: 'https://graph.microsoft.com/v1.0/me/mailFolders',
})
// Check for auth errors specifically
if (response.status === 401) {
return NextResponse.json(
{
@@ -96,7 +100,6 @@ export async function GET(request: Request) {
const data = await response.json()
const folders = data.value || []
// Transform folders to match the expected format
const transformedFolders = folders.map((folder: OutlookFolder) => ({
id: folder.id,
name: folder.displayName,
@@ -111,7 +114,6 @@ export async function GET(request: Request) {
} catch (innerError) {
logger.error('Error during API requests:', innerError)
// Check if it's an authentication error
const errorMessage = innerError instanceof Error ? innerError.message : String(innerError)
if (
errorMessage.includes('auth') ||

View File

@@ -64,15 +64,46 @@ export function sanitizeIdentifier(identifier: string): string {
return sanitizeSingleIdentifier(identifier)
}
/**
* Validates a WHERE clause to prevent SQL injection attacks
* @param where - The WHERE clause string to validate
* @throws {Error} If the WHERE clause contains potentially dangerous patterns
*/
function validateWhereClause(where: string): void {
const dangerousPatterns = [
// DDL and DML injection via stacked queries
/;\s*(drop|delete|insert|update|create|alter|grant|revoke)/i,
/union\s+select/i,
// Union-based injection
/union\s+(all\s+)?select/i,
// File operations
/into\s+outfile/i,
/load_file/i,
/load_file\s*\(/i,
/pg_read_file/i,
// Comment-based injection (can truncate query)
/--/,
/\/\*/,
/\*\//,
// Tautologies - always true/false conditions using backreferences
// Matches OR 'x'='x' or OR x=x (same value both sides) but NOT OR col='value'
/\bor\s+(['"]?)(\w+)\1\s*=\s*\1\2\1/i,
/\bor\s+true\b/i,
/\bor\s+false\b/i,
// AND tautologies (less common but still used in attacks)
/\band\s+(['"]?)(\w+)\1\s*=\s*\1\2\1/i,
/\band\s+true\b/i,
/\band\s+false\b/i,
// Time-based blind injection
/\bsleep\s*\(/i,
/\bwaitfor\s+delay/i,
/\bpg_sleep\s*\(/i,
/\bbenchmark\s*\(/i,
// Stacked queries (any statement after semicolon)
/;\s*\w+/,
// Information schema / system catalog queries
/information_schema/i,
/pg_catalog/i,
// System functions and procedures
/\bxp_cmdshell/i,
]
for (const pattern of dangerousPatterns) {

View File

@@ -4,6 +4,7 @@ import { account } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import type { SharepointSite } from '@/tools/sharepoint/types'
@@ -32,6 +33,12 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
}
const credentialIdValidation = validateAlphanumericId(credentialId, 'credentialId', 255)
if (!credentialIdValidation.isValid) {
logger.warn(`[${requestId}] Invalid credential ID`, { error: credentialIdValidation.error })
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
}
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (!credentials.length) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
@@ -47,8 +54,6 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
}
// Build URL for SharePoint sites
// Use search=* to get all sites the user has access to, or search for specific query
const searchQuery = query || '*'
const url = `https://graph.microsoft.com/v1.0/sites?search=${encodeURIComponent(searchQuery)}&$select=id,name,displayName,webUrl,createdDateTime,lastModifiedDateTime&$top=50`

View File

@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -93,7 +94,6 @@ export async function POST(request: Request) {
}
}
// Filter to channels the bot can access and format the response
const channels = (data.channels || [])
.filter((channel: SlackChannel) => {
const canAccess = !channel.is_archived && (channel.is_member || !channel.is_private)
@@ -106,6 +106,28 @@ export async function POST(request: Request) {
return canAccess
})
.filter((channel: SlackChannel) => {
const validation = validateAlphanumericId(channel.id, 'channelId', 50)
if (!validation.isValid) {
logger.warn('Invalid channel ID received from Slack API', {
channelId: channel.id,
channelName: channel.name,
error: validation.error,
})
return false
}
if (!/^[CDG][A-Z0-9]+$/i.test(channel.id)) {
logger.warn('Channel ID does not match Slack format', {
channelId: channel.id,
channelName: channel.name,
})
return false
}
return true
})
.map((channel: SlackChannel) => ({
id: channel.id,
name: channel.name,

View File

@@ -14,7 +14,12 @@ const SlackReadMessagesSchema = z
accessToken: z.string().min(1, 'Access token is required'),
channel: z.string().optional().nullable(),
userId: z.string().optional().nullable(),
limit: z.number().optional().nullable(),
limit: z.coerce
.number()
.min(1, 'Limit must be at least 1')
.max(15, 'Limit cannot exceed 15')
.optional()
.nullable(),
oldest: z.string().optional().nullable(),
latest: z.string().optional().nullable(),
})
@@ -62,8 +67,8 @@ export async function POST(request: NextRequest) {
const url = new URL('https://slack.com/api/conversations.history')
url.searchParams.append('channel', channel!)
const limit = validatedData.limit ? Number(validatedData.limit) : 10
url.searchParams.append('limit', String(Math.min(limit, 15)))
const limit = validatedData.limit ?? 10
url.searchParams.append('limit', String(limit))
if (validatedData.oldest) {
url.searchParams.append('oldest', validatedData.oldest)

View File

@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -20,13 +21,21 @@ export async function POST(request: Request) {
try {
const requestId = generateRequestId()
const body = await request.json()
const { credential, workflowId } = body
const { credential, workflowId, userId } = body
if (!credential) {
logger.error('Missing credential in request')
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
}
if (userId !== undefined && userId !== null) {
const validation = validateAlphanumericId(userId, 'userId', 100)
if (!validation.isValid) {
logger.warn('Invalid Slack user ID', { userId, error: validation.error })
return NextResponse.json({ error: validation.error }, { status: 400 })
}
}
let accessToken: string
const isBotToken = credential.startsWith('xoxb-')
@@ -63,6 +72,17 @@ export async function POST(request: Request) {
logger.info('Using OAuth token for Slack API')
}
if (userId) {
const userData = await fetchSlackUser(accessToken, userId)
const user = {
id: userData.user.id,
name: userData.user.name,
real_name: userData.user.real_name || userData.user.name,
}
logger.info(`Successfully fetched Slack user: ${userId}`)
return NextResponse.json({ user })
}
const data = await fetchSlackUsers(accessToken)
const users = (data.members || [])
@@ -87,6 +107,31 @@ export async function POST(request: Request) {
}
}
async function fetchSlackUser(accessToken: string, userId: string) {
const url = new URL('https://slack.com/api/users.info')
url.searchParams.append('user', userId)
const response = await fetch(url.toString(), {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`Slack API error: ${response.status} ${response.statusText}`)
}
const data = await response.json()
if (!data.ok) {
throw new Error(data.error || 'Failed to fetch user')
}
return data
}
async function fetchSlackUsers(accessToken: string) {
const url = new URL('https://slack.com/api/users.list')
url.searchParams.append('limit', '200')

View File

@@ -1,4 +1,7 @@
import { type Attributes, Client, type ConnectConfig } from 'ssh2'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('SSHUtils')
// File type constants from POSIX
const S_IFMT = 0o170000 // bit mask for the file type bit field
@@ -32,7 +35,6 @@ function formatSSHError(err: Error, config: { host: string; port: number }): Err
const host = config.host
const port = config.port
// Connection refused - server not running or wrong port
if (errorMessage.includes('econnrefused') || errorMessage.includes('connection refused')) {
return new Error(
`Connection refused to ${host}:${port}. ` +
@@ -42,7 +44,6 @@ function formatSSHError(err: Error, config: { host: string; port: number }): Err
)
}
// Connection reset - server closed connection unexpectedly
if (errorMessage.includes('econnreset') || errorMessage.includes('connection reset')) {
return new Error(
`Connection reset by ${host}:${port}. ` +
@@ -53,7 +54,6 @@ function formatSSHError(err: Error, config: { host: string; port: number }): Err
)
}
// Timeout - server unreachable or slow
if (errorMessage.includes('etimedout') || errorMessage.includes('timeout')) {
return new Error(
`Connection timed out to ${host}:${port}. ` +
@@ -63,7 +63,6 @@ function formatSSHError(err: Error, config: { host: string; port: number }): Err
)
}
// DNS/hostname resolution
if (errorMessage.includes('enotfound') || errorMessage.includes('getaddrinfo')) {
return new Error(
`Could not resolve hostname "${host}". ` +
@@ -71,7 +70,6 @@ function formatSSHError(err: Error, config: { host: string; port: number }): Err
)
}
// Authentication failure
if (errorMessage.includes('authentication') || errorMessage.includes('auth')) {
return new Error(
`Authentication failed for user on ${host}:${port}. ` +
@@ -81,7 +79,6 @@ function formatSSHError(err: Error, config: { host: string; port: number }): Err
)
}
// Private key format issues
if (
errorMessage.includes('key') &&
(errorMessage.includes('parse') || errorMessage.includes('invalid'))
@@ -93,7 +90,6 @@ function formatSSHError(err: Error, config: { host: string; port: number }): Err
)
}
// Host key verification (first connection)
if (errorMessage.includes('host key') || errorMessage.includes('hostkey')) {
return new Error(
`Host key verification issue for ${host}. ` +
@@ -101,7 +97,6 @@ function formatSSHError(err: Error, config: { host: string; port: number }): Err
)
}
// Return original error with context if no specific match
return new Error(`SSH connection to ${host}:${port} failed: ${err.message}`)
}
@@ -205,19 +200,119 @@ export function executeSSHCommand(client: Client, command: string): Promise<SSHC
/**
* Sanitize command input to prevent command injection
*
* Removes null bytes and other dangerous control characters while preserving
* legitimate shell syntax. Logs warnings for potentially dangerous patterns.
*
* Note: This function does not block complex shell commands (pipes, redirects, etc.)
* as users legitimately need these features for remote command execution.
*
* @param command - The command to sanitize
* @returns The sanitized command string
*
* @example
* ```typescript
* const safeCommand = sanitizeCommand(userInput)
* // Use safeCommand for SSH execution
* ```
*/
export function sanitizeCommand(command: string): string {
return command.trim()
let sanitized = command.replace(/\0/g, '')
sanitized = sanitized.replace(/[\x0B\x0C]/g, '')
sanitized = sanitized.trim()
const dangerousPatterns = [
{ pattern: /\$\(.*\)/, name: 'command substitution $()' },
{ pattern: /`.*`/, name: 'backtick command substitution' },
{ pattern: /;\s*rm\s+-rf/i, name: 'destructive rm -rf command' },
{ pattern: /;\s*dd\s+/i, name: 'dd command (disk operations)' },
{ pattern: /mkfs/i, name: 'filesystem formatting command' },
{ pattern: />\s*\/dev\/sd[a-z]/i, name: 'direct disk write' },
]
for (const { pattern, name } of dangerousPatterns) {
if (pattern.test(sanitized)) {
logger.warn(`Command contains ${name}`, {
command: sanitized.substring(0, 100) + (sanitized.length > 100 ? '...' : ''),
})
}
}
return sanitized
}
/**
* Sanitize file path - removes null bytes and trims whitespace
* Sanitize and validate file path to prevent path traversal attacks
*
* This function validates that a file path does not contain:
* - Null bytes
* - Path traversal sequences (.. or ../)
* - URL-encoded path traversal attempts
*
* @param path - The file path to sanitize and validate
* @returns The sanitized path if valid
* @throws Error if path traversal is detected
*
* @example
* ```typescript
* try {
* const safePath = sanitizePath(userInput)
* // Use safePath safely
* } catch (error) {
* // Handle invalid path
* }
* ```
*/
export function sanitizePath(path: string): string {
let sanitized = path.replace(/\0/g, '')
sanitized = sanitized.trim()
if (sanitized.includes('%00')) {
logger.warn('Path contains URL-encoded null bytes', {
path: path.substring(0, 100),
})
throw new Error('Path contains invalid characters')
}
const pathTraversalPatterns = [
'../', // Standard Unix path traversal
'..\\', // Windows path traversal
'/../', // Mid-path traversal
'\\..\\', // Windows mid-path traversal
'%2e%2e%2f', // Fully encoded ../
'%2e%2e/', // Partially encoded ../
'%2e%2e%5c', // Fully encoded ..\
'%2e%2e\\', // Partially encoded ..\
'..%2f', // .. with encoded /
'..%5c', // .. with encoded \
'%252e%252e', // Double URL encoded ..
'..%252f', // .. with double encoded /
'..%255c', // .. with double encoded \
]
const lowerPath = sanitized.toLowerCase()
for (const pattern of pathTraversalPatterns) {
if (lowerPath.includes(pattern.toLowerCase())) {
logger.warn('Path traversal attempt detected', {
pattern,
path: path.substring(0, 100),
})
throw new Error('Path contains invalid path traversal sequences')
}
}
const segments = sanitized.split(/[/\\]/)
for (const segment of segments) {
if (segment === '..') {
logger.warn('Path traversal attempt detected (.. as path segment)', {
path: path.substring(0, 100),
})
throw new Error('Path contains invalid path traversal sequences')
}
}
return sanitized
}

View File

@@ -3,6 +3,7 @@ import { account } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { validateEnum, validatePathSegment } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -11,7 +12,6 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('WealthboxItemsAPI')
// Interface for transformed Wealthbox items
interface WealthboxItem {
id: string
name: string
@@ -45,12 +45,23 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
}
if (type !== 'contact') {
const credentialIdValidation = validatePathSegment(credentialId, {
paramName: 'credentialId',
maxLength: 100,
allowHyphens: true,
allowUnderscores: true,
allowDots: false,
})
if (!credentialIdValidation.isValid) {
logger.warn(`[${requestId}] Invalid credentialId format: ${credentialId}`)
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
}
const ALLOWED_TYPES = ['contact'] as const
const typeValidation = validateEnum(type, ALLOWED_TYPES, 'type')
if (!typeValidation.isValid) {
logger.warn(`[${requestId}] Invalid item type: ${type}`)
return NextResponse.json(
{ error: 'Invalid item type. Only contact is supported.' },
{ status: 400 }
)
return NextResponse.json({ error: typeValidation.error }, { status: 400 })
}
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)

View File

@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -12,13 +13,21 @@ export async function POST(request: Request) {
try {
const requestId = generateRequestId()
const body = await request.json()
const { credential, workflowId } = body
const { credential, workflowId, siteId } = body
if (!credential) {
logger.error('Missing credential in request')
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
}
if (siteId) {
const siteIdValidation = validateAlphanumericId(siteId, 'siteId')
if (!siteIdValidation.isValid) {
logger.error('Invalid siteId', { error: siteIdValidation.error })
return NextResponse.json({ error: siteIdValidation.error }, { status: 400 })
}
}
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
@@ -46,7 +55,11 @@ export async function POST(request: Request) {
)
}
const response = await fetch('https://api.webflow.com/v2/sites', {
const url = siteId
? `https://api.webflow.com/v2/sites/${siteId}`
: 'https://api.webflow.com/v2/sites'
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
accept: 'application/json',
@@ -58,6 +71,7 @@ export async function POST(request: Request) {
logger.error('Failed to fetch Webflow sites', {
status: response.status,
error: errorData,
siteId: siteId || 'all',
})
return NextResponse.json(
{ error: 'Failed to fetch Webflow sites', details: errorData },
@@ -66,7 +80,13 @@ export async function POST(request: Request) {
}
const data = await response.json()
const sites = data.sites || []
let sites: any[]
if (siteId) {
sites = [data]
} else {
sites = data.sites || []
}
const formattedSites = sites.map((site: any) => ({
id: site.id,

View File

@@ -32,7 +32,6 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: 'Missing email or token parameter' }, { status: 400 })
}
// Verify token and get email type
const tokenVerification = verifyUnsubscribeToken(email, token)
if (!tokenVerification.valid) {
logger.warn(`[${requestId}] Invalid unsubscribe token for email: ${email}`)
@@ -42,7 +41,6 @@ export async function GET(req: NextRequest) {
const emailType = tokenVerification.emailType as EmailType
const isTransactional = isTransactionalEmail(emailType)
// Get current preferences
const preferences = await getEmailPreferences(email)
logger.info(
@@ -67,22 +65,42 @@ export async function POST(req: NextRequest) {
const requestId = generateRequestId()
try {
const body = await req.json()
const result = unsubscribeSchema.safeParse(body)
const { searchParams } = new URL(req.url)
const contentType = req.headers.get('content-type') || ''
if (!result.success) {
logger.warn(`[${requestId}] Invalid unsubscribe POST data`, {
errors: result.error.format(),
})
return NextResponse.json(
{ error: 'Invalid request data', details: result.error.format() },
{ status: 400 }
)
let email: string
let token: string
let type: 'all' | 'marketing' | 'updates' | 'notifications' = 'all'
if (contentType.includes('application/x-www-form-urlencoded')) {
email = searchParams.get('email') || ''
token = searchParams.get('token') || ''
if (!email || !token) {
logger.warn(`[${requestId}] One-click unsubscribe missing email or token in URL`)
return NextResponse.json({ error: 'Missing email or token parameter' }, { status: 400 })
}
logger.info(`[${requestId}] Processing one-click unsubscribe for: ${email}`)
} else {
const body = await req.json()
const result = unsubscribeSchema.safeParse(body)
if (!result.success) {
logger.warn(`[${requestId}] Invalid unsubscribe POST data`, {
errors: result.error.format(),
})
return NextResponse.json(
{ error: 'Invalid request data', details: result.error.format() },
{ status: 400 }
)
}
email = result.data.email
token = result.data.token
type = result.data.type
}
const { email, token, type } = result.data
// Verify token and get email type
const tokenVerification = verifyUnsubscribeToken(email, token)
if (!tokenVerification.valid) {
logger.warn(`[${requestId}] Invalid unsubscribe token for email: ${email}`)
@@ -92,7 +110,6 @@ export async function POST(req: NextRequest) {
const emailType = tokenVerification.emailType as EmailType
const isTransactional = isTransactionalEmail(emailType)
// Prevent unsubscribing from transactional emails
if (isTransactional) {
logger.warn(`[${requestId}] Attempted to unsubscribe from transactional email: ${email}`)
return NextResponse.json(
@@ -106,7 +123,6 @@ export async function POST(req: NextRequest) {
)
}
// Process unsubscribe based on type
let success = false
switch (type) {
case 'all':
@@ -130,7 +146,6 @@ export async function POST(req: NextRequest) {
logger.info(`[${requestId}] Successfully unsubscribed ${email} from ${type}`)
// Return 200 for one-click unsubscribe compliance
return NextResponse.json(
{
success: true,

View File

@@ -11,6 +11,7 @@ import { processInputFileFields } from '@/lib/execution/files'
import { preprocessExecution } from '@/lib/execution/preprocessing'
import { createLogger } from '@/lib/logs/console/logger'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { ALL_TRIGGER_TYPES } from '@/lib/logs/types'
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
import { type ExecutionEvent, encodeSSEEvent } from '@/lib/workflows/executor/execution-events'
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
@@ -30,7 +31,7 @@ const logger = createLogger('WorkflowExecuteAPI')
const ExecuteWorkflowSchema = z.object({
selectedOutputs: z.array(z.string()).optional().default([]),
triggerType: z.enum(['api', 'webhook', 'schedule', 'manual', 'chat']).optional(),
triggerType: z.enum(ALL_TRIGGER_TYPES).optional(),
stream: z.boolean().optional(),
useDraftState: z.boolean().optional(),
input: z.any().optional(),

View File

@@ -1,97 +0,0 @@
import { db } from '@sim/db'
import { userStats, workflow } from '@sim/db/schema'
import { eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('WorkflowStatsAPI')
const queryParamsSchema = z.object({
runs: z.coerce.number().int().min(1).max(100).default(1),
})
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const searchParams = request.nextUrl.searchParams
const validation = queryParamsSchema.safeParse({
runs: searchParams.get('runs'),
})
if (!validation.success) {
logger.error(`Invalid query parameters: ${validation.error.message}`)
return NextResponse.json(
{
error:
validation.error.errors[0]?.message ||
'Invalid number of runs. Must be between 1 and 100.',
},
{ status: 400 }
)
}
const { runs } = validation.data
try {
const [workflowRecord] = await db.select().from(workflow).where(eq(workflow.id, id)).limit(1)
if (!workflowRecord) {
return NextResponse.json({ error: `Workflow ${id} not found` }, { status: 404 })
}
try {
await db
.update(workflow)
.set({
runCount: workflowRecord.runCount + runs,
lastRunAt: new Date(),
})
.where(eq(workflow.id, id))
} catch (error) {
logger.error('Error updating workflow runCount:', error)
throw error
}
try {
const userStatsRecords = await db
.select()
.from(userStats)
.where(eq(userStats.userId, workflowRecord.userId))
if (userStatsRecords.length === 0) {
await db.insert(userStats).values({
id: crypto.randomUUID(),
userId: workflowRecord.userId,
totalManualExecutions: 0,
totalApiCalls: 0,
totalWebhookTriggers: 0,
totalScheduledExecutions: 0,
totalChatExecutions: 0,
totalTokensUsed: 0,
totalCost: '0.00',
lastActive: sql`now()`,
})
} else {
await db
.update(userStats)
.set({
lastActive: sql`now()`,
})
.where(eq(userStats.userId, workflowRecord.userId))
}
} catch (error) {
logger.error(`Error ensuring userStats for userId ${workflowRecord.userId}:`, error)
// Don't rethrow - we want to continue even if this fails
}
return NextResponse.json({
success: true,
runsAdded: runs,
newTotal: workflowRecord.runCount + runs,
})
} catch (error) {
logger.error('Error updating workflow stats:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -6,13 +6,14 @@ import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { encryptSecret } from '@/lib/core/security/encryption'
import { createLogger } from '@/lib/logs/console/logger'
import { ALL_TRIGGER_TYPES } from '@/lib/logs/types'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { MAX_EMAIL_RECIPIENTS, MAX_WORKFLOW_IDS } from '../constants'
const logger = createLogger('WorkspaceNotificationAPI')
const levelFilterSchema = z.array(z.enum(['info', 'error']))
const triggerFilterSchema = z.array(z.enum(['api', 'webhook', 'schedule', 'manual', 'chat']))
const triggerFilterSchema = z.array(z.enum(ALL_TRIGGER_TYPES))
const alertRuleSchema = z.enum([
'consecutive_failures',

View File

@@ -7,6 +7,7 @@ import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { encryptSecret } from '@/lib/core/security/encryption'
import { createLogger } from '@/lib/logs/console/logger'
import { ALL_TRIGGER_TYPES } from '@/lib/logs/types'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { MAX_EMAIL_RECIPIENTS, MAX_NOTIFICATIONS_PER_TYPE, MAX_WORKFLOW_IDS } from './constants'
@@ -14,7 +15,7 @@ const logger = createLogger('WorkspaceNotificationsAPI')
const notificationTypeSchema = z.enum(['webhook', 'email', 'slack'])
const levelFilterSchema = z.array(z.enum(['info', 'error']))
const triggerFilterSchema = z.array(z.enum(['api', 'webhook', 'schedule', 'manual', 'chat']))
const triggerFilterSchema = z.array(z.enum(ALL_TRIGGER_TYPES))
const alertRuleSchema = z.enum([
'consecutive_failures',
@@ -80,7 +81,7 @@ const createNotificationSchema = z
workflowIds: z.array(z.string()).max(MAX_WORKFLOW_IDS).default([]),
allWorkflows: z.boolean().default(false),
levelFilter: levelFilterSchema.default(['info', 'error']),
triggerFilter: triggerFilterSchema.default(['api', 'webhook', 'schedule', 'manual', 'chat']),
triggerFilter: triggerFilterSchema.default([...ALL_TRIGGER_TYPES]),
includeFinalOutput: z.boolean().default(false),
includeTraceSpans: z.boolean().default(false),
includeRateLimits: z.boolean().default(false),

View File

@@ -173,7 +173,7 @@ export async function GET(
// DELETE /api/workspaces/invitations/[invitationId] - Delete a workspace invitation
export async function DELETE(
_req: NextRequest,
_request: NextRequest,
{ params }: { params: Promise<{ invitationId: string }> }
) {
const { invitationId } = await params
@@ -221,7 +221,7 @@ export async function DELETE(
// POST /api/workspaces/invitations/[invitationId] - Resend a workspace invitation
export async function POST(
_req: NextRequest,
_request: NextRequest,
{ params }: { params: Promise<{ invitationId: string }> }
) {
const { invitationId } = await params

View File

@@ -29,30 +29,24 @@ export const metadata: Metadata = {
locale: 'en_US',
images: [
{
url: '/social/og-image.png',
width: 1200,
height: 630,
alt: 'Sim - Visual AI Workflow Builder',
url: '/logo/primary/rounded.png',
width: 512,
height: 512,
alt: 'Sim - AI Agent Workflow Builder',
type: 'image/png',
},
{
url: '/social/og-image-square.png',
width: 600,
height: 600,
alt: 'Sim Logo',
},
],
},
twitter: {
card: 'summary_large_image',
card: 'summary',
site: '@simdotai',
creator: '@simdotai',
title: 'Sim - AI Agent Workflow Builder | Open Source',
description:
'Open-source platform for agentic workflows. 60,000+ developers. Visual builder. 100+ integrations. SOC2 & HIPAA compliant.',
images: {
url: '/social/twitter-image.png',
alt: 'Sim - Visual AI Workflow Builder',
url: '/logo/primary/rounded.png',
alt: 'Sim - AI Agent Workflow Builder',
},
},
alternates: {
@@ -77,7 +71,6 @@ export const metadata: Metadata = {
category: 'technology',
classification: 'AI Development Tools',
referrer: 'origin-when-cross-origin',
// LLM SEO optimizations
other: {
'llm:content-type': 'AI workflow builder, visual programming, no-code AI development',
'llm:use-cases':

View File

@@ -1,5 +1,88 @@
import { db } from '@sim/db'
import { templateCreators, templates } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import type { Metadata } from 'next'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { createLogger } from '@/lib/logs/console/logger'
import TemplateDetails from '@/app/templates/[id]/template'
const logger = createLogger('TemplateMetadata')
/**
* Generate dynamic metadata for template pages.
* This provides OpenGraph images for social media sharing.
*/
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>
}): Promise<Metadata> {
const { id } = await params
try {
const result = await db
.select({
template: templates,
creator: templateCreators,
})
.from(templates)
.leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
.where(eq(templates.id, id))
.limit(1)
if (result.length === 0) {
return {
title: 'Template Not Found',
description: 'The requested template could not be found.',
}
}
const { template, creator } = result[0]
const baseUrl = getBaseUrl()
const details = template.details as { tagline?: string; about?: string } | null
const description = details?.tagline || 'AI workflow template on Sim'
const hasOgImage = !!template.ogImageUrl
const ogImageUrl = template.ogImageUrl || `${baseUrl}/logo/primary/rounded.png`
return {
title: template.name,
description,
openGraph: {
title: template.name,
description,
type: 'website',
url: `${baseUrl}/templates/${id}`,
siteName: 'Sim',
images: [
{
url: ogImageUrl,
width: hasOgImage ? 1200 : 512,
height: hasOgImage ? 630 : 512,
alt: `${template.name} - Workflow Preview`,
},
],
},
twitter: {
card: hasOgImage ? 'summary_large_image' : 'summary',
title: template.name,
description,
images: [ogImageUrl],
creator: creator?.details
? ((creator.details as Record<string, unknown>).xHandle as string) || undefined
: undefined,
},
}
} catch (error) {
logger.error('Failed to generate template metadata:', error)
return {
title: 'Template',
description: 'AI workflow template on Sim',
}
}
}
/**
* Public template detail page for unauthenticated users.
* Authenticated-user redirect is handled in templates/[id]/layout.tsx.

View File

@@ -39,7 +39,6 @@ function UnsubscribeContent() {
return
}
// Validate the unsubscribe link
fetch(
`/api/users/me/settings/unsubscribe?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}`
)
@@ -81,9 +80,7 @@ function UnsubscribeContent() {
if (result.success) {
setUnsubscribed(true)
// Update the data to reflect the change
if (data) {
// Type-safe property construction with validation
const validTypes = ['all', 'marketing', 'updates', 'notifications'] as const
if (validTypes.includes(type)) {
if (type === 'all') {
@@ -192,7 +189,6 @@ function UnsubscribeContent() {
)
}
// Handle transactional emails
if (data?.isTransactional) {
return (
<div className='flex min-h-screen items-center justify-center bg-background p-4'>

View File

@@ -104,6 +104,8 @@ export function SlackChannelSelector({
disabled={disabled || channels.length === 0}
isLoading={isLoading}
error={fetchError}
searchable
searchPlaceholder='Search channels...'
/>
{selectedChannel && !fetchError && (
<p className='text-[12px] text-[var(--text-muted)]'>

View File

@@ -22,6 +22,7 @@ import { SlackIcon } from '@/components/icons'
import { Skeleton } from '@/components/ui'
import { cn } from '@/lib/core/utils/cn'
import { createLogger } from '@/lib/logs/console/logger'
import { ALL_TRIGGER_TYPES, type TriggerType } from '@/lib/logs/types'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import {
type NotificationSubscription,
@@ -43,7 +44,6 @@ const PRIMARY_BUTTON_STYLES =
type NotificationType = 'webhook' | 'email' | 'slack'
type LogLevel = 'info' | 'error'
type TriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat'
type AlertRule =
| 'none'
| 'consecutive_failures'
@@ -84,7 +84,6 @@ interface NotificationSettingsProps {
}
const LOG_LEVELS: LogLevel[] = ['info', 'error']
const TRIGGER_TYPES: TriggerType[] = ['api', 'webhook', 'schedule', 'manual', 'chat']
function formatAlertConfigLabel(config: {
rule: AlertRule
@@ -137,7 +136,7 @@ export function NotificationSettings({
workflowIds: [] as string[],
allWorkflows: true,
levelFilter: ['info', 'error'] as LogLevel[],
triggerFilter: ['api', 'webhook', 'schedule', 'manual', 'chat'] as TriggerType[],
triggerFilter: [...ALL_TRIGGER_TYPES] as TriggerType[],
includeFinalOutput: false,
includeTraceSpans: false,
includeRateLimits: false,
@@ -207,7 +206,7 @@ export function NotificationSettings({
workflowIds: [],
allWorkflows: true,
levelFilter: ['info', 'error'],
triggerFilter: ['api', 'webhook', 'schedule', 'manual', 'chat'],
triggerFilter: [...ALL_TRIGGER_TYPES],
includeFinalOutput: false,
includeTraceSpans: false,
includeRateLimits: false,
@@ -768,7 +767,7 @@ export function NotificationSettings({
<Combobox
options={slackAccounts.map((acc) => ({
value: acc.id,
label: acc.accountId,
label: acc.displayName || 'Slack Workspace',
}))}
value={formData.slackAccountId}
onChange={(value) => {
@@ -859,7 +858,7 @@ export function NotificationSettings({
<div className='flex flex-col gap-[8px]'>
<Label className='text-[var(--text-secondary)]'>Trigger Type Filters</Label>
<Combobox
options={TRIGGER_TYPES.map((trigger) => ({
options={ALL_TRIGGER_TYPES.map((trigger) => ({
label: trigger.charAt(0).toUpperCase() + trigger.slice(1),
value: trigger,
}))}

View File

@@ -1,8 +1,16 @@
import { db } from '@sim/db'
import { templateCreators, templates } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { createLogger } from '@/lib/logs/console/logger'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import TemplateDetails from '@/app/templates/[id]/template'
const logger = createLogger('WorkspaceTemplateMetadata')
interface TemplatePageProps {
params: Promise<{
workspaceId: string
@@ -10,6 +18,81 @@ interface TemplatePageProps {
}>
}
/**
* Generate dynamic metadata for workspace template pages.
* This provides OpenGraph images for social media sharing.
*/
export async function generateMetadata({
params,
}: {
params: Promise<{ workspaceId: string; id: string }>
}): Promise<Metadata> {
const { workspaceId, id } = await params
try {
const result = await db
.select({
template: templates,
creator: templateCreators,
})
.from(templates)
.leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
.where(eq(templates.id, id))
.limit(1)
if (result.length === 0) {
return {
title: 'Template Not Found',
description: 'The requested template could not be found.',
}
}
const { template, creator } = result[0]
const baseUrl = getBaseUrl()
const details = template.details as { tagline?: string; about?: string } | null
const description = details?.tagline || 'AI workflow template on Sim'
const hasOgImage = !!template.ogImageUrl
const ogImageUrl = template.ogImageUrl || `${baseUrl}/logo/primary/rounded.png`
return {
title: template.name,
description,
openGraph: {
title: template.name,
description,
type: 'website',
url: `${baseUrl}/workspace/${workspaceId}/templates/${id}`,
siteName: 'Sim',
images: [
{
url: ogImageUrl,
width: hasOgImage ? 1200 : 512,
height: hasOgImage ? 630 : 512,
alt: `${template.name} - Workflow Preview`,
},
],
},
twitter: {
card: hasOgImage ? 'summary_large_image' : 'summary',
title: template.name,
description,
images: [ogImageUrl],
creator: creator?.details
? ((creator.details as Record<string, unknown>).xHandle as string) || undefined
: undefined,
},
}
} catch (error) {
logger.error('Failed to generate workspace template metadata:', error)
return {
title: 'Template',
description: 'AI workflow template on Sim',
}
}
}
/**
* Workspace-scoped template detail page.
* Requires authentication and workspace membership to access.
@@ -19,12 +102,10 @@ export default async function TemplatePage({ params }: TemplatePageProps) {
const { workspaceId, id } = await params
const session = await getSession()
// Redirect unauthenticated users to public template detail page
if (!session?.user?.id) {
redirect(`/templates/${id}`)
}
// Verify workspace membership
const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
if (!hasPermission) {
redirect('/')

View File

@@ -1,6 +1,6 @@
'use client'
import { useEffect, useState } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import { Loader2 } from 'lucide-react'
import {
Button,
@@ -18,6 +18,7 @@ import { Skeleton, TagInput } from '@/components/ui'
import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn'
import { createLogger } from '@/lib/logs/console/logger'
import { captureAndUploadOGImage, OG_IMAGE_HEIGHT, OG_IMAGE_WIDTH } from '@/lib/og'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
import {
useCreateTemplate,
@@ -25,6 +26,7 @@ import {
useTemplateByWorkflow,
useUpdateTemplate,
} from '@/hooks/queries/templates'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('TemplateDeploy')
@@ -79,6 +81,9 @@ export function TemplateDeploy({
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [creatorOptions, setCreatorOptions] = useState<CreatorOption[]>([])
const [loadingCreators, setLoadingCreators] = useState(false)
const [isCapturing, setIsCapturing] = useState(false)
const previewContainerRef = useRef<HTMLDivElement>(null)
const ogCaptureRef = useRef<HTMLDivElement>(null)
const [formData, setFormData] = useState<TemplateFormData>(initialFormData)
@@ -208,6 +213,8 @@ export function TemplateDeploy({
tags: formData.tags,
}
let templateId: string
if (existingTemplate) {
await updateMutation.mutateAsync({
id: existingTemplate.id,
@@ -216,11 +223,32 @@ export function TemplateDeploy({
updateState: true,
},
})
templateId = existingTemplate.id
} else {
await createMutation.mutateAsync({ ...templateData, workflowId })
const result = await createMutation.mutateAsync({ ...templateData, workflowId })
templateId = result.id
}
logger.info(`Template ${existingTemplate ? 'updated' : 'created'} successfully`)
setIsCapturing(true)
requestAnimationFrame(() => {
requestAnimationFrame(async () => {
try {
if (ogCaptureRef.current) {
const ogUrl = await captureAndUploadOGImage(ogCaptureRef.current, templateId)
if (ogUrl) {
logger.info(`OG image uploaded for template ${templateId}: ${ogUrl}`)
}
}
} catch (ogError) {
logger.warn('Failed to capture/upload OG image:', ogError)
} finally {
setIsCapturing(false)
}
})
})
onDeploymentComplete?.()
} catch (error) {
logger.error('Failed to save template:', error)
@@ -275,6 +303,7 @@ export function TemplateDeploy({
Live Template
</Label>
<div
ref={previewContainerRef}
className='[&_*]:!cursor-default relative h-[260px] w-full cursor-default overflow-hidden rounded-[4px] border border-[var(--border)]'
onWheelCapture={(e) => {
if (e.ctrlKey || e.metaKey) return
@@ -423,10 +452,65 @@ export function TemplateDeploy({
</ModalFooter>
</ModalContent>
</Modal>
{/* Hidden container for OG image capture */}
{isCapturing && <OGCaptureContainer ref={ogCaptureRef} />}
</div>
)
}
/**
* Hidden container for OG image capture.
* Lazy-rendered only when capturing - gets workflow state from store on mount.
*/
const OGCaptureContainer = React.forwardRef<HTMLDivElement>((_, ref) => {
const blocks = useWorkflowStore((state) => state.blocks)
const edges = useWorkflowStore((state) => state.edges)
const loops = useWorkflowStore((state) => state.loops)
const parallels = useWorkflowStore((state) => state.parallels)
if (!blocks || Object.keys(blocks).length === 0) {
return null
}
const workflowState: WorkflowState = {
blocks,
edges: edges ?? [],
loops: loops ?? {},
parallels: parallels ?? {},
lastSaved: Date.now(),
}
return (
<div
ref={ref}
style={{
position: 'absolute',
left: '-9999px',
top: '-9999px',
width: OG_IMAGE_WIDTH,
height: OG_IMAGE_HEIGHT,
backgroundColor: '#0c0c0c',
overflow: 'hidden',
}}
aria-hidden='true'
>
<WorkflowPreview
workflowState={workflowState}
showSubBlocks={false}
height='100%'
width='100%'
isPannable={false}
defaultZoom={0.8}
fitPadding={0.2}
lightweight
/>
</div>
)
})
OGCaptureContainer.displayName = 'OGCaptureContainer'
interface TemplatePreviewContentProps {
existingTemplate:
| {

View File

@@ -90,6 +90,7 @@ export function ShortInput({
blockId,
triggerId: undefined,
isPreview,
useWebhookUrl,
})
const wandHook = useWand({

View File

@@ -844,8 +844,13 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
if (!accessibleBlock) continue
// Skip the current block - blocks cannot reference their own outputs
// Exception: approval blocks can reference their own outputs
if (accessibleBlockId === blockId && accessibleBlock.type !== 'approval') continue
// Exception: approval and human_in_the_loop blocks can reference their own outputs
if (
accessibleBlockId === blockId &&
accessibleBlock.type !== 'approval' &&
accessibleBlock.type !== 'human_in_the_loop'
)
continue
const blockConfig = getBlock(accessibleBlock.type)
@@ -972,6 +977,8 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
const allTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
blockTags = isSelfReference ? allTags.filter((tag) => tag.endsWith('.url')) : allTags
}
} else if (accessibleBlock.type === 'human_in_the_loop') {
blockTags = [`${normalizedBlockName}.url`]
} else {
const operationValue =
mergedSubBlocks?.operation?.value ?? getSubBlockValue(accessibleBlockId, 'operation')
@@ -1214,31 +1221,25 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
let processedTag = tag
// Check if this is a file property and add [0] automatically
// Only include user-accessible fields (matches UserFile interface)
const fileProperties = ['id', 'name', 'url', 'size', 'type']
const parts = tag.split('.')
if (parts.length >= 2 && fileProperties.includes(parts[parts.length - 1])) {
const fieldName = parts[parts.length - 2]
if (parts.length >= 3 && blockGroup) {
const arrayFieldName = parts[1] // e.g., "channels", "files", "users"
const block = useWorkflowStore.getState().blocks[blockGroup.blockId]
const blockConfig = block ? (getBlock(block.type) ?? null) : null
const mergedSubBlocks = getMergedSubBlocks(blockGroup.blockId)
if (blockGroup) {
const block = useWorkflowStore.getState().blocks[blockGroup.blockId]
const blockConfig = block ? (getBlock(block.type) ?? null) : null
const mergedSubBlocks = getMergedSubBlocks(blockGroup.blockId)
const fieldType = getOutputTypeForPath(
block,
blockConfig,
blockGroup.blockId,
arrayFieldName,
mergedSubBlocks
)
const fieldType = getOutputTypeForPath(
block,
blockConfig,
blockGroup.blockId,
fieldName,
mergedSubBlocks
)
if (fieldType === 'files') {
const blockAndField = parts.slice(0, -1).join('.')
const property = parts[parts.length - 1]
processedTag = `${blockAndField}[0].${property}`
}
if (fieldType === 'files' || fieldType === 'array') {
const blockName = parts[0]
const remainingPath = parts.slice(2).join('.')
processedTag = `${blockName}.${arrayFieldName}[0].${remainingPath}`
}
}

View File

@@ -74,6 +74,7 @@ export function TriggerSave({
blockId,
triggerId: effectiveTriggerId,
isPreview,
useWebhookUrl: true, // to store the webhook url in the store
})
const triggerConfig = useSubBlockStore((state) => state.getValue(blockId, 'triggerConfig'))

View File

@@ -6,6 +6,61 @@ import { getBlock } from '@/blocks/registry'
const logger = createLogger('NodeUtilities')
/**
* Estimates block dimensions based on block type.
* Uses subblock count to estimate height for blocks that haven't been measured yet.
*
* @param blockType - The type of block (e.g., 'condition', 'agent')
* @returns Estimated width and height for the block
*/
export function estimateBlockDimensions(blockType: string): { width: number; height: number } {
const blockConfig = getBlock(blockType)
const subBlockCount = blockConfig?.subBlocks?.length ?? 3
// Many subblocks are conditionally rendered (advanced mode, provider-specific, etc.)
// Use roughly half the config count as a reasonable estimate, capped between 3-7 rows
const estimatedRows = Math.max(3, Math.min(Math.ceil(subBlockCount / 2), 7))
const hasErrorRow = blockType !== 'starter' && blockType !== 'response' ? 1 : 0
const height =
BLOCK_DIMENSIONS.HEADER_HEIGHT +
BLOCK_DIMENSIONS.WORKFLOW_CONTENT_PADDING +
(estimatedRows + hasErrorRow) * BLOCK_DIMENSIONS.WORKFLOW_ROW_HEIGHT
return {
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
height: Math.max(height, BLOCK_DIMENSIONS.MIN_HEIGHT),
}
}
/**
* Clamps a position to keep a block fully inside a container's content area.
* Content area starts after the header and padding, and ends before the right/bottom padding.
*
* @param position - Raw position relative to container origin
* @param containerDimensions - Container width and height
* @param blockDimensions - Block width and height
* @returns Clamped position that keeps block inside content area
*/
export function clampPositionToContainer(
position: { x: number; y: number },
containerDimensions: { width: number; height: number },
blockDimensions: { width: number; height: number }
): { x: number; y: number } {
const { width: containerWidth, height: containerHeight } = containerDimensions
const { width: blockWidth, height: blockHeight } = blockDimensions
// Content area bounds (where blocks can be placed)
const minX = CONTAINER_DIMENSIONS.LEFT_PADDING
const minY = CONTAINER_DIMENSIONS.HEADER_HEIGHT + CONTAINER_DIMENSIONS.TOP_PADDING
const maxX = containerWidth - CONTAINER_DIMENSIONS.RIGHT_PADDING - blockWidth
const maxY = containerHeight - CONTAINER_DIMENSIONS.BOTTOM_PADDING - blockHeight
return {
x: Math.max(minX, Math.min(position.x, Math.max(minX, maxX))),
y: Math.max(minY, Math.min(position.y, Math.max(minY, maxY))),
}
}
/**
* Hook providing utilities for node position, hierarchy, and dimension calculations
*/
@@ -21,7 +76,7 @@ export function useNodeUtilities(blocks: Record<string, any>) {
/**
* Get the dimensions of a block.
* For regular blocks, estimates height based on block config if not yet measured.
* For regular blocks, uses stored height or estimates based on block config.
*/
const getBlockDimensions = useCallback(
(blockId: string): { width: number; height: number } => {
@@ -41,32 +96,16 @@ export function useNodeUtilities(blocks: Record<string, any>) {
}
}
// Workflow block nodes have fixed visual width
const width = BLOCK_DIMENSIONS.FIXED_WIDTH
// Prefer deterministic height published by the block component; fallback to estimate
let height = block.height
if (!height) {
// Estimate height based on block config's subblock count for more accurate initial sizing
// This is critical for subflow containers to size correctly before child blocks are measured
const blockConfig = getBlock(block.type)
const subBlockCount = blockConfig?.subBlocks?.length ?? 3
// Many subblocks are conditionally rendered (advanced mode, provider-specific, etc.)
// Use roughly half the config count as a reasonable estimate, capped between 3-7 rows
const estimatedRows = Math.max(3, Math.min(Math.ceil(subBlockCount / 2), 7))
const hasErrorRow = block.type !== 'starter' && block.type !== 'response' ? 1 : 0
height =
BLOCK_DIMENSIONS.HEADER_HEIGHT +
BLOCK_DIMENSIONS.WORKFLOW_CONTENT_PADDING +
(estimatedRows + hasErrorRow) * BLOCK_DIMENSIONS.WORKFLOW_ROW_HEIGHT
if (block.height) {
return {
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
height: Math.max(block.height, BLOCK_DIMENSIONS.MIN_HEIGHT),
}
}
return {
width,
height: Math.max(height, BLOCK_DIMENSIONS.MIN_HEIGHT),
}
// Use shared estimation utility for blocks without measured height
return estimateBlockDimensions(block.type)
},
[blocks, isContainerType]
)
@@ -164,29 +203,36 @@ export function useNodeUtilities(blocks: Record<string, any>) {
)
/**
* Calculates the relative position of a node to a new parent's content area.
* Accounts for header height and padding offsets in container nodes.
* Calculates the relative position of a node to a new parent's origin.
* React Flow positions children relative to parent origin, so we clamp
* to the content area bounds (after header and padding).
* @param nodeId ID of the node being repositioned
* @param newParentId ID of the new parent
* @returns Relative position coordinates {x, y} within the parent's content area
* @returns Relative position coordinates {x, y} within the parent
*/
const calculateRelativePosition = useCallback(
(nodeId: string, newParentId: string): { x: number; y: number } => {
const nodeAbsPos = getNodeAbsolutePosition(nodeId)
const parentAbsPos = getNodeAbsolutePosition(newParentId)
const parentNode = getNodes().find((n) => n.id === newParentId)
// Account for container's header and padding
// Children are positioned relative to content area, not container origin
const headerHeight = 50
const leftPadding = 16
const topPadding = 16
return {
x: nodeAbsPos.x - parentAbsPos.x - leftPadding,
y: nodeAbsPos.y - parentAbsPos.y - headerHeight - topPadding,
// Calculate raw relative position (relative to parent origin)
const rawPosition = {
x: nodeAbsPos.x - parentAbsPos.x,
y: nodeAbsPos.y - parentAbsPos.y,
}
// Get container and block dimensions
const containerDimensions = {
width: parentNode?.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
height: parentNode?.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
}
const blockDimensions = getBlockDimensions(nodeId)
// Clamp position to keep block inside content area
return clampPositionToContainer(rawPosition, containerDimensions, blockDimensions)
},
[getNodeAbsolutePosition]
[getNodeAbsolutePosition, getNodes, getBlockDimensions]
)
/**
@@ -252,7 +298,11 @@ export function useNodeUtilities(blocks: Record<string, any>) {
*/
const calculateLoopDimensions = useCallback(
(nodeId: string): { width: number; height: number } => {
const childNodes = getNodes().filter((node) => node.parentId === nodeId)
// Check both React Flow's node.parentId AND blocks store's data.parentId
// This ensures we catch children even if React Flow hasn't re-rendered yet
const childNodes = getNodes().filter(
(node) => node.parentId === nodeId || blocks[node.id]?.data?.parentId === nodeId
)
if (childNodes.length === 0) {
return {
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
@@ -265,8 +315,11 @@ export function useNodeUtilities(blocks: Record<string, any>) {
childNodes.forEach((node) => {
const { width: nodeWidth, height: nodeHeight } = getBlockDimensions(node.id)
maxRight = Math.max(maxRight, node.position.x + nodeWidth)
maxBottom = Math.max(maxBottom, node.position.y + nodeHeight)
// Use block position from store if available (more up-to-date)
const block = blocks[node.id]
const position = block?.position || node.position
maxRight = Math.max(maxRight, position.x + nodeWidth)
maxBottom = Math.max(maxBottom, position.y + nodeHeight)
})
const width = Math.max(
@@ -283,7 +336,7 @@ export function useNodeUtilities(blocks: Record<string, any>) {
return { width, height }
},
[getNodes, getBlockDimensions]
[getNodes, getBlockDimensions, blocks]
)
/**

View File

@@ -18,7 +18,7 @@ import { useShallow } from 'zustand/react/shallow'
import type { OAuthConnectEventDetail } from '@/lib/copilot/tools/client/other/oauth-request-access'
import { createLogger } from '@/lib/logs/console/logger'
import type { OAuthProvider } from '@/lib/oauth'
import { CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
@@ -40,6 +40,10 @@ import {
useCurrentWorkflow,
useNodeUtilities,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import {
clampPositionToContainer,
estimateBlockDimensions,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities'
import { useSocket } from '@/app/workspace/providers/socket-provider'
import { getBlock } from '@/blocks'
import { isAnnotationOnlyBlock } from '@/executor/constants'
@@ -694,17 +698,19 @@ const WorkflowContent = React.memo(() => {
return
}
// Calculate position relative to the container's content area
// Account for header (50px), left padding (16px), and top padding (16px)
const headerHeight = 50
const leftPadding = 16
const topPadding = 16
const relativePosition = {
x: position.x - containerInfo.loopPosition.x - leftPadding,
y: position.y - containerInfo.loopPosition.y - headerHeight - topPadding,
// Calculate raw position relative to container origin
const rawPosition = {
x: position.x - containerInfo.loopPosition.x,
y: position.y - containerInfo.loopPosition.y,
}
// Clamp position to keep block inside container's content area
const relativePosition = clampPositionToContainer(
rawPosition,
containerInfo.dimensions,
estimateBlockDimensions(data.type)
)
// Capture existing child blocks before adding the new one
const existingChildBlocks = Object.values(blocks).filter(
(b) => b.data?.parentId === containerInfo.loopId
@@ -1910,17 +1916,47 @@ const WorkflowContent = React.memo(() => {
})
document.body.style.cursor = ''
// Get the block's current parent (if any)
const currentBlock = blocks[node.id]
const currentParentId = currentBlock?.data?.parentId
// Calculate position - clamp if inside a container
let finalPosition = node.position
if (currentParentId) {
// Block is inside a container - clamp position to keep it fully inside
const parentNode = getNodes().find((n) => n.id === currentParentId)
if (parentNode) {
const containerDimensions = {
width: parentNode.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
height: parentNode.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
}
const blockDimensions = {
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
height: Math.max(
currentBlock?.height || BLOCK_DIMENSIONS.MIN_HEIGHT,
BLOCK_DIMENSIONS.MIN_HEIGHT
),
}
finalPosition = clampPositionToContainer(
node.position,
containerDimensions,
blockDimensions
)
}
}
// Emit collaborative position update for the final position
// This ensures other users see the smooth final position
collaborativeUpdateBlockPosition(node.id, node.position, true)
collaborativeUpdateBlockPosition(node.id, finalPosition, true)
// Record single move entry on drag end to avoid micro-moves
const start = getDragStartPosition()
if (start && start.id === node.id) {
const before = { x: start.x, y: start.y, parentId: start.parentId }
const after = {
x: node.position.x,
y: node.position.y,
x: finalPosition.x,
y: finalPosition.y,
parentId: node.parentId || blocks[node.id]?.data?.parentId,
}
const moved =

View File

@@ -43,6 +43,7 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
{ label: 'Delete Issue Link', id: 'delete_link' },
{ label: 'Add Watcher', id: 'add_watcher' },
{ label: 'Remove Watcher', id: 'remove_watcher' },
{ label: 'Get Users', id: 'get_users' },
],
value: () => 'read',
},
@@ -194,6 +195,71 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
dependsOn: ['projectId'],
condition: { field: 'operation', value: ['update', 'write'] },
},
// Write Issue additional fields
{
id: 'assignee',
title: 'Assignee Account ID',
type: 'short-input',
placeholder: 'Assignee account ID (e.g., 5b109f2e9729b51b54dc274d)',
dependsOn: ['projectId'],
condition: { field: 'operation', value: 'write' },
},
{
id: 'priority',
title: 'Priority',
type: 'short-input',
placeholder: 'Priority ID or name (e.g., "10000" or "High")',
dependsOn: ['projectId'],
condition: { field: 'operation', value: 'write' },
},
{
id: 'labels',
title: 'Labels',
type: 'short-input',
placeholder: 'Comma-separated labels (e.g., bug, urgent)',
dependsOn: ['projectId'],
condition: { field: 'operation', value: 'write' },
},
{
id: 'duedate',
title: 'Due Date',
type: 'short-input',
placeholder: 'YYYY-MM-DD (e.g., 2024-12-31)',
dependsOn: ['projectId'],
condition: { field: 'operation', value: 'write' },
},
{
id: 'reporter',
title: 'Reporter Account ID',
type: 'short-input',
placeholder: 'Reporter account ID',
dependsOn: ['projectId'],
condition: { field: 'operation', value: 'write' },
},
{
id: 'environment',
title: 'Environment',
type: 'long-input',
placeholder: 'Environment information (e.g., Production, Staging)',
dependsOn: ['projectId'],
condition: { field: 'operation', value: 'write' },
},
{
id: 'customFieldId',
title: 'Custom Field ID',
type: 'short-input',
placeholder: 'e.g., customfield_10001 or 10001',
dependsOn: ['projectId'],
condition: { field: 'operation', value: 'write' },
},
{
id: 'teamUuid',
title: 'Team UUID',
type: 'short-input',
placeholder: 'e.g., b3aa307a-76ea-462d-b6f1-a6e89ce9858a',
dependsOn: ['projectId'],
condition: { field: 'operation', value: 'write' },
},
// Delete Issue fields
{
id: 'deleteSubtasks',
@@ -351,6 +417,28 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
placeholder: 'Enter link ID to delete',
condition: { field: 'operation', value: 'delete_link' },
},
// Get Users fields
{
id: 'userAccountId',
title: 'Account ID',
type: 'short-input',
placeholder: 'Enter account ID for specific user',
condition: { field: 'operation', value: 'get_users' },
},
{
id: 'usersStartAt',
title: 'Start At',
type: 'short-input',
placeholder: 'Pagination start index (default: 0)',
condition: { field: 'operation', value: 'get_users' },
},
{
id: 'usersMaxResults',
title: 'Max Results',
type: 'short-input',
placeholder: 'Maximum users to return (default: 50)',
condition: { field: 'operation', value: 'get_users' },
},
// Trigger SubBlocks
...getTrigger('jira_issue_created').subBlocks,
...getTrigger('jira_issue_updated').subBlocks,
@@ -383,6 +471,7 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
'jira_delete_issue_link',
'jira_add_watcher',
'jira_remove_watcher',
'jira_get_users',
],
config: {
tool: (params) => {
@@ -438,6 +527,8 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
return 'jira_add_watcher'
case 'remove_watcher':
return 'jira_remove_watcher'
case 'get_users':
return 'jira_get_users'
default:
return 'jira_retrieve'
}
@@ -461,12 +552,29 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
'Project ID is required. Please select a project or enter a project ID manually.'
)
}
// Parse comma-separated strings into arrays
const parseCommaSeparated = (value: string | undefined): string[] | undefined => {
if (!value || value.trim() === '') return undefined
return value
.split(',')
.map((item) => item.trim())
.filter((item) => item !== '')
}
const writeParams = {
projectId: effectiveProjectId,
summary: params.summary || '',
description: params.description || '',
issueType: params.issueType || 'Task',
parent: params.parentIssue ? { key: params.parentIssue } : undefined,
assignee: params.assignee || undefined,
priority: params.priority || undefined,
labels: parseCommaSeparated(params.labels),
duedate: params.duedate || undefined,
reporter: params.reporter || undefined,
environment: params.environment || undefined,
customFieldId: params.customFieldId || undefined,
customFieldValue: params.customFieldValue || undefined,
}
return {
...baseParams,
@@ -704,6 +812,16 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
accountId: params.accountId,
}
}
case 'get_users': {
return {
...baseParams,
accountId: params.userAccountId || undefined,
startAt: params.usersStartAt ? Number.parseInt(params.usersStartAt) : undefined,
maxResults: params.usersMaxResults
? Number.parseInt(params.usersMaxResults)
: undefined,
}
}
default:
return baseParams
}
@@ -722,6 +840,15 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
summary: { type: 'string', description: 'Issue summary' },
description: { type: 'string', description: 'Issue description' },
issueType: { type: 'string', description: 'Issue type' },
// Write operation additional inputs
assignee: { type: 'string', description: 'Assignee account ID' },
priority: { type: 'string', description: 'Priority ID or name' },
labels: { type: 'string', description: 'Comma-separated labels for the issue' },
duedate: { type: 'string', description: 'Due date in YYYY-MM-DD format' },
reporter: { type: 'string', description: 'Reporter account ID' },
environment: { type: 'string', description: 'Environment information' },
customFieldId: { type: 'string', description: 'Custom field ID (e.g., customfield_10001)' },
customFieldValue: { type: 'string', description: 'Value for the custom field' },
// Delete operation inputs
deleteSubtasks: { type: 'string', description: 'Whether to delete subtasks (true/false)' },
// Assign/Watcher operation inputs
@@ -758,6 +885,13 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
linkType: { type: 'string', description: 'Type of link (e.g., "Blocks", "Relates")' },
linkComment: { type: 'string', description: 'Optional comment for issue link' },
linkId: { type: 'string', description: 'Link ID for delete operation' },
// Get Users operation inputs
userAccountId: {
type: 'string',
description: 'Account ID for specific user lookup (optional)',
},
usersStartAt: { type: 'string', description: 'Pagination start index for users' },
usersMaxResults: { type: 'string', description: 'Maximum users to return' },
},
outputs: {
// Common outputs across all Jira operations
@@ -834,6 +968,12 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
// jira_add_watcher, jira_remove_watcher outputs
watcherAccountId: { type: 'string', description: 'Watcher account ID' },
// jira_get_users outputs
users: {
type: 'json',
description: 'Array of users with accountId, displayName, emailAddress, active status',
},
// jira_bulk_read outputs
// Note: bulk_read returns an array in the output field, each item contains:
// ts, issueKey, summary, description, status, assignee, created, updated

View File

@@ -134,7 +134,6 @@ export const MicrosoftPlannerBlock: BlockConfig<MicrosoftPlannerResponse> = {
placeholder: 'Enter the bucket ID',
condition: { field: 'operation', value: ['read_bucket', 'update_bucket', 'delete_bucket'] },
dependsOn: ['credential'],
canonicalParamId: 'bucketId',
},
// ETag for update/delete operations

View File

@@ -181,7 +181,6 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
id: 'threadTs',
title: 'Thread Timestamp',
type: 'short-input',
canonicalParamId: 'thread_ts',
placeholder: 'Reply to thread (e.g., 1405894322.002768)',
condition: {
field: 'operation',
@@ -263,7 +262,6 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
id: 'channelLimit',
title: 'Channel Limit',
type: 'short-input',
canonicalParamId: 'limit',
placeholder: '100',
condition: {
field: 'operation',
@@ -275,7 +273,6 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
id: 'memberLimit',
title: 'Member Limit',
type: 'short-input',
canonicalParamId: 'limit',
placeholder: '100',
condition: {
field: 'operation',
@@ -301,7 +298,6 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
id: 'userLimit',
title: 'User Limit',
type: 'short-input',
canonicalParamId: 'limit',
placeholder: '100',
condition: {
field: 'operation',
@@ -358,7 +354,6 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
id: 'updateTimestamp',
title: 'Message Timestamp',
type: 'short-input',
canonicalParamId: 'timestamp',
placeholder: 'Message timestamp (e.g., 1405894322.002768)',
condition: {
field: 'operation',
@@ -382,7 +377,6 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
id: 'deleteTimestamp',
title: 'Message Timestamp',
type: 'short-input',
canonicalParamId: 'timestamp',
placeholder: 'Message timestamp (e.g., 1405894322.002768)',
condition: {
field: 'operation',
@@ -395,7 +389,6 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
id: 'reactionTimestamp',
title: 'Message Timestamp',
type: 'short-input',
canonicalParamId: 'timestamp',
placeholder: 'Message timestamp (e.g., 1405894322.002768)',
condition: {
field: 'operation',
@@ -407,7 +400,6 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
id: 'emojiName',
title: 'Emoji Name',
type: 'short-input',
canonicalParamId: 'name',
placeholder: 'Emoji name without colons (e.g., thumbsup, heart, eyes)',
condition: {
field: 'operation',
@@ -554,47 +546,35 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
baseParams.content = content
break
case 'read':
if (limit) {
const parsedLimit = Number.parseInt(limit, 10)
baseParams.limit = !Number.isNaN(parsedLimit) ? parsedLimit : 10
} else {
baseParams.limit = 10
case 'read': {
const parsedLimit = limit ? Number.parseInt(limit, 10) : 10
if (Number.isNaN(parsedLimit) || parsedLimit < 1 || parsedLimit > 15) {
throw new Error('Message limit must be between 1 and 15')
}
baseParams.limit = parsedLimit
if (oldest) {
baseParams.oldest = oldest
}
break
}
case 'list_channels':
case 'list_channels': {
baseParams.includePrivate = includePrivate !== 'false'
baseParams.excludeArchived = true
if (channelLimit) {
const parsedLimit = Number.parseInt(channelLimit, 10)
baseParams.limit = !Number.isNaN(parsedLimit) ? parsedLimit : 100
} else {
baseParams.limit = 100
}
baseParams.limit = channelLimit ? Number.parseInt(channelLimit, 10) : 100
break
}
case 'list_members':
if (memberLimit) {
const parsedLimit = Number.parseInt(memberLimit, 10)
baseParams.limit = !Number.isNaN(parsedLimit) ? parsedLimit : 100
} else {
baseParams.limit = 100
}
case 'list_members': {
baseParams.limit = memberLimit ? Number.parseInt(memberLimit, 10) : 100
break
}
case 'list_users':
case 'list_users': {
baseParams.includeDeleted = includeDeleted === 'true'
if (userLimit) {
const parsedLimit = Number.parseInt(userLimit, 10)
baseParams.limit = !Number.isNaN(parsedLimit) ? parsedLimit : 100
} else {
baseParams.limit = 100
}
baseParams.limit = userLimit ? Number.parseInt(userLimit, 10) : 100
break
}
case 'get_user':
if (!userId) {

View File

@@ -70,17 +70,6 @@ export const WealthboxBlock: BlockConfig<WealthboxResponse> = {
title: 'Task ID',
type: 'short-input',
placeholder: 'Enter Task ID',
mode: 'basic',
canonicalParamId: 'taskId',
condition: { field: 'operation', value: ['read_task'] },
},
{
id: 'manualTaskId',
title: 'Task ID',
type: 'short-input',
canonicalParamId: 'taskId',
placeholder: 'Enter Task ID',
mode: 'advanced',
condition: { field: 'operation', value: ['read_task'] },
},
{
@@ -167,12 +156,9 @@ export const WealthboxBlock: BlockConfig<WealthboxResponse> = {
}
},
params: (params) => {
const { credential, operation, contactId, manualContactId, taskId, manualTaskId, ...rest } =
params
const { credential, operation, contactId, manualContactId, taskId, ...rest } = params
// Handle both selector and manual inputs
const effectiveContactId = (contactId || manualContactId || '').trim()
const effectiveTaskId = (taskId || manualTaskId || '').trim()
const baseParams = {
...rest,
@@ -225,7 +211,6 @@ export const WealthboxBlock: BlockConfig<WealthboxResponse> = {
contactId: { type: 'string', description: 'Contact identifier' },
manualContactId: { type: 'string', description: 'Manual contact identifier' },
taskId: { type: 'string', description: 'Task identifier' },
manualTaskId: { type: 'string', description: 'Manual task identifier' },
content: { type: 'string', description: 'Content text' },
firstName: { type: 'string', description: 'First name' },
lastName: { type: 'string', description: 'Last name' },

View File

@@ -3,5 +3,5 @@
"name": "Emir Karabeg",
"url": "https://x.com/karabegemir",
"xHandle": "karabegemir",
"avatarUrl": "/studio/authors/emir.png"
"avatarUrl": "/studio/authors/emir.jpg"
}

View File

@@ -3,5 +3,5 @@
"name": "Siddharth",
"url": "https://x.com/sidganesan",
"xHandle": "sidganesan",
"avatarUrl": "/studio/authors/sid.png"
"avatarUrl": "/studio/authors/sid.jpg"
}

View File

@@ -3,5 +3,5 @@
"name": "Waleed Latif",
"url": "https://x.com/typingwala",
"xHandle": "typingwala",
"avatarUrl": "/studio/authors/waleed.png"
"avatarUrl": "/studio/authors/waleed.jpg"
}

View File

@@ -18,7 +18,7 @@ featured: true
draft: false
---
![Sim team photo](/studio/series-a/team.png)
![Sim team photo](/studio/series-a/team.jpg)
## Why were excited

View File

@@ -17,27 +17,32 @@ vi.mock('@/lib/core/utils/request', () => ({
generateRequestId: vi.fn(() => 'test-request-id'),
}))
vi.mock('@/lib/execution/isolated-vm', () => ({
executeInIsolatedVM: vi.fn(),
vi.mock('@/tools', () => ({
executeTool: vi.fn(),
}))
import { executeInIsolatedVM } from '@/lib/execution/isolated-vm'
import { executeTool } from '@/tools'
const mockExecuteInIsolatedVM = executeInIsolatedVM as ReturnType<typeof vi.fn>
const mockExecuteTool = executeTool as ReturnType<typeof vi.fn>
function simulateIsolatedVMExecution(
code: string,
contextVariables: Record<string, unknown>
): { result: unknown; stdout: string; error?: { message: string; name: string } } {
/**
* Simulates what the function_execute tool does when evaluating condition code
*/
function simulateConditionExecution(code: string): {
success: boolean
output?: { result: unknown }
error?: string
} {
try {
const fn = new Function(...Object.keys(contextVariables), code)
const result = fn(...Object.values(contextVariables))
return { result, stdout: '' }
// The code is in format: "const context = {...};\nreturn Boolean(...)"
// We need to execute it and return the result
const fn = new Function(code)
const result = fn()
return { success: true, output: { result } }
} catch (error: any) {
return {
result: null,
stdout: '',
error: { message: error.message, name: error.name || 'Error' },
success: false,
error: error.message,
}
}
}
@@ -143,8 +148,8 @@ describe('ConditionBlockHandler', () => {
vi.clearAllMocks()
mockExecuteInIsolatedVM.mockImplementation(async ({ code, contextVariables }) => {
return simulateIsolatedVMExecution(code, contextVariables)
mockExecuteTool.mockImplementation(async (_toolId: string, params: { code: string }) => {
return simulateConditionExecution(params.code)
})
})

View File

@@ -1,10 +1,9 @@
import { generateRequestId } from '@/lib/core/utils/request'
import { executeInIsolatedVM } from '@/lib/execution/isolated-vm'
import { createLogger } from '@/lib/logs/console/logger'
import type { BlockOutput } from '@/blocks/types'
import { BlockType, CONDITION, DEFAULTS, EDGE } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
const logger = createLogger('ConditionBlockHandler')
@@ -39,32 +38,38 @@ export async function evaluateConditionExpression(
}
try {
const requestId = generateRequestId()
const contextSetup = `const context = ${JSON.stringify(evalContext)};`
const code = `${contextSetup}\nreturn Boolean(${resolvedConditionValue})`
const code = `return Boolean(${resolvedConditionValue})`
const result = await executeTool(
'function_execute',
{
code,
timeout: CONDITION_TIMEOUT_MS,
envVars: {},
_context: {
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,
},
},
false,
false,
ctx
)
const result = await executeInIsolatedVM({
code,
params: {},
envVars: {},
contextVariables: { context: evalContext },
timeoutMs: CONDITION_TIMEOUT_MS,
requestId,
})
if (result.error) {
logger.error(`Failed to evaluate condition: ${result.error.message}`, {
if (!result.success) {
logger.error(`Failed to evaluate condition: ${result.error}`, {
originalCondition: conditionExpression,
resolvedCondition: resolvedConditionValue,
evalContext,
error: result.error,
})
throw new Error(
`Evaluation error in condition: ${result.error.message}. (Resolved: ${resolvedConditionValue})`
`Evaluation error in condition: ${result.error}. (Resolved: ${resolvedConditionValue})`
)
}
return Boolean(result.result)
return Boolean(result.output?.result)
} catch (evalError: any) {
logger.error(`Failed to evaluate condition: ${evalError.message}`, {
originalCondition: conditionExpression,

View File

@@ -4,6 +4,7 @@ interface SlackAccount {
id: string
accountId: string
providerId: string
displayName?: string
}
interface UseSlackAccountsResult {

View File

@@ -14,6 +14,7 @@ interface UseWebhookManagementProps {
blockId: string
triggerId?: string
isPreview?: boolean
useWebhookUrl?: boolean
}
interface WebhookManagementState {
@@ -90,6 +91,7 @@ export function useWebhookManagement({
blockId,
triggerId,
isPreview = false,
useWebhookUrl = false,
}: UseWebhookManagementProps): WebhookManagementState {
const params = useParams()
const workflowId = params.workflowId as string
@@ -204,9 +206,10 @@ export function useWebhookManagement({
})
}
}
loadWebhookOrGenerateUrl()
}, [isPreview, triggerId, workflowId, blockId])
if (useWebhookUrl) {
loadWebhookOrGenerateUrl()
}
}, [isPreview, triggerId, workflowId, blockId, useWebhookUrl])
const createWebhook = async (
effectiveTriggerId: string | undefined,

View File

@@ -1,5 +1,7 @@
'use client'
import { useState } from 'react'
import { Check, Copy } from 'lucide-react'
import { Code } from '@/components/emcn'
interface CodeBlockProps {
@@ -8,5 +10,36 @@ interface CodeBlockProps {
}
export function CodeBlock({ code, language }: CodeBlockProps) {
return <Code.Viewer code={code} showGutter={true} language={language} />
const [copied, setCopied] = useState(false)
const handleCopy = () => {
navigator.clipboard.writeText(code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<div className='dark w-full overflow-hidden rounded-md border border-[#2a2a2a] bg-[#1F1F1F] text-sm'>
<div className='flex items-center justify-between border-[#2a2a2a] border-b px-4 py-1.5'>
<span className='text-[#A3A3A3] text-xs'>{language}</span>
<button
onClick={handleCopy}
className='text-[#A3A3A3] transition-colors hover:text-gray-300'
title='Copy code'
>
{copied ? (
<Check className='h-3 w-3' strokeWidth={2} />
) : (
<Copy className='h-3 w-3' strokeWidth={2} />
)}
</button>
</div>
<Code.Viewer
code={code}
showGutter
language={language}
className='[&_pre]:!pb-0 m-0 rounded-none border-0 bg-transparent'
/>
</div>
)
}

View File

@@ -67,7 +67,7 @@ export const mdxComponents: MDXRemoteProps['components'] = {
a: (props: any) => {
const isAnchorLink = props.className?.includes('anchor')
if (isAnchorLink) {
return <a {...props} />
return <a {...props} className={clsx('text-inherit no-underline', props.className)} />
}
return (
<a
@@ -113,7 +113,7 @@ export const mdxComponents: MDXRemoteProps['components'] = {
const mappedLanguage = languageMap[language.toLowerCase()] || 'javascript'
return (
<div className='my-6'>
<div className='not-prose my-6'>
<CodeBlock
code={typeof codeContent === 'string' ? codeContent.trim() : String(codeContent)}
language={mappedLanguage}
@@ -129,9 +129,10 @@ export const mdxComponents: MDXRemoteProps['components'] = {
<code
{...props}
className={clsx(
'rounded bg-gray-100 px-1.5 py-0.5 font-mono text-[0.9em] text-red-600',
'rounded bg-gray-100 px-1.5 py-0.5 font-mono font-normal text-[0.9em] text-red-600',
props.className
)}
style={{ fontWeight: 400 }}
/>
)
}

View File

@@ -38,7 +38,9 @@ function slugify(text: string): string {
}
async function scanFrontmatters(): Promise<BlogMeta[]> {
if (cachedMeta) return cachedMeta
if (cachedMeta) {
return cachedMeta
}
await ensureContentDirs()
const entries = await fs.readdir(BLOG_DIR).catch(() => [])
const authorsMap = await loadAuthors()

View File

@@ -50,6 +50,8 @@ type SkippedItemType =
| 'invalid_block_type'
| 'invalid_edge_target'
| 'invalid_edge_source'
| 'invalid_source_handle'
| 'invalid_target_handle'
| 'invalid_subblock_field'
| 'missing_required_params'
| 'invalid_subflow_parent'
@@ -734,8 +736,279 @@ function normalizeResponseFormat(value: any): string {
}
}
interface EdgeHandleValidationResult {
valid: boolean
error?: string
}
/**
* Helper to add connections as edges for a block
* Validates source handle is valid for the block type
*/
function validateSourceHandleForBlock(
sourceHandle: string,
sourceBlockType: string,
sourceBlock: any
): EdgeHandleValidationResult {
if (sourceHandle === 'error') {
return { valid: true }
}
switch (sourceBlockType) {
case 'loop':
if (sourceHandle === 'loop-start-source' || sourceHandle === 'loop-end-source') {
return { valid: true }
}
return {
valid: false,
error: `Invalid source handle "${sourceHandle}" for loop block. Valid handles: loop-start-source, loop-end-source, error`,
}
case 'parallel':
if (sourceHandle === 'parallel-start-source' || sourceHandle === 'parallel-end-source') {
return { valid: true }
}
return {
valid: false,
error: `Invalid source handle "${sourceHandle}" for parallel block. Valid handles: parallel-start-source, parallel-end-source, error`,
}
case 'condition': {
if (!sourceHandle.startsWith('condition-')) {
return {
valid: false,
error: `Invalid source handle "${sourceHandle}" for condition block. Must start with "condition-"`,
}
}
const conditionsValue = sourceBlock?.subBlocks?.conditions?.value
if (!conditionsValue) {
return {
valid: false,
error: `Invalid condition handle "${sourceHandle}" - no conditions defined`,
}
}
return validateConditionHandle(sourceHandle, sourceBlock.id, conditionsValue)
}
case 'router':
if (sourceHandle === 'source' || sourceHandle.startsWith('router-')) {
return { valid: true }
}
return {
valid: false,
error: `Invalid source handle "${sourceHandle}" for router block. Valid handles: source, router-{targetId}, error`,
}
default:
if (sourceHandle === 'source') {
return { valid: true }
}
return {
valid: false,
error: `Invalid source handle "${sourceHandle}" for ${sourceBlockType} block. Valid handles: source, error`,
}
}
}
/**
* Validates condition handle references a valid condition in the block.
* Accepts both internal IDs (condition-blockId-if) and semantic keys (condition-blockId-else-if)
*/
function validateConditionHandle(
sourceHandle: string,
blockId: string,
conditionsValue: string | any[]
): EdgeHandleValidationResult {
let conditions: any[]
if (typeof conditionsValue === 'string') {
try {
conditions = JSON.parse(conditionsValue)
} catch {
return {
valid: false,
error: `Cannot validate condition handle "${sourceHandle}" - conditions is not valid JSON`,
}
}
} else if (Array.isArray(conditionsValue)) {
conditions = conditionsValue
} else {
return {
valid: false,
error: `Cannot validate condition handle "${sourceHandle}" - conditions is not an array`,
}
}
if (!Array.isArray(conditions) || conditions.length === 0) {
return {
valid: false,
error: `Invalid condition handle "${sourceHandle}" - no conditions defined`,
}
}
const validHandles = new Set<string>()
const semanticPrefix = `condition-${blockId}-`
let elseIfCount = 0
for (const condition of conditions) {
if (condition.id) {
validHandles.add(`condition-${condition.id}`)
}
const title = condition.title?.toLowerCase()
if (title === 'if') {
validHandles.add(`${semanticPrefix}if`)
} else if (title === 'else if') {
elseIfCount++
validHandles.add(
elseIfCount === 1 ? `${semanticPrefix}else-if` : `${semanticPrefix}else-if-${elseIfCount}`
)
} else if (title === 'else') {
validHandles.add(`${semanticPrefix}else`)
}
}
if (validHandles.has(sourceHandle)) {
return { valid: true }
}
const validOptions = Array.from(validHandles).slice(0, 5)
const moreCount = validHandles.size - validOptions.length
let validOptionsStr = validOptions.join(', ')
if (moreCount > 0) {
validOptionsStr += `, ... and ${moreCount} more`
}
return {
valid: false,
error: `Invalid condition handle "${sourceHandle}". Valid handles: ${validOptionsStr}`,
}
}
/**
* Validates target handle is valid (must be 'target')
*/
function validateTargetHandle(targetHandle: string): EdgeHandleValidationResult {
if (targetHandle === 'target') {
return { valid: true }
}
return {
valid: false,
error: `Invalid target handle "${targetHandle}". Expected "target"`,
}
}
/**
* Creates a validated edge between two blocks.
* Returns true if edge was created, false if skipped due to validation errors.
*/
function createValidatedEdge(
modifiedState: any,
sourceBlockId: string,
targetBlockId: string,
sourceHandle: string,
targetHandle: string,
operationType: string,
logger: ReturnType<typeof createLogger>,
skippedItems?: SkippedItem[]
): boolean {
if (!modifiedState.blocks[targetBlockId]) {
logger.warn(`Target block "${targetBlockId}" not found. Edge skipped.`, {
sourceBlockId,
targetBlockId,
sourceHandle,
})
skippedItems?.push({
type: 'invalid_edge_target',
operationType,
blockId: sourceBlockId,
reason: `Edge from "${sourceBlockId}" to "${targetBlockId}" skipped - target block does not exist`,
details: { sourceHandle, targetHandle, targetId: targetBlockId },
})
return false
}
const sourceBlock = modifiedState.blocks[sourceBlockId]
if (!sourceBlock) {
logger.warn(`Source block "${sourceBlockId}" not found. Edge skipped.`, {
sourceBlockId,
targetBlockId,
})
skippedItems?.push({
type: 'invalid_edge_source',
operationType,
blockId: sourceBlockId,
reason: `Edge from "${sourceBlockId}" to "${targetBlockId}" skipped - source block does not exist`,
details: { sourceHandle, targetHandle, targetId: targetBlockId },
})
return false
}
const sourceBlockType = sourceBlock.type
if (!sourceBlockType) {
logger.warn(`Source block "${sourceBlockId}" has no type. Edge skipped.`, {
sourceBlockId,
targetBlockId,
})
skippedItems?.push({
type: 'invalid_edge_source',
operationType,
blockId: sourceBlockId,
reason: `Edge from "${sourceBlockId}" to "${targetBlockId}" skipped - source block has no type`,
details: { sourceHandle, targetHandle, targetId: targetBlockId },
})
return false
}
const sourceValidation = validateSourceHandleForBlock(sourceHandle, sourceBlockType, sourceBlock)
if (!sourceValidation.valid) {
logger.warn(`Invalid source handle. Edge skipped.`, {
sourceBlockId,
targetBlockId,
sourceHandle,
error: sourceValidation.error,
})
skippedItems?.push({
type: 'invalid_source_handle',
operationType,
blockId: sourceBlockId,
reason: sourceValidation.error || `Invalid source handle "${sourceHandle}"`,
details: { sourceHandle, targetHandle, targetId: targetBlockId },
})
return false
}
const targetValidation = validateTargetHandle(targetHandle)
if (!targetValidation.valid) {
logger.warn(`Invalid target handle. Edge skipped.`, {
sourceBlockId,
targetBlockId,
targetHandle,
error: targetValidation.error,
})
skippedItems?.push({
type: 'invalid_target_handle',
operationType,
blockId: sourceBlockId,
reason: targetValidation.error || `Invalid target handle "${targetHandle}"`,
details: { sourceHandle, targetHandle, targetId: targetBlockId },
})
return false
}
modifiedState.edges.push({
id: crypto.randomUUID(),
source: sourceBlockId,
sourceHandle,
target: targetBlockId,
targetHandle,
type: 'default',
})
return true
}
/**
* Adds connections as edges for a block
*/
function addConnectionsAsEdges(
modifiedState: any,
@@ -747,34 +1020,16 @@ function addConnectionsAsEdges(
Object.entries(connections).forEach(([sourceHandle, targets]) => {
const targetArray = Array.isArray(targets) ? targets : [targets]
targetArray.forEach((targetId: string) => {
// Validate target block exists - skip edge if target doesn't exist
if (!modifiedState.blocks[targetId]) {
logger.warn(
`Target block "${targetId}" not found when creating connection from "${blockId}". ` +
`Edge skipped.`,
{
sourceBlockId: blockId,
targetBlockId: targetId,
existingBlocks: Object.keys(modifiedState.blocks),
}
)
skippedItems?.push({
type: 'invalid_edge_target',
operationType: 'add_edge',
blockId: blockId,
reason: `Edge from "${blockId}" to "${targetId}" skipped - target block does not exist`,
details: { sourceHandle, targetId },
})
return
}
modifiedState.edges.push({
id: crypto.randomUUID(),
source: blockId,
createValidatedEdge(
modifiedState,
blockId,
targetId,
sourceHandle,
target: targetId,
targetHandle: 'target',
type: 'default',
})
'target',
'add_edge',
logger,
skippedItems
)
})
})
}
@@ -1257,67 +1512,44 @@ function applyOperationsToWorkflowState(
// Handle connections update (convert to edges)
if (params?.connections) {
// Remove existing edges from this block
modifiedState.edges = modifiedState.edges.filter((edge: any) => edge.source !== block_id)
// Add new edges based on connections
Object.entries(params.connections).forEach(([connectionType, targets]) => {
if (targets === null) return
// Map semantic connection names to actual React Flow handle IDs
// 'success' in YAML/connections maps to 'source' handle in React Flow
const mapConnectionTypeToHandle = (type: string): string => {
if (type === 'success') return 'source'
if (type === 'error') return 'error'
// Conditions and other types pass through as-is
return type
}
const actualSourceHandle = mapConnectionTypeToHandle(connectionType)
const sourceHandle = mapConnectionTypeToHandle(connectionType)
const addEdge = (targetBlock: string, targetHandle?: string) => {
// Validate target block exists - skip edge if target doesn't exist
if (!modifiedState.blocks[targetBlock]) {
logger.warn(
`Target block "${targetBlock}" not found when creating connection from "${block_id}". ` +
`Edge skipped.`,
{
sourceBlockId: block_id,
targetBlockId: targetBlock,
existingBlocks: Object.keys(modifiedState.blocks),
}
)
logSkippedItem(skippedItems, {
type: 'invalid_edge_target',
operationType: 'edit',
blockId: block_id,
reason: `Edge from "${block_id}" to "${targetBlock}" skipped - target block does not exist`,
details: { sourceHandle: actualSourceHandle, targetId: targetBlock },
})
return
}
modifiedState.edges.push({
id: crypto.randomUUID(),
source: block_id,
sourceHandle: actualSourceHandle,
target: targetBlock,
targetHandle: targetHandle || 'target',
type: 'default',
})
const addEdgeForTarget = (targetBlock: string, targetHandle?: string) => {
createValidatedEdge(
modifiedState,
block_id,
targetBlock,
sourceHandle,
targetHandle || 'target',
'edit',
logger,
skippedItems
)
}
if (typeof targets === 'string') {
addEdge(targets)
addEdgeForTarget(targets)
} else if (Array.isArray(targets)) {
targets.forEach((target: any) => {
if (typeof target === 'string') {
addEdge(target)
addEdgeForTarget(target)
} else if (target?.block) {
addEdge(target.block, target.handle)
addEdgeForTarget(target.block, target.handle)
}
})
} else if (typeof targets === 'object' && (targets as any)?.block) {
addEdge((targets as any).block, (targets as any).handle)
addEdgeForTarget((targets as any).block, (targets as any).handle)
}
})
}

View File

@@ -138,6 +138,7 @@ export const env = createEnv({
S3_CHAT_BUCKET_NAME: z.string().optional(), // S3 bucket for chat logos
S3_COPILOT_BUCKET_NAME: z.string().optional(), // S3 bucket for copilot files
S3_PROFILE_PICTURES_BUCKET_NAME: z.string().optional(), // S3 bucket for profile pictures
S3_OG_IMAGES_BUCKET_NAME: z.string().optional(), // S3 bucket for OpenGraph images
// Cloud Storage - Azure Blob
AZURE_ACCOUNT_NAME: z.string().optional(), // Azure storage account name
@@ -149,6 +150,7 @@ export const env = createEnv({
AZURE_STORAGE_CHAT_CONTAINER_NAME: z.string().optional(), // Azure container for chat logos
AZURE_STORAGE_COPILOT_CONTAINER_NAME: z.string().optional(), // Azure container for copilot files
AZURE_STORAGE_PROFILE_PICTURES_CONTAINER_NAME: z.string().optional(), // Azure container for profile pictures
AZURE_STORAGE_OG_IMAGES_CONTAINER_NAME: z.string().optional(), // Azure container for OpenGraph images
// Data Retention
FREE_PLAN_LOG_RETENTION_DAYS: z.string().optional(), // Log retention days for free plan users

View File

@@ -37,8 +37,28 @@ export const isEmailVerificationEnabled = isTruthy(env.EMAIL_VERIFICATION_ENABLE
/**
* Is authentication disabled (for self-hosted deployments behind private networks)
* This flag is blocked when isHosted is true.
*/
export const isAuthDisabled = isTruthy(env.DISABLE_AUTH)
export const isAuthDisabled = isTruthy(env.DISABLE_AUTH) && !isHosted
if (isTruthy(env.DISABLE_AUTH)) {
import('@/lib/logs/console/logger')
.then(({ createLogger }) => {
const logger = createLogger('FeatureFlags')
if (isHosted) {
logger.error(
'DISABLE_AUTH is set but ignored on hosted environment. Authentication remains enabled for security.'
)
} else {
logger.warn(
'DISABLE_AUTH is enabled. Authentication is bypassed and all requests use an anonymous session. Only use this in trusted private networks.'
)
}
})
.catch(() => {
// Fallback during config compilation when logger is unavailable
})
}
/**
* Is user registration disabled

View File

@@ -1,4 +1,5 @@
import { env, getEnv } from '../config/env'
import { isDev } from '../config/feature-flags'
/**
* Content Security Policy (CSP) configuration builder
@@ -79,10 +80,16 @@ export const buildTimeCSPDirectives: CSPDirectives = {
'connect-src': [
"'self'",
env.NEXT_PUBLIC_APP_URL || '',
env.OLLAMA_URL || 'http://localhost:11434',
env.NEXT_PUBLIC_SOCKET_URL || 'http://localhost:3002',
env.NEXT_PUBLIC_SOCKET_URL?.replace('http://', 'ws://').replace('https://', 'wss://') ||
'ws://localhost:3002',
// Only include localhost fallbacks in development mode
...(env.OLLAMA_URL ? [env.OLLAMA_URL] : isDev ? ['http://localhost:11434'] : []),
...(env.NEXT_PUBLIC_SOCKET_URL
? [
env.NEXT_PUBLIC_SOCKET_URL,
env.NEXT_PUBLIC_SOCKET_URL.replace('http://', 'ws://').replace('https://', 'wss://'),
]
: isDev
? ['http://localhost:3002', 'ws://localhost:3002']
: []),
'https://api.browser-use.com',
'https://api.exa.ai',
'https://api.firecrawl.dev',
@@ -128,11 +135,16 @@ export function buildCSPString(directives: CSPDirectives): string {
* This maintains compatibility with existing inline scripts while fixing Docker env var issues
*/
export function generateRuntimeCSP(): string {
const socketUrl = getEnv('NEXT_PUBLIC_SOCKET_URL') || 'http://localhost:3002'
const socketWsUrl =
socketUrl.replace('http://', 'ws://').replace('https://', 'wss://') || 'ws://localhost:3002'
const appUrl = getEnv('NEXT_PUBLIC_APP_URL') || ''
const ollamaUrl = getEnv('OLLAMA_URL') || 'http://localhost:11434'
// Only include localhost URLs in development or when explicitly configured
const socketUrl = getEnv('NEXT_PUBLIC_SOCKET_URL') || (isDev ? 'http://localhost:3002' : '')
const socketWsUrl = socketUrl
? socketUrl.replace('http://', 'ws://').replace('https://', 'wss://')
: isDev
? 'ws://localhost:3002'
: ''
const ollamaUrl = getEnv('OLLAMA_URL') || (isDev ? 'http://localhost:11434' : '')
const brandLogoDomains = getHostnameFromUrl(getEnv('NEXT_PUBLIC_BRAND_LOGO_URL'))
const brandFaviconDomains = getHostnameFromUrl(getEnv('NEXT_PUBLIC_BRAND_FAVICON_URL'))

View File

@@ -958,3 +958,112 @@ export function createPinnedUrl(originalUrl: string, resolvedIP: string): string
const port = parsed.port ? `:${parsed.port}` : ''
return `${parsed.protocol}//${resolvedIP}${port}${parsed.pathname}${parsed.search}`
}
/**
* Validates a Google Calendar ID
*
* Google Calendar IDs can be:
* - "primary" (literal string for the user's primary calendar)
* - Email addresses (for user calendars)
* - Alphanumeric strings with hyphens, underscores, and dots (for other calendars)
*
* This validator allows these legitimate formats while blocking path traversal and injection attempts.
*
* @param value - The calendar ID to validate
* @param paramName - Name of the parameter for error messages
* @returns ValidationResult
*
* @example
* ```typescript
* const result = validateGoogleCalendarId(calendarId, 'calendarId')
* if (!result.isValid) {
* return NextResponse.json({ error: result.error }, { status: 400 })
* }
* ```
*/
export function validateGoogleCalendarId(
value: string | null | undefined,
paramName = 'calendarId'
): ValidationResult {
if (value === null || value === undefined || value === '') {
return {
isValid: false,
error: `${paramName} is required`,
}
}
if (value === 'primary') {
return { isValid: true, sanitized: value }
}
const pathTraversalPatterns = [
'../',
'..\\',
'%2e%2e%2f',
'%2e%2e/',
'..%2f',
'%2e%2e%5c',
'%2e%2e\\',
'..%5c',
'%252e%252e%252f',
]
const lowerValue = value.toLowerCase()
for (const pattern of pathTraversalPatterns) {
if (lowerValue.includes(pattern)) {
logger.warn('Path traversal attempt in Google Calendar ID', {
paramName,
value: value.substring(0, 100),
})
return {
isValid: false,
error: `${paramName} contains invalid path traversal sequence`,
}
}
}
if (/[\x00-\x1f\x7f]/.test(value) || value.includes('%00')) {
logger.warn('Control characters in Google Calendar ID', { paramName })
return {
isValid: false,
error: `${paramName} contains invalid control characters`,
}
}
if (value.includes('\n') || value.includes('\r')) {
return {
isValid: false,
error: `${paramName} contains invalid newline characters`,
}
}
const emailPattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
if (emailPattern.test(value)) {
return { isValid: true, sanitized: value }
}
const calendarIdPattern = /^[a-zA-Z0-9._@%#+-]+$/
if (!calendarIdPattern.test(value)) {
logger.warn('Invalid Google Calendar ID format', {
paramName,
value: value.substring(0, 100),
})
return {
isValid: false,
error: `${paramName} format is invalid. Must be "primary", an email address, or an alphanumeric ID`,
}
}
if (value.length > 255) {
logger.warn('Google Calendar ID exceeds maximum length', {
paramName,
length: value.length,
})
return {
isValid: false,
error: `${paramName} exceeds maximum length of 255 characters`,
}
}
return { isValid: true, sanitized: value }
}

View File

@@ -31,20 +31,25 @@ vi.mock('crypto', () => ({
}),
}))
vi.mock('@/lib/core/config/env', () => ({
env: {
ENCRYPTION_KEY: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
OPENAI_API_KEY_1: 'test-openai-key-1',
OPENAI_API_KEY_2: 'test-openai-key-2',
OPENAI_API_KEY_3: 'test-openai-key-3',
ANTHROPIC_API_KEY_1: 'test-anthropic-key-1',
ANTHROPIC_API_KEY_2: 'test-anthropic-key-2',
ANTHROPIC_API_KEY_3: 'test-anthropic-key-3',
GEMINI_API_KEY_1: 'test-gemini-key-1',
GEMINI_API_KEY_2: 'test-gemini-key-2',
GEMINI_API_KEY_3: 'test-gemini-key-3',
},
}))
vi.mock('@/lib/core/config/env', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/core/config/env')>()
return {
...actual,
env: {
...actual.env,
ENCRYPTION_KEY: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', // fake key for testing
OPENAI_API_KEY_1: 'test-openai-key-1', // fake key for testing
OPENAI_API_KEY_2: 'test-openai-key-2', // fake key for testing
OPENAI_API_KEY_3: 'test-openai-key-3', // fake key for testing
ANTHROPIC_API_KEY_1: 'test-anthropic-key-1', // fake key for testing
ANTHROPIC_API_KEY_2: 'test-anthropic-key-2', // fake key for testing
ANTHROPIC_API_KEY_3: 'test-anthropic-key-3', // fake key for testing
GEMINI_API_KEY_1: 'test-gemini-key-1', // fake key for testing
GEMINI_API_KEY_2: 'test-gemini-key-2', // fake key for testing
GEMINI_API_KEY_3: 'test-gemini-key-3', // fake key for testing
},
}
})
afterEach(() => {
vi.clearAllMocks()

Some files were not shown because too many files have changed in this diff Show More