Compare commits

..

40 Commits

Author SHA1 Message Date
Waleed
ab4e9dc72f v0.5.73: ci, helm updates, kb, ui fixes, note block enhancements 2026-01-26 22:04:35 -08:00
Vikhyath Mondreti
1c58c35bd8 v0.5.72: azure connection string, supabase improvement, multitrigger resolution, docs quick reference 2026-01-25 23:42:27 -08:00
Waleed
d63a5cb504 v0.5.71: ux, ci improvements, docs updates 2026-01-25 03:08:08 -08:00
Waleed
8bd5d41723 v0.5.70: router fix, anthropic agent response format adherence 2026-01-24 20:57:02 -08:00
Waleed
c12931bc50 v0.5.69: kb upgrades, blog, copilot improvements, auth consolidation (#2973)
* fix(subflows): tag dropdown + resolution logic (#2949)

* fix(subflows): tag dropdown + resolution logic

* fixes;

* revert parallel change

* chore(deps): bump posthog-js to 1.334.1 (#2948)

* fix(idempotency): add conflict target to atomicallyClaimDb query + remove redundant db namespace tracking (#2950)

* fix(idempotency): add conflict target to atomicallyClaimDb query

* delete needs to account for namespace

* simplify namespace filtering logic

* fix cleanup

* consistent target

* improvement(kb): add document filtering, select all, and React Query migration (#2951)

* improvement(kb): add document filtering, select all, and React Query migration

* test(kb): update tests for enabledFilter and removed userId params

* fix(kb): remove non-null assertion, add explicit guard

* improvement(logs): trace span, details (#2952)

* improvement(action-bar): ordering

* improvement(logs): details, trace span

* feat(blog): v0.5 release post (#2953)

* feat(blog): v0.5 post

* improvement(blog): simplify title and remove code block header

- Simplified blog title from Introducing Sim Studio v0.5 to Introducing Sim v0.5
- Removed language label header and copy button from code blocks for cleaner appearance

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

* ack PR comments

* small styling improvements

* created system to create post-specific components

* updated componnet

* cache invalidation

---------

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

* feat(admin): add credits endpoint to issue credits to users (#2954)

* feat(admin): add credits endpoint to issue credits to users

* fix(admin): use existing credit functions and handle enterprise seats

* fix(admin): reject NaN and Infinity in amount validation

* styling

* fix(admin): validate userId and email are strings

* improvement(copilot): fast mode, subagent tool responses and allow preferences (#2955)

* Improvements

* Fix actions mapping

* Remove console logs

* fix(billing): handle missing userStats and prevent crashes (#2956)

* fix(billing): handle missing userStats and prevent crashes

* fix(billing): correct import path for getFilledPillColor

* fix(billing): add Number.isFinite check to lastPeriodCost

* fix(logs): refresh logic to refresh logs details (#2958)

* fix(security): add authentication and input validation to API routes (#2959)

* fix(security): add authentication and input validation to API routes

* moved utils

* remove extraneous commetns

* removed unused dep

* improvement(helm): add internal ingress support and same-host path consolidation (#2960)

* improvement(helm): add internal ingress support and same-host path consolidation

* improvement(helm): clean up ingress template comments

Simplify verbose inline Helm comments and section dividers to match the
minimal style used in services.yaml.

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

* fix(helm): add missing copilot path consolidation for realtime host

When copilot.host equals realtime.host but differs from app.host,
copilot paths were not being routed. Added logic to consolidate
copilot paths into the realtime rule for this scenario.

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

* improvement(helm): follow ingress best practices

- Remove orphan comments that appeared when services were disabled
- Add documentation about path ordering requirements
- Paths rendered in order: realtime, copilot, app (specific before catch-all)
- Clean template output matching industry Helm chart standards

---------

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

* feat(blog): enterprise post (#2961)

* feat(blog): enterprise post

* added more images, styling

* more content

* updated v0-5 post

* remove unused transition

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>

* fix(envvars): resolution standardized (#2957)

* fix(envvars): resolution standardized

* remove comments

* address bugbot

* fix highlighting for env vars

* remove comments

* address greptile

* address bugbot

* fix(copilot): mask credentials fix (#2963)

* Fix copilot masking

* Clean up

* Lint

* improvement(webhooks): remove dead code (#2965)

* fix(webhooks): subscription recreation path

* improvement(webhooks): remove dead code

* fix tests

* address bugbot comments

* fix restoration edge case

* fix more edge cases

* address bugbot comments

* fix gmail polling

* add warnings for UI indication for credential sets

* fix(preview): subblock values (#2969)

* fix(child-workflow): nested spans handoff (#2966)

* fix(child-workflow): nested spans handoff

* remove overly defensive programming

* update type check

* type more code

* remove more dead code

* address bugbot comments

* fix(security): restrict API key access on internal-only routes (#2964)

* fix(security): restrict API key access on internal-only routes

* test(security): update function execute tests for checkInternalAuth

* updated agent handler

* move session check higher in checkSessionOrInternalAuth

* extracted duplicate code into helper for resolving user from jwt

* fix(copilot): update copilot chat title (#2968)

* fix(hitl): fix condition blocks after hitl (#2967)

* fix(notes): ghost edges (#2970)

* fix(notes): ghost edges

* fix deployed state fallback

* fallback

* remove UI level checks

* annotation missing from autoconnect source check

* improvement(docs): loop and parallel var reference syntax (#2975)

* fix(blog): slash actions description (#2976)

* improvement(docs): loop and parallel var reference syntax

* fix(blog): slash actions description

* fix(auth): copilot routes (#2977)

* Fix copilot auth

* Fix

* Fix

* Fix

* fix(copilot): fix edit summary for loops/parallels (#2978)

* fix(integrations): hide from tool bar (#2544)

* fix(landing): ui (#2979)

* fix(edge-validation): race condition on collaborative add (#2980)

* fix(variables): boolean type support and input improvements (#2981)

* fix(variables): boolean type support and input improvements

* fix formatting

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2026-01-24 14:29:53 -08:00
Waleed
e9c4251c1c v0.5.68: router block reasoning, executor improvements, variable resolution consolidation, helm updates (#2946)
* improvement(workflow-item): stabilize avatar layout and fix name truncation (#2939)

* improvement(workflow-item): stabilize avatar layout and fix name truncation

* fix(avatars): revert overflow bg to hardcoded color for contrast

* fix(executor): stop parallel execution when block errors (#2940)

* improvement(helm): add per-deployment extraVolumes support (#2942)

* fix(gmail): expose messageId field in read email block (#2943)

* fix(resolver): consolidate reference resolution  (#2941)

* fix(resolver): consolidate code to resolve references

* fix edge cases

* use already formatted error

* fix multi index

* fix backwards compat reachability

* handle backwards compatibility accurately

* use shared constant correctly

* feat(router): expose reasoning output in router v2 block (#2945)

* fix(copilot): always allow, credential masking (#2947)

* Fix always allow, credential validation

* Credential masking

* Autoload

* fix(executor): handle condition dead-end branches in loops (#2944)

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
2026-01-22 13:48:15 -08:00
Waleed
cc2be33d6b v0.5.67: loading, password reset, ui improvements, helm updates (#2928)
* fix(zustand): updated to useShallow from deprecated createWithEqualityFn (#2919)

* fix(logger): use direct env access for webpack inlining (#2920)

* fix(notifications): text overflow with line-clamp (#2921)

* chore(helm): add env vars for Vertex AI, orgs, and telemetry (#2922)

* fix(auth): improve reset password flow and consolidate brand detection (#2924)

* fix(auth): improve reset password flow and consolidate brand detection

* fix(auth): set errorHandled for EMAIL_NOT_VERIFIED to prevent duplicate error

* fix(auth): clear success message on login errors

* chore(auth): fix import order per lint

* fix(action-bar): duplicate subflows with children (#2923)

* fix(action-bar): duplicate subflows with children

* fix(action-bar): add validateTriggerPaste for subflow duplicate

* fix(resolver): agent response format, input formats, root level (#2925)

* fix(resolvers): agent response format, input formats, root level

* fix response block initial seeding

* fix tests

* fix(messages-input): fix cursor alignment and auto-resize with overlay (#2926)

* fix(messages-input): fix cursor alignment and auto-resize with overlay

* fixed remaining zustand warnings

* fix(stores): remove dead code causing log spam on startup (#2927)

* fix(stores): remove dead code causing log spam on startup

* fix(stores): replace custom tools zustand store with react query cache

* improvement(ui): use BrandedButton and BrandedLink components (#2930)

- Refactor auth forms to use BrandedButton component
- Add BrandedLink component for changelog page
- Reduce code duplication in login, signup, reset-password forms
- Update star count default value

* fix(custom-tools): remove unsafe title fallback in getCustomTool (#2929)

* fix(custom-tools): remove unsafe title fallback in getCustomTool

* fix(custom-tools): restore title fallback in getCustomTool lookup

Custom tools are referenced by title (custom_${title}), not database ID.
The title fallback is required for client-side tool resolution to work.

* fix(null-bodies): empty bodies handling (#2931)

* fix(null-statuses): empty bodies handling

* address bugbot comment

* fix(token-refresh): microsoft, notion, x, linear (#2933)

* fix(microsoft): proactive refresh needed

* fix(x): missing token refresh flag

* notion and linear missing flag too

* address bugbot comment

* fix(auth): handle EMAIL_NOT_VERIFIED in onError callback (#2932)

* fix(auth): handle EMAIL_NOT_VERIFIED in onError callback

* refactor(auth): extract redirectToVerify helper to reduce duplication

* fix(workflow-selector): use dedicated selector for workflow dropdown (#2934)

* feat(workflow-block): preview (#2935)

* improvement(copilot): tool configs to show nested props (#2936)

* fix(auth): add genericOAuth providers to trustedProviders (#2937)

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
2026-01-21 22:53:25 -08:00
Vikhyath Mondreti
45371e521e v0.5.66: external http requests fix, ring highlighting 2026-01-21 02:55:39 -08:00
Waleed
0ce0f98aa5 v0.5.65: gemini updates, textract integration, ui updates (#2909)
* fix(google): wrap primitive tool responses for Gemini API compatibility (#2900)

* fix(canonical): copilot path + update parent (#2901)

* fix(rss): add top-level title, link, pubDate fields to RSS trigger output (#2902)

* fix(rss): add top-level title, link, pubDate fields to RSS trigger output

* fix(imap): add top-level fields to IMAP trigger output

* improvement(browseruse): add profile id param (#2903)

* improvement(browseruse): add profile id param

* make request a stub since we have directExec

* improvement(executor): upgraded abort controller to handle aborts for loops and parallels (#2880)

* improvement(executor): upgraded abort controller to handle aborts for loops and parallels

* comments

* improvement(files): update execution for passing base64 strings (#2906)

* progress

* improvement(execution): update execution for passing base64 strings

* fix types

* cleanup comments

* path security vuln

* reject promise correctly

* fix redirect case

* remove proxy routes

* fix tests

* use ipaddr

* feat(tools): added textract, added v2 for mistral, updated tag dropdown (#2904)

* feat(tools): added textract

* cleanup

* ack pr comments

* reorder

* removed upload for textract async version

* fix additional fields dropdown in editor, update parser to leave validation to be done on the server

* added mistral v2, files v2, and finalized textract

* updated the rest of the old file patterns, updated mistral outputs for v2

* updated tag dropdown to parse non-operation fields as well

* updated extension finder

* cleanup

* added description for inputs to workflow

* use helper for internal route check

* fix tag dropdown merge conflict change

* remove duplicate code

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>

* fix(ui): change add inputs button to match output selector (#2907)

* fix(canvas): removed invite to workspace from canvas popover (#2908)

* fix(canvas): removed invite to workspace

* removed unused props

* fix(copilot): legacy tool display names (#2911)

* fix(a2a): canonical merge  (#2912)

* fix canonical merge

* fix empty array case

* fix(change-detection): copilot diffs have extra field (#2913)

* improvement(logs): improved logs ui bugs, added subflow disable UI (#2910)

* improvement(logs): improved logs ui bugs, added subflow disable UI

* added duplicate to action bar for subflows

* feat(broadcast): email v0.5 (#2905)

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
2026-01-20 23:54:55 -08:00
Waleed
dff1c9d083 v0.5.64: unsubscribe, search improvements, metrics, additional SSO configuration 2026-01-20 00:34:11 -08:00
Vikhyath Mondreti
b09f683072 v0.5.63: ui and performance improvements, more google tools 2026-01-18 15:22:42 -08:00
Vikhyath Mondreti
a8bb0db660 v0.5.62: webhook bug fixes, seeding default subblock values, block selection fixes 2026-01-16 20:27:06 -08:00
Waleed
af82820a28 v0.5.61: webhook improvements, workflow controls, react query for deployment status, chat fixes, reducto and pulse OCR, linear fixes 2026-01-16 18:06:23 -08:00
Waleed
4372841797 v0.5.60: invitation flow improvements, chat fixes, a2a improvements, additional copilot actions 2026-01-15 00:02:18 -08:00
Waleed
5e8c843241 v0.5.59: a2a support, documentation 2026-01-13 13:21:21 -08:00
Waleed
7bf3d73ee6 v0.5.58: export folders, new tools, permissions groups enhancements 2026-01-13 00:56:59 -08:00
Vikhyath Mondreti
7ffc11a738 v0.5.57: subagents, context menu improvements, bug fixes 2026-01-11 11:38:40 -08:00
Waleed
be578e2ed7 v0.5.56: batch operations, access control and permission groups, billing fixes 2026-01-10 00:31:34 -08:00
Waleed
f415e5edc4 v0.5.55: polling groups, bedrock provider, devcontainer fixes, workflow preview enhancements 2026-01-08 23:36:56 -08:00
Waleed
13a6e6c3fa v0.5.54: seo, model blacklist, helm chart updates, fireflies integration, autoconnect improvements, billing fixes 2026-01-07 16:09:45 -08:00
Waleed
f5ab7f21ae v0.5.53: hotkey improvements, added redis fallback, fixes for workflow tool 2026-01-06 23:34:52 -08:00
Waleed
bfb6fffe38 v0.5.52: new port-based router block, combobox expression and variable support 2026-01-06 16:14:10 -08:00
Waleed
4fbec0a43f v0.5.51: triggers, kb, condition block improvements, supabase and grain integration updates 2026-01-06 14:26:46 -08:00
Waleed
585f5e365b v0.5.50: import improvements, ui upgrades, kb styling and performance improvements 2026-01-05 00:35:55 -08:00
Waleed
3792bdd252 v0.5.49: hitl improvements, new email styles, imap trigger, logs context menu (#2672)
* feat(logs-context-menu): consolidated logs utils and types, added logs record context menu (#2659)

* feat(email): welcome email; improvement(emails): ui/ux (#2658)

* feat(email): welcome email; improvement(emails): ui/ux

* improvement(emails): links, accounts, preview

* refactor(emails): file structure and wrapper components

* added envvar for personal emails sent, added isHosted gate

* fixed failing tests, added env mock

* fix: removed comment

---------

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

* fix(logging): hitl + trigger dev crash protection (#2664)

* hitl gaps

* deal with trigger worker crashes

* cleanup import strcuture

* feat(imap): added support for imap trigger (#2663)

* feat(tools): added support for imap trigger

* feat(imap): added parity, tested

* ack PR comments

* final cleanup

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

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

* fix(grain): updated grain trigger to auto-establish trigger (#2666)

Co-authored-by: aadamgough <adam@sim.ai>

* feat(admin): routes to manage deployments (#2667)

* feat(admin): routes to manage deployments

* fix naming fo deployed by

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date (#2668)

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date

* removed unused params, cleaned up redundant utils

* improvement(invite): aligned styling (#2669)

* improvement(invite): aligned with rest of app

* fix(invite): error handling

* fix: addressed comments

---------

Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>
Co-authored-by: Adam Gough <77861281+aadamgough@users.noreply.github.com>
Co-authored-by: aadamgough <adam@sim.ai>
2026-01-03 13:19:18 -08:00
Waleed
eb5d1f3e5b v0.5.48: copy-paste workflow blocks, docs updates, mcp tool fixes 2025-12-31 18:00:04 -08:00
Waleed
54ab82c8dd v0.5.47: deploy workflow as mcp, kb chunks tokenizer, UI improvements, jira service management tools 2025-12-30 23:18:58 -08:00
Waleed
f895bf469b v0.5.46: build improvements, greptile, light mode improvements 2025-12-29 02:17:52 -08:00
Waleed
dd3209af06 v0.5.45: light mode fixes, realtime usage indicator, docker build improvements 2025-12-27 19:57:42 -08:00
Waleed
b6ba3b50a7 v0.5.44: keyboard shortcuts, autolayout, light mode, byok, testing improvements 2025-12-26 21:25:19 -08:00
Waleed
b304233062 v0.5.43: export logs, circleback, grain, vertex, code hygiene, schedule improvements 2025-12-23 19:19:18 -08:00
Vikhyath Mondreti
57e4b49bd6 v0.5.42: fix memory migration 2025-12-23 01:24:54 -08:00
Vikhyath Mondreti
e12dd204ed v0.5.41: memory fixes, copilot improvements, knowledgebase improvements, LLM providers standardization 2025-12-23 00:15:18 -08:00
Vikhyath Mondreti
3d9d9cbc54 v0.5.40: supabase ops to allow non-public schemas, jira uuid 2025-12-21 22:28:05 -08:00
Waleed
0f4ec962ad v0.5.39: notion, workflow variables fixes 2025-12-20 20:44:00 -08:00
Waleed
4827866f9a v0.5.38: snap to grid, copilot ux improvements, billing line items 2025-12-20 17:24:38 -08:00
Waleed
3e697d9ed9 v0.5.37: redaction utils consolidation, logs updates, autoconnect improvements, additional kb tag types 2025-12-19 22:31:55 -08:00
Martin Yankov
4431a1a484 fix(helm): add custom egress rules to realtime network policy (#2481)
The realtime service network policy was missing the custom egress rules section
that allows configuration of additional egress rules via values.yaml. This caused
the realtime pods to be unable to connect to external databases (e.g., PostgreSQL
on port 5432) when using external database configurations.

The app network policy already had this section, but the realtime network policy
was missing it, creating an inconsistency and preventing the realtime service
from accessing external databases configured via networkPolicy.egress values.

This fix adds the same custom egress rules template section to the realtime
network policy, matching the app network policy behavior and allowing users to
configure database connectivity via values.yaml.
2025-12-19 18:59:08 -08:00
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
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
94 changed files with 793 additions and 1338 deletions

View File

@@ -10,7 +10,6 @@ describe('OAuth Token API Routes', () => {
const mockGetUserId = vi.fn()
const mockGetCredential = vi.fn()
const mockRefreshTokenIfNeeded = vi.fn()
const mockGetOAuthToken = vi.fn()
const mockAuthorizeCredentialUse = vi.fn()
const mockCheckHybridAuth = vi.fn()
@@ -30,7 +29,6 @@ describe('OAuth Token API Routes', () => {
getUserId: mockGetUserId,
getCredential: mockGetCredential,
refreshTokenIfNeeded: mockRefreshTokenIfNeeded,
getOAuthToken: mockGetOAuthToken,
}))
vi.doMock('@sim/logger', () => ({
@@ -232,140 +230,6 @@ describe('OAuth Token API Routes', () => {
expect(response.status).toBe(401)
expect(data).toHaveProperty('error', 'Failed to refresh access token')
})
describe('credentialAccountUserId + providerId path', () => {
it('should reject unauthenticated requests', async () => {
mockCheckHybridAuth.mockResolvedValueOnce({
success: false,
error: 'Authentication required',
})
const req = createMockRequest('POST', {
credentialAccountUserId: 'target-user-id',
providerId: 'google',
})
const { POST } = await import('@/app/api/auth/oauth/token/route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(401)
expect(data).toHaveProperty('error', 'User not authenticated')
expect(mockGetOAuthToken).not.toHaveBeenCalled()
})
it('should reject API key authentication', async () => {
mockCheckHybridAuth.mockResolvedValueOnce({
success: true,
authType: 'api_key',
userId: 'test-user-id',
})
const req = createMockRequest('POST', {
credentialAccountUserId: 'test-user-id',
providerId: 'google',
})
const { POST } = await import('@/app/api/auth/oauth/token/route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(401)
expect(data).toHaveProperty('error', 'User not authenticated')
expect(mockGetOAuthToken).not.toHaveBeenCalled()
})
it('should reject internal JWT authentication', async () => {
mockCheckHybridAuth.mockResolvedValueOnce({
success: true,
authType: 'internal_jwt',
userId: 'test-user-id',
})
const req = createMockRequest('POST', {
credentialAccountUserId: 'test-user-id',
providerId: 'google',
})
const { POST } = await import('@/app/api/auth/oauth/token/route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(401)
expect(data).toHaveProperty('error', 'User not authenticated')
expect(mockGetOAuthToken).not.toHaveBeenCalled()
})
it('should reject requests for other users credentials', async () => {
mockCheckHybridAuth.mockResolvedValueOnce({
success: true,
authType: 'session',
userId: 'attacker-user-id',
})
const req = createMockRequest('POST', {
credentialAccountUserId: 'victim-user-id',
providerId: 'google',
})
const { POST } = await import('@/app/api/auth/oauth/token/route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(403)
expect(data).toHaveProperty('error', 'Unauthorized')
expect(mockGetOAuthToken).not.toHaveBeenCalled()
})
it('should allow session-authenticated users to access their own credentials', async () => {
mockCheckHybridAuth.mockResolvedValueOnce({
success: true,
authType: 'session',
userId: 'test-user-id',
})
mockGetOAuthToken.mockResolvedValueOnce('valid-access-token')
const req = createMockRequest('POST', {
credentialAccountUserId: 'test-user-id',
providerId: 'google',
})
const { POST } = await import('@/app/api/auth/oauth/token/route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toHaveProperty('accessToken', 'valid-access-token')
expect(mockGetOAuthToken).toHaveBeenCalledWith('test-user-id', 'google')
})
it('should return 404 when credential not found for user', async () => {
mockCheckHybridAuth.mockResolvedValueOnce({
success: true,
authType: 'session',
userId: 'test-user-id',
})
mockGetOAuthToken.mockResolvedValueOnce(null)
const req = createMockRequest('POST', {
credentialAccountUserId: 'test-user-id',
providerId: 'nonexistent-provider',
})
const { POST } = await import('@/app/api/auth/oauth/token/route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(404)
expect(data.error).toContain('No credential found')
})
})
})
/**

View File

@@ -71,22 +71,6 @@ export async function POST(request: NextRequest) {
providerId,
})
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success || auth.authType !== 'session' || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized request for credentialAccountUserId path`, {
success: auth.success,
authType: auth.authType,
})
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}
if (auth.userId !== credentialAccountUserId) {
logger.warn(
`[${requestId}] User ${auth.userId} attempted to access credentials for ${credentialAccountUserId}`
)
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
try {
const accessToken = await getOAuthToken(credentialAccountUserId, providerId)
if (!accessToken) {

View File

@@ -26,9 +26,8 @@ vi.mock('@/serializer', () => ({
Serializer: vi.fn(),
}))
vi.mock('@/lib/workflows/subblocks', () => ({
mergeSubblockStateWithValues: vi.fn().mockReturnValue({}),
mergeSubBlockValues: vi.fn().mockReturnValue({}),
vi.mock('@/stores/workflows/server-utils', () => ({
mergeSubblockState: vi.fn().mockReturnValue({}),
}))
const mockDecryptSecret = vi.fn()

View File

@@ -1,19 +1,13 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { NextResponse } from 'next/server'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
export const dynamic = 'force-dynamic'
const logger = createLogger('AsanaAddCommentAPI')
export async function POST(request: NextRequest) {
export async function POST(request: Request) {
try {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { accessToken, taskGid, text } = await request.json()
if (!accessToken) {

View File

@@ -1,19 +1,13 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { NextResponse } from 'next/server'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
export const dynamic = 'force-dynamic'
const logger = createLogger('AsanaCreateTaskAPI')
export async function POST(request: NextRequest) {
export async function POST(request: Request) {
try {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { accessToken, workspace, name, notes, assignee, due_on } = await request.json()
if (!accessToken) {

View File

@@ -1,19 +1,13 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { NextResponse } from 'next/server'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
export const dynamic = 'force-dynamic'
const logger = createLogger('AsanaGetProjectsAPI')
export async function POST(request: NextRequest) {
export async function POST(request: Request) {
try {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { accessToken, workspace } = await request.json()
if (!accessToken) {

View File

@@ -1,19 +1,13 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { NextResponse } from 'next/server'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
export const dynamic = 'force-dynamic'
const logger = createLogger('AsanaGetTaskAPI')
export async function POST(request: NextRequest) {
export async function POST(request: Request) {
try {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { accessToken, taskGid, workspace, project, limit } = await request.json()
if (!accessToken) {

View File

@@ -1,19 +1,13 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { NextResponse } from 'next/server'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
export const dynamic = 'force-dynamic'
const logger = createLogger('AsanaSearchTasksAPI')
export async function POST(request: NextRequest) {
export async function POST(request: Request) {
try {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { accessToken, workspace, text, assignee, projects, completed } = await request.json()
if (!accessToken) {

View File

@@ -1,19 +1,13 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { NextResponse } from 'next/server'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
export const dynamic = 'force-dynamic'
const logger = createLogger('AsanaUpdateTaskAPI')
export async function PUT(request: NextRequest) {
export async function PUT(request: Request) {
try {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { accessToken, taskGid, name, notes, assignee, completed, due_on } = await request.json()
if (!accessToken) {

View File

@@ -1,6 +1,5 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { NextResponse } from 'next/server'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
@@ -9,13 +8,8 @@ const logger = createLogger('ConfluenceAttachmentAPI')
export const dynamic = 'force-dynamic'
// Delete an attachment
export async function DELETE(request: NextRequest) {
export async function DELETE(request: Request) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { domain, accessToken, cloudId: providedCloudId, attachmentId } = await request.json()
if (!domain) {

View File

@@ -1,6 +1,5 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { NextResponse } from 'next/server'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
@@ -9,13 +8,8 @@ const logger = createLogger('ConfluenceAttachmentsAPI')
export const dynamic = 'force-dynamic'
// List attachments on a page
export async function GET(request: NextRequest) {
export async function GET(request: Request) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const domain = searchParams.get('domain')
const accessToken = searchParams.get('accessToken')

View File

@@ -1,7 +1,6 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
@@ -47,13 +46,8 @@ const deleteCommentSchema = z
)
// Update a comment
export async function PUT(request: NextRequest) {
export async function PUT(request: Request) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const validation = putCommentSchema.safeParse(body)
@@ -134,13 +128,8 @@ export async function PUT(request: NextRequest) {
}
// Delete a comment
export async function DELETE(request: NextRequest) {
export async function DELETE(request: Request) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const validation = deleteCommentSchema.safeParse(body)

View File

@@ -1,6 +1,5 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { NextResponse } from 'next/server'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
@@ -9,13 +8,8 @@ const logger = createLogger('ConfluenceCommentsAPI')
export const dynamic = 'force-dynamic'
// Create a comment
export async function POST(request: NextRequest) {
export async function POST(request: Request) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { domain, accessToken, cloudId: providedCloudId, pageId, comment } = await request.json()
if (!domain) {
@@ -92,13 +86,8 @@ export async function POST(request: NextRequest) {
}
// List comments on a page
export async function GET(request: NextRequest) {
export async function GET(request: Request) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const domain = searchParams.get('domain')
const accessToken = searchParams.get('accessToken')

View File

@@ -1,6 +1,5 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { NextResponse } from 'next/server'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
@@ -8,13 +7,8 @@ const logger = createLogger('ConfluenceCreatePageAPI')
export const dynamic = 'force-dynamic'
export async function POST(request: NextRequest) {
export async function POST(request: Request) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const {
domain,
accessToken,

View File

@@ -1,6 +1,5 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { NextResponse } from 'next/server'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
@@ -9,13 +8,8 @@ const logger = createLogger('ConfluenceLabelsAPI')
export const dynamic = 'force-dynamic'
// Add a label to a page
export async function POST(request: NextRequest) {
export async function POST(request: Request) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const {
domain,
accessToken,
@@ -93,13 +87,8 @@ export async function POST(request: NextRequest) {
}
// List labels on a page
export async function GET(request: NextRequest) {
export async function GET(request: Request) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const domain = searchParams.get('domain')
const accessToken = searchParams.get('accessToken')

View File

@@ -1,7 +1,6 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
@@ -74,13 +73,8 @@ const deletePageSchema = z
}
)
export async function POST(request: NextRequest) {
export async function POST(request: Request) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const validation = postPageSchema.safeParse(body)
@@ -150,13 +144,8 @@ export async function POST(request: NextRequest) {
}
}
export async function PUT(request: NextRequest) {
export async function PUT(request: Request) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const validation = putPageSchema.safeParse(body)
@@ -259,13 +248,8 @@ export async function PUT(request: NextRequest) {
}
}
export async function DELETE(request: NextRequest) {
export async function DELETE(request: Request) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const validation = deletePageSchema.safeParse(body)

View File

@@ -1,6 +1,5 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { NextResponse } from 'next/server'
import { validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
@@ -9,13 +8,8 @@ const logger = createLogger('ConfluencePagesAPI')
export const dynamic = 'force-dynamic'
// List pages or search pages
export async function POST(request: NextRequest) {
export async function POST(request: Request) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const {
domain,
accessToken,

View File

@@ -1,6 +1,5 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { NextResponse } from 'next/server'
import { validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
@@ -8,13 +7,8 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('Confluence Search')
export async function POST(request: NextRequest) {
export async function POST(request: Request) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const {
domain,
accessToken,

View File

@@ -1,6 +1,5 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { NextResponse } from 'next/server'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
@@ -9,13 +8,8 @@ const logger = createLogger('ConfluenceSpaceAPI')
export const dynamic = 'force-dynamic'
// Get a specific space
export async function GET(request: NextRequest) {
export async function GET(request: Request) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const domain = searchParams.get('domain')
const accessToken = searchParams.get('accessToken')

View File

@@ -1,6 +1,5 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { NextResponse } from 'next/server'
import { validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
@@ -9,13 +8,8 @@ const logger = createLogger('ConfluenceSpacesAPI')
export const dynamic = 'force-dynamic'
// List all spaces
export async function GET(request: NextRequest) {
export async function GET(request: Request) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const domain = searchParams.get('domain')
const accessToken = searchParams.get('accessToken')

View File

@@ -1,6 +1,5 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
@@ -12,11 +11,6 @@ export const dynamic = 'force-dynamic'
export async function POST(request: NextRequest) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { domain, accessToken, cloudId: providedCloudId, pageId, file, fileName, comment } = body

View File

@@ -1,6 +1,5 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { NextResponse } from 'next/server'
import { validateNumericId } from '@/lib/core/security/input-validation'
interface DiscordChannel {
@@ -14,12 +13,7 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('DiscordChannelsAPI')
export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
export async function POST(request: Request) {
try {
const { botToken, serverId, channelId } = await request.json()

View File

@@ -1,6 +1,5 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { NextResponse } from 'next/server'
import { validateNumericId } from '@/lib/core/security/input-validation'
interface DiscordServer {
@@ -13,12 +12,7 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('DiscordServersAPI')
export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
export async function POST(request: Request) {
try {
const { botToken, serverId } = await request.json()

View File

@@ -1,7 +1,6 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -16,11 +15,6 @@ export async function GET(request: NextRequest) {
const requestId = generateRequestId()
logger.info(`[${requestId}] Google Drive file request received`)
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try {
const { searchParams } = new URL(request.url)
const credentialId = searchParams.get('credentialId')

View File

@@ -1,7 +1,7 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -73,12 +73,14 @@ export async function GET(request: NextRequest) {
const requestId = generateRequestId()
logger.info(`[${requestId}] Google Drive files request received`)
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthenticated request rejected`)
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const credentialId = searchParams.get('credentialId')
const mimeType = searchParams.get('mimeType')

View File

@@ -1,6 +1,5 @@
import { type NextRequest, NextResponse } from 'next/server'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createDynamoDBClient, deleteItem } from '@/app/api/tools/dynamodb/utils'
const DeleteSchema = z.object({
@@ -14,13 +13,8 @@ const DeleteSchema = z.object({
conditionExpression: z.string().optional(),
})
export async function POST(request: NextRequest) {
export async function POST(request: Request) {
try {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const validatedData = DeleteSchema.parse(body)

View File

@@ -1,6 +1,5 @@
import { type NextRequest, NextResponse } from 'next/server'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createDynamoDBClient, getItem } from '@/app/api/tools/dynamodb/utils'
const GetSchema = z.object({
@@ -20,13 +19,8 @@ const GetSchema = z.object({
}),
})
export async function POST(request: NextRequest) {
export async function POST(request: Request) {
try {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const validatedData = GetSchema.parse(body)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createRawDynamoDBClient, describeTable, listTables } from '@/app/api/tools/dynamodb/utils'
const logger = createLogger('DynamoDBIntrospectAPI')
@@ -18,11 +17,6 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = IntrospectSchema.parse(body)

View File

@@ -1,6 +1,5 @@
import { type NextRequest, NextResponse } from 'next/server'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createDynamoDBClient, putItem } from '@/app/api/tools/dynamodb/utils'
const PutSchema = z.object({
@@ -13,13 +12,8 @@ const PutSchema = z.object({
}),
})
export async function POST(request: NextRequest) {
export async function POST(request: Request) {
try {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const validatedData = PutSchema.parse(body)

View File

@@ -1,6 +1,5 @@
import { type NextRequest, NextResponse } from 'next/server'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createDynamoDBClient, queryItems } from '@/app/api/tools/dynamodb/utils'
const QuerySchema = z.object({
@@ -16,13 +15,8 @@ const QuerySchema = z.object({
limit: z.number().positive().optional(),
})
export async function POST(request: NextRequest) {
export async function POST(request: Request) {
try {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const validatedData = QuerySchema.parse(body)

View File

@@ -1,6 +1,5 @@
import { type NextRequest, NextResponse } from 'next/server'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createDynamoDBClient, scanItems } from '@/app/api/tools/dynamodb/utils'
const ScanSchema = z.object({
@@ -15,13 +14,8 @@ const ScanSchema = z.object({
limit: z.number().positive().optional(),
})
export async function POST(request: NextRequest) {
export async function POST(request: Request) {
try {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const validatedData = ScanSchema.parse(body)

View File

@@ -1,6 +1,5 @@
import { type NextRequest, NextResponse } from 'next/server'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createDynamoDBClient, updateItem } from '@/app/api/tools/dynamodb/utils'
const UpdateSchema = z.object({
@@ -17,13 +16,8 @@ const UpdateSchema = z.object({
conditionExpression: z.string().optional(),
})
export async function POST(request: NextRequest) {
export async function POST(request: Request) {
try {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const validatedData = UpdateSchema.parse(body)

View File

@@ -1,7 +1,6 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -30,11 +29,6 @@ export async function GET(request: NextRequest) {
const requestId = generateRequestId()
logger.info(`[${requestId}] Google Sheets sheets request received`)
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try {
const { searchParams } = new URL(request.url)
const credentialId = searchParams.get('credentialId')

View File

@@ -1,6 +1,5 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { NextResponse } from 'next/server'
import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation'
import { getJiraCloudId } from '@/tools/jira/utils'
@@ -8,13 +7,8 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('JiraIssueAPI')
export async function POST(request: NextRequest) {
export async function POST(request: Request) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { domain, accessToken, issueId, cloudId: providedCloudId } = await request.json()
if (!domain) {
logger.error('Missing domain in request')

View File

@@ -1,6 +1,5 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { NextResponse } from 'next/server'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getJiraCloudId } from '@/tools/jira/utils'
@@ -27,13 +26,8 @@ const validateRequiredParams = (domain: string | null, accessToken: string | nul
return null
}
export async function POST(request: NextRequest) {
export async function POST(request: Request) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { domain, accessToken, issueKeys = [], cloudId: providedCloudId } = await request.json()
const validationError = validateRequiredParams(domain || null, accessToken || null)
@@ -107,13 +101,8 @@ export async function POST(request: NextRequest) {
}
}
export async function GET(request: NextRequest) {
export async function GET(request: Request) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const url = new URL(request.url)
const domain = url.searchParams.get('domain')?.trim()
const accessToken = url.searchParams.get('accessToken')

View File

@@ -1,6 +1,5 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { NextResponse } from 'next/server'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getJiraCloudId } from '@/tools/jira/utils'
@@ -8,13 +7,8 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('JiraProjectsAPI')
export async function GET(request: NextRequest) {
export async function GET(request: Request) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const url = new URL(request.url)
const domain = url.searchParams.get('domain')?.trim()
const accessToken = url.searchParams.get('accessToken')
@@ -104,13 +98,8 @@ export async function GET(request: NextRequest) {
}
}
export async function POST(request: NextRequest) {
export async function POST(request: Request) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { domain, accessToken, projectId, cloudId: providedCloudId } = await request.json()
if (!domain) {

View File

@@ -1,7 +1,6 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation'
import { getJiraCloudId } from '@/tools/jira/utils'
@@ -22,13 +21,8 @@ const jiraUpdateSchema = z.object({
cloudId: z.string().optional(),
})
export async function PUT(request: NextRequest) {
export async function PUT(request: Request) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const validation = jiraUpdateSchema.safeParse(body)

View File

@@ -1,6 +1,5 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { NextResponse } from 'next/server'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getJiraCloudId } from '@/tools/jira/utils'
@@ -8,13 +7,8 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('JiraWriteAPI')
export async function POST(request: NextRequest) {
export async function POST(request: Request) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const {
domain,
accessToken,

View File

@@ -1,6 +1,5 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { NextResponse } from 'next/server'
import {
validateAlphanumericId,
validateEnum,
@@ -16,12 +15,7 @@ const logger = createLogger('JsmApprovalsAPI')
const VALID_ACTIONS = ['get', 'answer'] as const
const VALID_DECISIONS = ['approve', 'decline'] as const
export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
export async function POST(request: Request) {
try {
const body = await request.json()
const {

View File

@@ -1,6 +1,5 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { NextResponse } from 'next/server'
import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation'
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
@@ -8,12 +7,7 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('JsmCommentAPI')
export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
export async function POST(request: Request) {
try {
const {
domain,

View File

@@ -1,6 +1,5 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { NextResponse } from 'next/server'
import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation'
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
@@ -8,12 +7,7 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('JsmCommentsAPI')
export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
export async function POST(request: Request) {
try {
const body = await request.json()
const {

View File

@@ -1,6 +1,5 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { NextResponse } from 'next/server'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
@@ -8,12 +7,7 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('JsmCustomersAPI')
export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
export async function POST(request: Request) {
try {
const body = await request.json()
const {

View File

@@ -1,6 +1,5 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { NextResponse } from 'next/server'
import {
validateAlphanumericId,
validateEnum,
@@ -14,12 +13,7 @@ const logger = createLogger('JsmOrganizationAPI')
const VALID_ACTIONS = ['create', 'add_to_service_desk'] as const
export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
export async function POST(request: Request) {
try {
const body = await request.json()
const {

View File

@@ -1,6 +1,5 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { NextResponse } from 'next/server'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
@@ -8,12 +7,7 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('JsmOrganizationsAPI')
export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
export async function POST(request: Request) {
try {
const body = await request.json()
const { domain, accessToken, cloudId: cloudIdParam, serviceDeskId, start, limit } = body

View File

@@ -1,6 +1,5 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { NextResponse } from 'next/server'
import {
validateEnum,
validateJiraCloudId,
@@ -14,12 +13,7 @@ const logger = createLogger('JsmParticipantsAPI')
const VALID_ACTIONS = ['get', 'add'] as const
export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
export async function POST(request: Request) {
try {
const body = await request.json()
const {

View File

@@ -1,6 +1,5 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { NextResponse } from 'next/server'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
@@ -8,12 +7,7 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('JsmQueuesAPI')
export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
export async function POST(request: Request) {
try {
const body = await request.json()
const {

View File

@@ -1,6 +1,5 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { NextResponse } from 'next/server'
import {
validateAlphanumericId,
validateJiraCloudId,
@@ -12,12 +11,7 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('JsmRequestAPI')
export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
export async function POST(request: Request) {
try {
const body = await request.json()
const {

View File

@@ -1,6 +1,5 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { NextResponse } from 'next/server'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
@@ -8,12 +7,7 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('JsmRequestsAPI')
export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
export async function POST(request: Request) {
try {
const body = await request.json()
const {

View File

@@ -1,6 +1,5 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { NextResponse } from 'next/server'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
@@ -8,12 +7,7 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('JsmRequestTypesAPI')
export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
export async function POST(request: Request) {
try {
const body = await request.json()
const { domain, accessToken, cloudId: cloudIdParam, serviceDeskId, start, limit } = body

View File

@@ -1,6 +1,5 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { NextResponse } from 'next/server'
import { validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
@@ -8,12 +7,7 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('JsmServiceDesksAPI')
export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
export async function POST(request: Request) {
try {
const body = await request.json()
const { domain, accessToken, cloudId: cloudIdParam, start, limit } = body

View File

@@ -1,6 +1,5 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { NextResponse } from 'next/server'
import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation'
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
@@ -8,12 +7,7 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('JsmSlaAPI')
export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
export async function POST(request: Request) {
try {
const body = await request.json()
const { domain, accessToken, cloudId: cloudIdParam, issueIdOrKey, start, limit } = body

View File

@@ -1,6 +1,5 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { NextResponse } from 'next/server'
import {
validateAlphanumericId,
validateJiraCloudId,
@@ -12,12 +11,7 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('JsmTransitionAPI')
export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
export async function POST(request: Request) {
try {
const {
domain,

View File

@@ -1,6 +1,5 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { NextResponse } from 'next/server'
import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation'
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
@@ -8,12 +7,7 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('JsmTransitionsAPI')
export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
export async function POST(request: Request) {
try {
const body = await request.json()
const { domain, accessToken, cloudId: cloudIdParam, issueIdOrKey } = body

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createMongoDBConnection, sanitizeCollectionName, validateFilter } from '../utils'
const logger = createLogger('MongoDBDeleteAPI')
@@ -41,12 +40,6 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
let client = null
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized MongoDB delete attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const params = DeleteSchema.parse(body)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createMongoDBConnection, sanitizeCollectionName, validatePipeline } from '../utils'
const logger = createLogger('MongoDBExecuteAPI')
@@ -33,12 +32,6 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
let client = null
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized MongoDB execute attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const params = ExecuteSchema.parse(body)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createMongoDBConnection, sanitizeCollectionName } from '../utils'
const logger = createLogger('MongoDBInsertAPI')
@@ -38,12 +37,6 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
let client = null
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized MongoDB insert attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const params = InsertSchema.parse(body)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createMongoDBConnection, executeIntrospect } from '../utils'
const logger = createLogger('MongoDBIntrospectAPI')
@@ -21,12 +20,6 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
let client = null
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized MongoDB introspect attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const params = IntrospectSchema.parse(body)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createMongoDBConnection, sanitizeCollectionName, validateFilter } from '../utils'
const logger = createLogger('MongoDBQueryAPI')
@@ -50,12 +49,6 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
let client = null
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized MongoDB query attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const params = QuerySchema.parse(body)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createMongoDBConnection, sanitizeCollectionName, validateFilter } from '../utils'
const logger = createLogger('MongoDBUpdateAPI')
@@ -60,12 +59,6 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
let client = null
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized MongoDB update attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const params = UpdateSchema.parse(body)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import {
convertNeo4jTypesToJSON,
createNeo4jDriver,
@@ -27,12 +26,6 @@ export async function POST(request: NextRequest) {
let driver = null
let session = null
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized Neo4j create attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const params = CreateSchema.parse(body)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createNeo4jDriver, validateCypherQuery } from '@/app/api/tools/neo4j/utils'
const logger = createLogger('Neo4jDeleteAPI')
@@ -24,12 +23,6 @@ export async function POST(request: NextRequest) {
let driver = null
let session = null
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized Neo4j delete attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const params = DeleteSchema.parse(body)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import {
convertNeo4jTypesToJSON,
createNeo4jDriver,
@@ -27,12 +26,6 @@ export async function POST(request: NextRequest) {
let driver = null
let session = null
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized Neo4j execute attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const params = ExecuteSchema.parse(body)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createNeo4jDriver } from '@/app/api/tools/neo4j/utils'
import type { Neo4jNodeSchema, Neo4jRelationshipSchema } from '@/tools/neo4j/types'
@@ -22,12 +21,6 @@ export async function POST(request: NextRequest) {
let driver = null
let session = null
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized Neo4j introspect attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const params = IntrospectSchema.parse(body)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import {
convertNeo4jTypesToJSON,
createNeo4jDriver,
@@ -27,12 +26,6 @@ export async function POST(request: NextRequest) {
let driver = null
let session = null
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized Neo4j merge attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const params = MergeSchema.parse(body)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import {
convertNeo4jTypesToJSON,
createNeo4jDriver,
@@ -27,12 +26,6 @@ export async function POST(request: NextRequest) {
let driver = null
let session = null
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized Neo4j query attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const params = QuerySchema.parse(body)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import {
convertNeo4jTypesToJSON,
createNeo4jDriver,
@@ -27,12 +26,6 @@ export async function POST(request: NextRequest) {
let driver = null
let session = null
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized Neo4j update attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const params = UpdateSchema.parse(body)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createRdsClient, executeDelete } from '@/app/api/tools/rds/utils'
const logger = createLogger('RDSDeleteAPI')
@@ -23,11 +22,6 @@ const DeleteSchema = z.object({
export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const params = DeleteSchema.parse(body)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createRdsClient, executeStatement } from '@/app/api/tools/rds/utils'
const logger = createLogger('RDSExecuteAPI')
@@ -20,11 +19,6 @@ const ExecuteSchema = z.object({
export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const params = ExecuteSchema.parse(body)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createRdsClient, executeInsert } from '@/app/api/tools/rds/utils'
const logger = createLogger('RDSInsertAPI')
@@ -23,11 +22,6 @@ const InsertSchema = z.object({
export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const params = InsertSchema.parse(body)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createRdsClient, executeIntrospect, type RdsEngine } from '@/app/api/tools/rds/utils'
const logger = createLogger('RDSIntrospectAPI')
@@ -21,11 +20,6 @@ const IntrospectSchema = z.object({
export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const params = IntrospectSchema.parse(body)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createRdsClient, executeStatement, validateQuery } from '@/app/api/tools/rds/utils'
const logger = createLogger('RDSQueryAPI')
@@ -20,11 +19,6 @@ const QuerySchema = z.object({
export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const params = QuerySchema.parse(body)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createRdsClient, executeUpdate } from '@/app/api/tools/rds/utils'
const logger = createLogger('RDSUpdateAPI')
@@ -26,11 +25,6 @@ const UpdateSchema = z.object({
export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const params = UpdateSchema.parse(body)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createSqsClient, sendMessage } from '../utils'
const logger = createLogger('SQSSendMessageAPI')
@@ -22,11 +21,6 @@ const SendMessageSchema = z.object({
export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const params = SendMessageSchema.parse(body)

View File

@@ -1,7 +1,6 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { env } from '@/lib/core/config/env'
import { isSensitiveKey, REDACTED_MARKER } from '@/lib/core/security/redaction'
import { ensureZodObject, normalizeUrl } from '@/app/api/tools/stagehand/utils'
@@ -92,11 +91,6 @@ function substituteVariables(text: string, variables: Record<string, string> | u
}
export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
let stagehand: StagehandType | null = null
try {

View File

@@ -1,7 +1,6 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { env } from '@/lib/core/config/env'
import { ensureZodObject, normalizeUrl } from '@/app/api/tools/stagehand/utils'
@@ -23,11 +22,6 @@ const requestSchema = z.object({
})
export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
let stagehand: StagehandType | null = null
try {

View File

@@ -203,9 +203,7 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
{field.name}
</span>
<Badge variant='type' size='sm'>
{field.type || 'string'}
</Badge>
<Badge size='sm'>{field.type || 'string'}</Badge>
</div>
</div>
<div className='border-[var(--border-1)] border-t px-[10px] pt-[6px] pb-[10px]'>

View File

@@ -511,9 +511,7 @@ export function McpDeploy({
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
{field.name}
</span>
<Badge variant='type' size='sm'>
{field.type}
</Badge>
<Badge size='sm'>{field.type}</Badge>
</div>
</div>
<div className='border-[var(--border-1)] border-t px-[10px] pt-[6px] pb-[10px]'>

View File

@@ -245,9 +245,7 @@ export function DocumentTagEntry({
{tag.collapsed ? tag.tagName || `Tag ${index + 1}` : `Tag ${index + 1}`}
</span>
{tag.collapsed && tag.tagName && (
<Badge variant='type' size='sm'>
{FIELD_TYPE_LABELS[tag.fieldType] || 'Text'}
</Badge>
<Badge size='sm'>{FIELD_TYPE_LABELS[tag.fieldType] || 'Text'}</Badge>
)}
</div>
<div className='flex items-center gap-[8px] pl-[8px]' onClick={(e) => e.stopPropagation()}>

View File

@@ -223,11 +223,7 @@ function InputMappingField({
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
{fieldName}
</span>
{fieldType && (
<Badge variant='type' size='sm'>
{fieldType}
</Badge>
)}
{fieldType && <Badge size='sm'>{fieldType}</Badge>}
</div>
</div>

View File

@@ -238,9 +238,7 @@ export function KnowledgeTagFilters({
{filter.collapsed ? filter.tagName || `Filter ${index + 1}` : `Filter ${index + 1}`}
</span>
{filter.collapsed && filter.tagName && (
<Badge variant='type' size='sm'>
{FIELD_TYPE_LABELS[filter.fieldType] || 'Text'}
</Badge>
<Badge size='sm'>{FIELD_TYPE_LABELS[filter.fieldType] || 'Text'}</Badge>
)}
</div>
<div className='flex items-center gap-[8px] pl-[8px]' onClick={(e) => e.stopPropagation()}>

View File

@@ -310,11 +310,7 @@ export function FieldFormat({
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
{field.name || `${title} ${index + 1}`}
</span>
{field.name && showType && (
<Badge variant='type' size='sm'>
{field.type}
</Badge>
)}
{field.name && showType && <Badge size='sm'>{field.type}</Badge>}
</div>
<div className='flex items-center gap-[8px] pl-[8px]' onClick={(e) => e.stopPropagation()}>
<Button variant='ghost' onClick={addField} disabled={isReadOnly} className='h-auto p-0'>

View File

@@ -345,11 +345,7 @@ export function VariablesInput({
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
{assignment.variableName || `Variable ${index + 1}`}
</span>
{assignment.variableName && (
<Badge variant='type' size='sm'>
{assignment.type}
</Badge>
)}
{assignment.variableName && <Badge size='sm'>{assignment.type}</Badge>}
</div>
<div
className='flex items-center gap-[8px] pl-[8px]'

View File

@@ -796,7 +796,6 @@ export const Terminal = memo(function Terminal() {
const terminalRef = useRef<HTMLElement>(null)
const prevEntriesLengthRef = useRef(0)
const prevWorkflowEntriesLengthRef = useRef(0)
const hasInitializedEntriesRef = useRef(false)
const isTerminalFocusedRef = useRef(false)
const lastExpandedHeightRef = useRef<number>(DEFAULT_EXPANDED_HEIGHT)
const setTerminalHeight = useTerminalStore((state) => state.setTerminalHeight)
@@ -1008,33 +1007,12 @@ export const Terminal = memo(function Terminal() {
return JSON.stringify(outputData, null, 2)
}, [outputData])
/**
* Reset entry tracking when switching workflows to ensure auto-open
* works correctly for each workflow independently.
*/
useEffect(() => {
hasInitializedEntriesRef.current = false
}, [activeWorkflowId])
/**
* Auto-open the terminal on new entries when "Open on run" is enabled.
* This mirrors the header toggle behavior by using expandToLastHeight,
* ensuring we always get the same smooth height transition.
*
* Skips the initial sync after console hydration to avoid auto-opening
* when persisted entries are restored on page refresh.
*/
useEffect(() => {
if (!hasConsoleHydrated) {
return
}
if (!hasInitializedEntriesRef.current) {
hasInitializedEntriesRef.current = true
prevWorkflowEntriesLengthRef.current = allWorkflowEntries.length
return
}
if (!openOnRun) {
prevWorkflowEntriesLengthRef.current = allWorkflowEntries.length
return
@@ -1048,14 +1026,7 @@ export const Terminal = memo(function Terminal() {
}
prevWorkflowEntriesLengthRef.current = currentLength
}, [
allWorkflowEntries.length,
expandToLastHeight,
openOnRun,
isExpanded,
hasConsoleHydrated,
activeWorkflowId,
])
}, [allWorkflowEntries.length, expandToLastHeight, openOnRun, isExpanded])
/**
* Handle row click - toggle if clicking same entry

View File

@@ -66,6 +66,7 @@ import { useWorkspaceEnvironment } from '@/hooks/queries/environment'
import { useAutoConnect, useSnapToGridSize } from '@/hooks/queries/general-settings'
import { useCanvasViewport } from '@/hooks/use-canvas-viewport'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useStreamCleanup } from '@/hooks/use-stream-cleanup'
import { useCanvasModeStore } from '@/stores/canvas-mode'
import { useChatStore } from '@/stores/chat/store'
@@ -99,33 +100,39 @@ const logger = createLogger('Workflow')
const DEFAULT_PASTE_OFFSET = { x: 50, y: 50 }
/**
* Calculates the offset to paste blocks at viewport center, or simple offset for nested blocks
* Gets the center of the current viewport in flow coordinates
*/
function getViewportCenter(
screenToFlowPosition: (pos: { x: number; y: number }) => { x: number; y: number }
): { x: number; y: number } {
const flowContainer = document.querySelector('.react-flow')
if (!flowContainer) {
return screenToFlowPosition({
x: window.innerWidth / 2,
y: window.innerHeight / 2,
})
}
const rect = flowContainer.getBoundingClientRect()
return screenToFlowPosition({
x: rect.width / 2,
y: rect.height / 2,
})
}
/**
* Calculates the offset to paste blocks at viewport center
*/
function calculatePasteOffset(
clipboard: {
blocks: Record<
string,
{
position: { x: number; y: number }
type: string
height?: number
data?: { parentId?: string }
}
>
blocks: Record<string, { position: { x: number; y: number }; type: string; height?: number }>
} | null,
viewportCenter: { x: number; y: number },
existingBlocks: Record<string, { id: string }> = {}
screenToFlowPosition: (pos: { x: number; y: number }) => { x: number; y: number }
): { x: number; y: number } {
if (!clipboard) return DEFAULT_PASTE_OFFSET
const clipboardBlocks = Object.values(clipboard.blocks)
if (clipboardBlocks.length === 0) return DEFAULT_PASTE_OFFSET
const allBlocksNested = clipboardBlocks.every(
(b) => b.data?.parentId && existingBlocks[b.data.parentId]
)
if (allBlocksNested) return DEFAULT_PASTE_OFFSET
const minX = Math.min(...clipboardBlocks.map((b) => b.position.x))
const maxX = Math.max(
...clipboardBlocks.map((b) => {
@@ -148,6 +155,8 @@ function calculatePasteOffset(
)
const clipboardCenter = { x: (minX + maxX) / 2, y: (minY + maxY) / 2 }
const viewportCenter = getViewportCenter(screenToFlowPosition)
return {
x: viewportCenter.x - clipboardCenter.x,
y: viewportCenter.y - clipboardCenter.y,
@@ -257,7 +266,7 @@ const WorkflowContent = React.memo(() => {
const router = useRouter()
const reactFlowInstance = useReactFlow()
const { screenToFlowPosition, getNodes, setNodes, getIntersectingNodes } = reactFlowInstance
const { fitViewToBounds, getViewportCenter } = useCanvasViewport(reactFlowInstance)
const { fitViewToBounds } = useCanvasViewport(reactFlowInstance)
const { emitCursorUpdate } = useSocket()
const workspaceId = params.workspaceId as string
@@ -321,12 +330,16 @@ const WorkflowContent = React.memo(() => {
const isAutoConnectEnabled = useAutoConnect()
const autoConnectRef = useRef(isAutoConnectEnabled)
autoConnectRef.current = isAutoConnectEnabled
useEffect(() => {
autoConnectRef.current = isAutoConnectEnabled
}, [isAutoConnectEnabled])
// Panel open states for context menu
const isVariablesOpen = useVariablesStore((state) => state.isOpen)
const isChatOpen = useChatStore((state) => state.isChatOpen)
// Permission config for invitation control
const { isInvitationsDisabled } = usePermissionConfig()
const snapGrid: [number, number] = useMemo(
() => [snapToGridSize, snapToGridSize],
[snapToGridSize]
@@ -460,16 +473,11 @@ const WorkflowContent = React.memo(() => {
)
/** Re-applies diff markers when blocks change after socket rehydration. */
const diffBlocksRef = useRef(blocks)
const blocksRef = useRef(blocks)
useEffect(() => {
if (!isWorkflowReady) return
const blocksChanged = blocks !== diffBlocksRef.current
if (!blocksChanged) return
diffBlocksRef.current = blocks
if (hasActiveDiff && isDiffReady) {
if (hasActiveDiff && isDiffReady && blocks !== blocksRef.current) {
blocksRef.current = blocks
setTimeout(() => reapplyDiffMarkers(), 0)
}
}, [blocks, hasActiveDiff, isDiffReady, reapplyDiffMarkers, isWorkflowReady])
@@ -532,7 +540,8 @@ const WorkflowContent = React.memo(() => {
})
}, [edges, isShowingDiff, isDiffReady, diffAnalysis, blocks])
const { userPermissions } = useWorkspacePermissionsContext()
const { userPermissions, workspacePermissions, permissionsError } =
useWorkspacePermissionsContext()
/** Returns read-only permissions when viewing snapshot, otherwise user permissions. */
const effectivePermissions = useMemo(() => {
@@ -768,6 +777,25 @@ const WorkflowContent = React.memo(() => {
[isErrorConnectionDrag]
)
/** Logs permission loading results for debugging. */
useEffect(() => {
if (permissionsError) {
logger.error('Failed to load workspace permissions', {
workspaceId,
error: permissionsError,
})
} else if (workspacePermissions) {
logger.info('Workspace permissions loaded in workflow', {
workspaceId,
userCount: workspacePermissions.total,
permissions: workspacePermissions.users.map((u) => ({
email: u.email,
permissions: u.permissionType,
})),
})
}
}, [workspacePermissions, permissionsError, workspaceId])
const updateNodeParent = useCallback(
(nodeId: string, newParentId: string | null, affectedEdges: any[] = []) => {
const node = getNodes().find((n: any) => n.id === nodeId)
@@ -873,125 +901,11 @@ const WorkflowContent = React.memo(() => {
* Consolidates shared logic for context paste, duplicate, and keyboard paste.
*/
const executePasteOperation = useCallback(
(
operation: 'paste' | 'duplicate',
pasteOffset: { x: number; y: number },
targetContainer?: {
loopId: string
loopPosition: { x: number; y: number }
dimensions: { width: number; height: number }
} | null,
pasteTargetPosition?: { x: number; y: number }
) => {
// For context menu paste into a subflow, calculate offset to center blocks at click position
// Skip click-position centering if blocks came from inside a subflow (relative coordinates)
let effectiveOffset = pasteOffset
if (targetContainer && pasteTargetPosition && clipboard) {
const clipboardBlocks = Object.values(clipboard.blocks)
// Only use click-position centering for top-level blocks (absolute coordinates)
// Blocks with parentId have relative positions that can't be mixed with absolute click position
const hasNestedBlocks = clipboardBlocks.some((b) => b.data?.parentId)
if (clipboardBlocks.length > 0 && !hasNestedBlocks) {
const minX = Math.min(...clipboardBlocks.map((b) => b.position.x))
const maxX = Math.max(
...clipboardBlocks.map((b) => b.position.x + BLOCK_DIMENSIONS.FIXED_WIDTH)
)
const minY = Math.min(...clipboardBlocks.map((b) => b.position.y))
const maxY = Math.max(
...clipboardBlocks.map((b) => b.position.y + BLOCK_DIMENSIONS.MIN_HEIGHT)
)
const clipboardCenter = { x: (minX + maxX) / 2, y: (minY + maxY) / 2 }
effectiveOffset = {
x: pasteTargetPosition.x - clipboardCenter.x,
y: pasteTargetPosition.y - clipboardCenter.y,
}
}
}
const pasteData = preparePasteData(effectiveOffset)
(operation: 'paste' | 'duplicate', pasteOffset: { x: number; y: number }) => {
const pasteData = preparePasteData(pasteOffset)
if (!pasteData) return
let pastedBlocksArray = Object.values(pasteData.blocks)
// If pasting into a subflow, adjust blocks to be children of that subflow
if (targetContainer) {
// Check if any pasted block is a trigger - triggers cannot be in subflows
const hasTrigger = pastedBlocksArray.some((b) => TriggerUtils.isTriggerBlock(b))
if (hasTrigger) {
addNotification({
level: 'error',
message: 'Triggers cannot be placed inside loop or parallel subflows.',
workflowId: activeWorkflowId || undefined,
})
return
}
// Check if any pasted block is a subflow - subflows cannot be nested
const hasSubflow = pastedBlocksArray.some((b) => b.type === 'loop' || b.type === 'parallel')
if (hasSubflow) {
addNotification({
level: 'error',
message: 'Subflows cannot be nested inside other subflows.',
workflowId: activeWorkflowId || undefined,
})
return
}
// Adjust each block's position to be relative to the container and set parentId
pastedBlocksArray = pastedBlocksArray.map((block) => {
// For blocks already nested (have parentId), positions are already relative - use as-is
// For top-level blocks, convert absolute position to relative by subtracting container position
const wasNested = Boolean(block.data?.parentId)
const relativePosition = wasNested
? { x: block.position.x, y: block.position.y }
: {
x: block.position.x - targetContainer.loopPosition.x,
y: block.position.y - targetContainer.loopPosition.y,
}
// Clamp position to keep block inside container (below header)
const clampedPosition = {
x: Math.max(
CONTAINER_DIMENSIONS.LEFT_PADDING,
Math.min(
relativePosition.x,
targetContainer.dimensions.width -
BLOCK_DIMENSIONS.FIXED_WIDTH -
CONTAINER_DIMENSIONS.RIGHT_PADDING
)
),
y: Math.max(
CONTAINER_DIMENSIONS.HEADER_HEIGHT + CONTAINER_DIMENSIONS.TOP_PADDING,
Math.min(
relativePosition.y,
targetContainer.dimensions.height -
BLOCK_DIMENSIONS.MIN_HEIGHT -
CONTAINER_DIMENSIONS.BOTTOM_PADDING
)
),
}
return {
...block,
position: clampedPosition,
data: {
...block.data,
parentId: targetContainer.loopId,
extent: 'parent',
},
}
})
// Update pasteData.blocks with the modified blocks
pasteData.blocks = pastedBlocksArray.reduce(
(acc, block) => {
acc[block.id] = block
return acc
},
{} as Record<string, (typeof pastedBlocksArray)[0]>
)
}
const pastedBlocksArray = Object.values(pasteData.blocks)
const validation = validateTriggerPaste(pastedBlocksArray, blocks, operation)
if (!validation.isValid) {
addNotification({
@@ -1012,47 +926,21 @@ const WorkflowContent = React.memo(() => {
pasteData.parallels,
pasteData.subBlockValues
)
// Resize container if we pasted into a subflow
if (targetContainer) {
resizeLoopNodesWrapper()
}
},
[
preparePasteData,
blocks,
clipboard,
addNotification,
activeWorkflowId,
collaborativeBatchAddBlocks,
setPendingSelection,
resizeLoopNodesWrapper,
]
)
const handleContextPaste = useCallback(() => {
if (!hasClipboard()) return
// Convert context menu position to flow coordinates and check if inside a subflow
const flowPosition = screenToFlowPosition(contextMenuPosition)
const targetContainer = isPointInLoopNode(flowPosition)
executePasteOperation(
'paste',
calculatePasteOffset(clipboard, getViewportCenter(), blocks),
targetContainer,
flowPosition // Pass the click position so blocks are centered at where user right-clicked
)
}, [
hasClipboard,
executePasteOperation,
clipboard,
getViewportCenter,
screenToFlowPosition,
contextMenuPosition,
isPointInLoopNode,
blocks,
])
executePasteOperation('paste', calculatePasteOffset(clipboard, screenToFlowPosition))
}, [hasClipboard, executePasteOperation, clipboard, screenToFlowPosition])
const handleContextDuplicate = useCallback(() => {
copyBlocks(contextMenuBlocks.map((b) => b.id))
@@ -1118,6 +1006,10 @@ const WorkflowContent = React.memo(() => {
setIsChatOpen(!isChatOpen)
}, [])
const handleContextInvite = useCallback(() => {
window.dispatchEvent(new CustomEvent('open-invite-modal'))
}, [])
useEffect(() => {
let cleanup: (() => void) | null = null
@@ -1162,10 +1054,7 @@ const WorkflowContent = React.memo(() => {
} else if ((event.ctrlKey || event.metaKey) && event.key === 'v') {
if (effectivePermissions.canEdit && hasClipboard()) {
event.preventDefault()
executePasteOperation(
'paste',
calculatePasteOffset(clipboard, getViewportCenter(), blocks)
)
executePasteOperation('paste', calculatePasteOffset(clipboard, screenToFlowPosition))
}
}
}
@@ -1185,9 +1074,8 @@ const WorkflowContent = React.memo(() => {
hasClipboard,
effectivePermissions.canEdit,
clipboard,
getViewportCenter,
screenToFlowPosition,
executePasteOperation,
blocks,
])
/**
@@ -1619,7 +1507,7 @@ const WorkflowContent = React.memo(() => {
if (!type) return
if (type === 'connectionBlock') return
const basePosition = getViewportCenter()
const basePosition = getViewportCenter(screenToFlowPosition)
if (type === 'loop' || type === 'parallel') {
const id = crypto.randomUUID()
@@ -1688,7 +1576,7 @@ const WorkflowContent = React.memo(() => {
)
}
}, [
getViewportCenter,
screenToFlowPosition,
blocks,
addBlock,
effectivePermissions.canEdit,
@@ -2162,8 +2050,6 @@ const WorkflowContent = React.memo(() => {
// Local state for nodes - allows smooth drag without store updates on every frame
const [displayNodes, setDisplayNodes] = useState<Node[]>([])
// Sync derivedNodes to displayNodes while preserving selection state
// This effect handles both normal sync and pending selection from paste/duplicate
useEffect(() => {
// Check for pending selection (from paste/duplicate), otherwise preserve existing selection
if (pendingSelection && pendingSelection.length > 0) {
@@ -2193,6 +2079,7 @@ const WorkflowContent = React.memo(() => {
}, [derivedNodes, blocks, pendingSelection, clearPendingSelection])
// Phase 2: When displayNodes updates, check if pending zoom blocks are ready
// (Phase 1 is located earlier in the file where pendingZoomBlockIdsRef is defined)
useEffect(() => {
const pendingBlockIds = pendingZoomBlockIdsRef.current
if (!pendingBlockIds || pendingBlockIds.size === 0) {
@@ -2383,6 +2270,40 @@ const WorkflowContent = React.memo(() => {
resizeLoopNodesWrapper()
}, [derivedNodes, resizeLoopNodesWrapper, isWorkflowReady])
/** Cleans up orphaned nodes with invalid parent references after deletion. */
useEffect(() => {
if (!isWorkflowReady) return
// Create a mapping of node IDs to check for missing parent references
const nodeIds = new Set(Object.keys(blocks))
// Check for nodes with invalid parent references and collect updates
const orphanedUpdates: Array<{
id: string
position: { x: number; y: number }
parentId: string
}> = []
Object.entries(blocks).forEach(([id, block]) => {
const parentId = block.data?.parentId
// If block has a parent reference but parent no longer exists
if (parentId && !nodeIds.has(parentId)) {
logger.warn('Found orphaned node with invalid parent reference', {
nodeId: id,
missingParentId: parentId,
})
const absolutePosition = getNodeAbsolutePosition(id)
orphanedUpdates.push({ id, position: absolutePosition, parentId: '' })
}
})
// Batch update all orphaned nodes at once
if (orphanedUpdates.length > 0) {
batchUpdateBlocksWithParent(orphanedUpdates)
}
}, [blocks, batchUpdateBlocksWithParent, getNodeAbsolutePosition, isWorkflowReady])
/** Handles edge removal changes. */
const onEdgesChange = useCallback(
(changes: any) => {

View File

@@ -611,9 +611,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
{name}
</span>
<Badge variant='type' size='sm'>
{prop.type || 'any'}
</Badge>
<Badge size='sm'>{prop.type || 'any'}</Badge>
</div>
</div>
<div className='border-[var(--border-1)] border-t px-[10px] pt-[6px] pb-[10px]'>

View File

@@ -16,7 +16,6 @@ const badgeVariants = cva(
'gap-[4px] rounded-[40px] border border-[var(--border)] text-[var(--text-secondary)] bg-[var(--surface-4)] hover:text-[var(--text-primary)] hover:border-[var(--border-1)] hover:bg-[var(--surface-6)] dark:hover:bg-[var(--surface-5)]',
outline:
'gap-[4px] rounded-[40px] border border-[var(--border-1)] bg-transparent text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-5)] dark:hover:bg-transparent dark:hover:border-[var(--surface-6)]',
type: 'gap-[4px] rounded-[40px] border border-[var(--border)] text-[var(--text-secondary)] bg-[var(--surface-4)] dark:bg-[var(--surface-6)]',
green: `${STATUS_BASE} bg-[#bbf7d0] text-[#15803d] dark:bg-[rgba(34,197,94,0.2)] dark:text-[#86efac]`,
red: `${STATUS_BASE} bg-[#fecaca] text-[var(--text-error)] dark:bg-[#551a1a] dark:text-[var(--text-error)]`,
gray: `${STATUS_BASE} bg-[#e7e5e4] text-[#57534e] dark:bg-[var(--terminal-status-info-bg)] dark:text-[var(--terminal-status-info-color)]`,
@@ -85,7 +84,7 @@ export interface BadgeProps
*
* @remarks
* Supports two categories of variants:
* - **Bordered**: `default`, `outline`, `type` - traditional badges with borders
* - **Bordered**: `default`, `outline` - traditional badges with borders
* - **Status colors**: `green`, `red`, `gray`, `blue`, `blue-secondary`, `purple`,
* `orange`, `amber`, `teal`, `cyan`, `gray-secondary` - borderless colored badges
*

View File

@@ -57,16 +57,31 @@ function getVisibleCanvasBounds(): VisibleBounds {
* Gets the center of the visible canvas in screen coordinates.
*/
function getVisibleCanvasCenter(): { x: number; y: number } {
const bounds = getVisibleCanvasBounds()
const style = getComputedStyle(document.documentElement)
const sidebarWidth = Number.parseInt(style.getPropertyValue('--sidebar-width') || '0', 10)
const panelWidth = Number.parseInt(style.getPropertyValue('--panel-width') || '0', 10)
const terminalHeight = Number.parseInt(style.getPropertyValue('--terminal-height') || '0', 10)
const flowContainer = document.querySelector('.react-flow')
const rect = flowContainer?.getBoundingClientRect()
const containerLeft = rect?.left ?? 0
const containerTop = rect?.top ?? 0
if (!flowContainer) {
const visibleWidth = window.innerWidth - sidebarWidth - panelWidth
const visibleHeight = window.innerHeight - terminalHeight
return {
x: sidebarWidth + visibleWidth / 2,
y: visibleHeight / 2,
}
}
const rect = flowContainer.getBoundingClientRect()
// Calculate actual visible area in screen coordinates
const visibleLeft = Math.max(rect.left, sidebarWidth)
const visibleRight = Math.min(rect.right, window.innerWidth - panelWidth)
const visibleBottom = Math.min(rect.bottom, window.innerHeight - terminalHeight)
return {
x: containerLeft + bounds.offsetLeft + bounds.width / 2,
y: containerTop + bounds.height / 2,
x: (visibleLeft + visibleRight) / 2,
y: (rect.top + visibleBottom) / 2,
}
}

View File

@@ -14,7 +14,6 @@ import {
loadDeployedWorkflowState,
loadWorkflowFromNormalizedTables,
} from '@/lib/workflows/persistence/utils'
import { mergeSubblockStateWithValues } from '@/lib/workflows/subblocks'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { updateWorkflowRunCounts } from '@/lib/workflows/utils'
import { Executor } from '@/executor'
@@ -27,6 +26,7 @@ import type {
import type { ExecutionResult, NormalizedBlockOutput } from '@/executor/types'
import { hasExecutionResult } from '@/executor/utils/errors'
import { Serializer } from '@/serializer'
import { mergeSubblockState } from '@/stores/workflows/server-utils'
const logger = createLogger('ExecutionCore')
@@ -172,7 +172,8 @@ export async function executeWorkflowCore(
logger.info(`[${requestId}] Using deployed workflow state (deployed execution)`)
}
const mergedStates = mergeSubblockStateWithValues(blocks)
// Merge block states
const mergedStates = mergeSubblockState(blocks)
const personalEnvUserId =
metadata.isClientSession && metadata.sessionUserId

View File

@@ -0,0 +1,52 @@
/**
* Server-Safe Workflow Utilities
*
* This file contains workflow utility functions that can be safely imported
* by server-side API routes without causing client/server boundary violations.
*
* Unlike the main utils.ts file, this does NOT import any client-side stores
* or React hooks, making it safe for use in Next.js API routes.
*/
import { mergeSubblockStateWithValues } from '@/lib/workflows/subblocks'
import type { BlockState } from '@/stores/workflows/workflow/types'
/**
* Server-safe version of mergeSubblockState for API routes
*
* Merges workflow block states with provided subblock values while maintaining block structure.
* This version takes explicit subblock values instead of reading from client stores.
*
* @param blocks - Block configurations from workflow state
* @param subBlockValues - Object containing subblock values keyed by blockId -> subBlockId -> value
* @param blockId - Optional specific block ID to merge (merges all if not provided)
* @returns Merged block states with updated values
*/
export function mergeSubblockState(
blocks: Record<string, BlockState>,
subBlockValues: Record<string, Record<string, any>> = {},
blockId?: string
): Record<string, BlockState> {
return mergeSubblockStateWithValues(blocks, subBlockValues, blockId)
}
/**
* Server-safe async version of mergeSubblockState for API routes
*
* Asynchronously merges workflow block states with provided subblock values.
* This version takes explicit subblock values instead of reading from client stores.
*
* @param blocks - Block configurations from workflow state
* @param subBlockValues - Object containing subblock values keyed by blockId -> subBlockId -> value
* @param blockId - Optional specific block ID to merge (merges all if not provided)
* @returns Promise resolving to merged block states with updated values
*/
export async function mergeSubblockStateAsync(
blocks: Record<string, BlockState>,
subBlockValues: Record<string, Record<string, any>> = {},
blockId?: string
): Promise<Record<string, BlockState>> {
// Since we're not reading from client stores, we can just return the sync version
// The async nature was only needed for the client-side store operations
return mergeSubblockState(blocks, subBlockValues, blockId)
}

View File

@@ -7,7 +7,7 @@ import {
} from '@sim/testing'
import { describe, expect, it } from 'vitest'
import { normalizeName } from '@/executor/constants'
import { getUniqueBlockName, regenerateBlockIds } from './utils'
import { getUniqueBlockName } from './utils'
describe('normalizeName', () => {
it.concurrent('should convert to lowercase', () => {
@@ -223,213 +223,3 @@ describe('getUniqueBlockName', () => {
expect(getUniqueBlockName('myblock', existingBlocks)).toBe('myblock 2')
})
})
describe('regenerateBlockIds', () => {
const positionOffset = { x: 50, y: 50 }
it('should preserve parentId and use same offset when duplicating a block inside an existing subflow', () => {
const loopId = 'loop-1'
const childId = 'child-1'
const existingBlocks = {
[loopId]: createLoopBlock({ id: loopId, name: 'Loop 1' }),
}
const blocksToCopy = {
[childId]: createAgentBlock({
id: childId,
name: 'Agent 1',
position: { x: 100, y: 50 },
data: { parentId: loopId, extent: 'parent' },
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset, // { x: 50, y: 50 } - small offset, used as-is
existingBlocks,
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
expect(newBlocks).toHaveLength(1)
const duplicatedBlock = newBlocks[0]
expect(duplicatedBlock.data?.parentId).toBe(loopId)
expect(duplicatedBlock.data?.extent).toBe('parent')
expect(duplicatedBlock.position).toEqual({ x: 150, y: 100 })
})
it('should clear parentId when parent does not exist in paste set or existing blocks', () => {
const nonExistentParentId = 'non-existent-loop'
const childId = 'child-1'
const blocksToCopy = {
[childId]: createAgentBlock({
id: childId,
name: 'Agent 1',
position: { x: 100, y: 50 },
data: { parentId: nonExistentParentId, extent: 'parent' },
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
{},
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
expect(newBlocks).toHaveLength(1)
const duplicatedBlock = newBlocks[0]
expect(duplicatedBlock.data?.parentId).toBeUndefined()
expect(duplicatedBlock.data?.extent).toBeUndefined()
})
it('should remap parentId when copying both parent and child together', () => {
const loopId = 'loop-1'
const childId = 'child-1'
const blocksToCopy = {
[loopId]: createLoopBlock({
id: loopId,
name: 'Loop 1',
position: { x: 200, y: 200 },
}),
[childId]: createAgentBlock({
id: childId,
name: 'Agent 1',
position: { x: 100, y: 50 },
data: { parentId: loopId, extent: 'parent' },
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
{},
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
expect(newBlocks).toHaveLength(2)
const newLoop = newBlocks.find((b) => b.type === 'loop')
const newChild = newBlocks.find((b) => b.type === 'agent')
expect(newLoop).toBeDefined()
expect(newChild).toBeDefined()
expect(newChild!.data?.parentId).toBe(newLoop!.id)
expect(newChild!.data?.extent).toBe('parent')
expect(newLoop!.position).toEqual({ x: 250, y: 250 })
expect(newChild!.position).toEqual({ x: 100, y: 50 })
})
it('should apply offset to top-level blocks', () => {
const blockId = 'block-1'
const blocksToCopy = {
[blockId]: createAgentBlock({
id: blockId,
name: 'Agent 1',
position: { x: 100, y: 100 },
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
{},
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
expect(newBlocks).toHaveLength(1)
expect(newBlocks[0].position).toEqual({ x: 150, y: 150 })
})
it('should generate unique names for duplicated blocks', () => {
const blockId = 'block-1'
const existingBlocks = {
existing: createAgentBlock({ id: 'existing', name: 'Agent 1' }),
}
const blocksToCopy = {
[blockId]: createAgentBlock({
id: blockId,
name: 'Agent 1',
position: { x: 100, y: 100 },
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
existingBlocks,
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
expect(newBlocks).toHaveLength(1)
expect(newBlocks[0].name).toBe('Agent 2')
})
it('should ignore large viewport offset for blocks inside existing subflows', () => {
const loopId = 'loop-1'
const childId = 'child-1'
const existingBlocks = {
[loopId]: createLoopBlock({ id: loopId, name: 'Loop 1' }),
}
const blocksToCopy = {
[childId]: createAgentBlock({
id: childId,
name: 'Agent 1',
position: { x: 100, y: 50 },
data: { parentId: loopId, extent: 'parent' },
}),
}
const largeViewportOffset = { x: 2000, y: 1500 }
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
largeViewportOffset,
existingBlocks,
getUniqueBlockName
)
const duplicatedBlock = Object.values(result.blocks)[0]
expect(duplicatedBlock.position).toEqual({ x: 280, y: 70 })
expect(duplicatedBlock.data?.parentId).toBe(loopId)
})
})

View File

@@ -1,8 +1,7 @@
import type { Edge } from 'reactflow'
import { v4 as uuidv4 } from 'uuid'
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants'
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { mergeSubblockStateWithValues } from '@/lib/workflows/subblocks'
import { mergeSubBlockValues, mergeSubblockStateWithValues } from '@/lib/workflows/subblocks'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { getBlock } from '@/blocks'
import { isAnnotationOnlyBlock, normalizeName } from '@/executor/constants'
@@ -17,8 +16,7 @@ import type {
} from '@/stores/workflows/workflow/types'
import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'
/** Threshold to detect viewport-based offsets vs small duplicate offsets */
const LARGE_OFFSET_THRESHOLD = 300
const WEBHOOK_SUBBLOCK_FIELDS = ['webhookId', 'triggerPath']
/**
* Checks if an edge is valid (source and target exist, not annotation-only, target is not a trigger)
@@ -206,6 +204,64 @@ export function prepareBlockState(options: PrepareBlockStateOptions): BlockState
}
}
export interface PrepareDuplicateBlockStateOptions {
sourceBlock: BlockState
newId: string
newName: string
positionOffset: { x: number; y: number }
subBlockValues: Record<string, unknown>
}
/**
* Prepares a BlockState for duplicating an existing block.
* Copies block structure and subblock values, excluding webhook fields.
*/
export function prepareDuplicateBlockState(options: PrepareDuplicateBlockStateOptions): {
block: BlockState
subBlockValues: Record<string, unknown>
} {
const { sourceBlock, newId, newName, positionOffset, subBlockValues } = options
const filteredSubBlockValues = Object.fromEntries(
Object.entries(subBlockValues).filter(([key]) => !WEBHOOK_SUBBLOCK_FIELDS.includes(key))
)
const baseSubBlocks: Record<string, SubBlockState> = sourceBlock.subBlocks
? JSON.parse(JSON.stringify(sourceBlock.subBlocks))
: {}
WEBHOOK_SUBBLOCK_FIELDS.forEach((field) => {
if (field in baseSubBlocks) {
delete baseSubBlocks[field]
}
})
const mergedSubBlocks = mergeSubBlockValues(baseSubBlocks, filteredSubBlockValues) as Record<
string,
SubBlockState
>
const block: BlockState = {
id: newId,
type: sourceBlock.type,
name: newName,
position: {
x: sourceBlock.position.x + positionOffset.x,
y: sourceBlock.position.y + positionOffset.y,
},
data: sourceBlock.data ? JSON.parse(JSON.stringify(sourceBlock.data)) : {},
subBlocks: mergedSubBlocks,
outputs: sourceBlock.outputs ? JSON.parse(JSON.stringify(sourceBlock.outputs)) : {},
enabled: sourceBlock.enabled ?? true,
horizontalHandles: sourceBlock.horizontalHandles ?? true,
advancedMode: sourceBlock.advancedMode ?? false,
triggerMode: sourceBlock.triggerMode ?? false,
height: sourceBlock.height || 0,
}
return { block, subBlockValues: filteredSubBlockValues }
}
/**
* Merges workflow block states with subblock values while maintaining block structure
* @param blocks - Block configurations from workflow store
@@ -292,6 +348,78 @@ export function mergeSubblockState(
)
}
/**
* Asynchronously merges workflow block states with subblock values
* Ensures all values are properly resolved before returning
*
* @param blocks - Block configurations from workflow store
* @param workflowId - ID of the workflow to merge values for
* @param blockId - Optional specific block ID to merge (merges all if not provided)
* @returns Promise resolving to merged block states with updated values
*/
export async function mergeSubblockStateAsync(
blocks: Record<string, BlockState>,
workflowId?: string,
blockId?: string
): Promise<Record<string, BlockState>> {
const subBlockStore = useSubBlockStore.getState()
if (workflowId) {
const workflowValues = subBlockStore.workflowValues[workflowId] || {}
return mergeSubblockStateWithValues(blocks, workflowValues, blockId)
}
const blocksToProcess = blockId ? { [blockId]: blocks[blockId] } : blocks
// Process blocks in parallel for better performance
const processedBlockEntries = await Promise.all(
Object.entries(blocksToProcess).map(async ([id, block]) => {
// Skip if block is undefined or doesn't have subBlocks
if (!block || !block.subBlocks) {
return [id, block] as const
}
// Process all subblocks in parallel
const subBlockEntries = await Promise.all(
Object.entries(block.subBlocks).map(async ([subBlockId, subBlock]) => {
// Skip if subBlock is undefined
if (!subBlock) {
return null
}
const storedValue = subBlockStore.getValue(id, subBlockId)
return [
subBlockId,
{
...subBlock,
value: (storedValue !== undefined && storedValue !== null
? storedValue
: subBlock.value) as SubBlockState['value'],
},
] as const
})
)
// Convert entries back to an object
const mergedSubBlocks = Object.fromEntries(
subBlockEntries.filter((entry): entry is readonly [string, SubBlockState] => entry !== null)
) as Record<string, SubBlockState>
// Return the full block state with updated subBlocks (including orphaned values)
return [
id,
{
...block,
subBlocks: mergedSubBlocks,
},
] as const
})
)
return Object.fromEntries(processedBlockEntries) as Record<string, BlockState>
}
function updateValueReferences(value: unknown, nameMap: Map<string, string>): unknown {
if (typeof value === 'string') {
let updatedValue = value
@@ -316,10 +444,14 @@ function updateValueReferences(value: unknown, nameMap: Map<string, string>): un
function updateBlockReferences(
blocks: Record<string, BlockState>,
idMap: Map<string, string>,
nameMap: Map<string, string>,
clearTriggerRuntimeValues = false
): void {
Object.entries(blocks).forEach(([_, block]) => {
// NOTE: parentId remapping is handled in regenerateBlockIds' second pass.
// Do NOT remap parentId here as it would incorrectly clear already-mapped IDs.
if (block.subBlocks) {
Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]) => {
if (clearTriggerRuntimeValues && TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(subBlockId)) {
@@ -401,7 +533,7 @@ export function regenerateWorkflowIds(
})
}
updateBlockReferences(newBlocks, nameMap, clearTriggerRuntimeValues)
updateBlockReferences(newBlocks, blockIdMap, nameMap, clearTriggerRuntimeValues)
return {
blocks: newBlocks,
@@ -442,36 +574,13 @@ export function regenerateBlockIds(
const newNormalizedName = normalizeName(newName)
nameMap.set(oldNormalizedName, newNormalizedName)
// Determine position offset based on parent relationship:
// 1. Parent also being copied: keep exact relative position (parent itself will be offset)
// 2. Parent exists in existing workflow: use provided offset, but cap large viewport-based
// offsets since they don't make sense for relative positions
// 3. Top-level block (no parent): apply full paste offset
// Check if this block has a parent that's also being copied
// If so, it's a nested block and should keep its relative position (no offset)
// Only top-level blocks (no parent in the paste set) get the position offset
const hasParentInPasteSet = block.data?.parentId && blocks[block.data.parentId]
const hasParentInExistingWorkflow =
block.data?.parentId && existingBlockNames[block.data.parentId]
let newPosition: Position
if (hasParentInPasteSet) {
// Parent also being copied - keep exact relative position
newPosition = { x: block.position.x, y: block.position.y }
} else if (hasParentInExistingWorkflow) {
// Block stays in existing subflow - use provided offset unless it's viewport-based (large)
const isLargeOffset =
Math.abs(positionOffset.x) > LARGE_OFFSET_THRESHOLD ||
Math.abs(positionOffset.y) > LARGE_OFFSET_THRESHOLD
const effectiveOffset = isLargeOffset ? DEFAULT_DUPLICATE_OFFSET : positionOffset
newPosition = {
x: block.position.x + effectiveOffset.x,
y: block.position.y + effectiveOffset.y,
}
} else {
// Top-level block - apply full paste offset
newPosition = {
x: block.position.x + positionOffset.x,
y: block.position.y + positionOffset.y,
}
}
const newPosition = hasParentInPasteSet
? { x: block.position.x, y: block.position.y } // Keep relative position
: { x: block.position.x + positionOffset.x, y: block.position.y + positionOffset.y }
// Placeholder block - we'll update parentId in second pass
const newBlock: BlockState = {
@@ -493,30 +602,19 @@ export function regenerateBlockIds(
})
// Second pass: update parentId references for nested blocks
// If a block's parent is also being pasted, map to new parentId
// If parent exists in existing workflow, keep the original parentId (block stays in same subflow)
// Otherwise clear the parentId
// If a block's parent is also being pasted, map to new parentId; otherwise clear it
Object.entries(newBlocks).forEach(([, block]) => {
if (block.data?.parentId) {
const oldParentId = block.data.parentId
const newParentId = blockIdMap.get(oldParentId)
if (newParentId) {
// Parent is being pasted - map to new parent ID
block.data = {
...block.data,
parentId: newParentId,
extent: 'parent',
}
} else if (existingBlockNames[oldParentId]) {
// Parent exists in existing workflow - keep original parentId (block stays in same subflow)
block.data = {
...block.data,
parentId: oldParentId,
extent: 'parent',
}
} else {
// Parent doesn't exist anywhere - clear the relationship
block.data = { ...block.data, parentId: undefined, extent: undefined }
}
}
@@ -549,7 +647,7 @@ export function regenerateBlockIds(
}
})
updateBlockReferences(newBlocks, nameMap, false)
updateBlockReferences(newBlocks, blockIdMap, nameMap, false)
Object.entries(newSubBlockValues).forEach(([_, blockValues]) => {
Object.keys(blockValues).forEach((subBlockId) => {

View File

@@ -22,6 +22,8 @@ import {
WorkflowBuilder,
} from '@sim/testing'
import { beforeEach, describe, expect, it } from 'vitest'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
describe('workflow store', () => {
@@ -363,6 +365,30 @@ describe('workflow store', () => {
})
})
describe('duplicateBlock', () => {
it('should duplicate a block', () => {
const { addBlock, duplicateBlock } = useWorkflowStore.getState()
addBlock('original', 'agent', 'Original Agent', { x: 0, y: 0 })
duplicateBlock('original')
const { blocks } = useWorkflowStore.getState()
const blockIds = Object.keys(blocks)
expect(blockIds.length).toBe(2)
const duplicatedId = blockIds.find((id) => id !== 'original')
expect(duplicatedId).toBeDefined()
if (duplicatedId) {
expect(blocks[duplicatedId].type).toBe('agent')
expect(blocks[duplicatedId].name).toContain('Original Agent')
expect(blocks[duplicatedId].position.x).not.toBe(0)
}
})
})
describe('batchUpdatePositions', () => {
it('should update block position', () => {
const { addBlock, batchUpdatePositions } = useWorkflowStore.getState()
@@ -426,6 +452,29 @@ describe('workflow store', () => {
expect(state.loops.loop1.forEachItems).toBe('["a", "b", "c"]')
})
it('should regenerate loops when updateLoopCollection is called', () => {
const { addBlock, updateLoopCollection } = useWorkflowStore.getState()
addBlock(
'loop1',
'loop',
'Test Loop',
{ x: 0, y: 0 },
{
loopType: 'forEach',
collection: '["item1", "item2"]',
}
)
updateLoopCollection('loop1', '["item1", "item2", "item3"]')
const state = useWorkflowStore.getState()
expect(state.blocks.loop1?.data?.collection).toBe('["item1", "item2", "item3"]')
expect(state.loops.loop1).toBeDefined()
expect(state.loops.loop1.forEachItems).toBe('["item1", "item2", "item3"]')
})
it('should clamp loop count between 1 and 1000', () => {
const { addBlock, updateLoopCount } = useWorkflowStore.getState()
@@ -550,6 +599,118 @@ describe('workflow store', () => {
})
})
describe('mode switching', () => {
it('should toggle advanced mode on a block', () => {
const { addBlock, toggleBlockAdvancedMode } = useWorkflowStore.getState()
addBlock('agent1', 'agent', 'Test Agent', { x: 0, y: 0 })
let state = useWorkflowStore.getState()
expect(state.blocks.agent1?.advancedMode).toBe(false)
toggleBlockAdvancedMode('agent1')
state = useWorkflowStore.getState()
expect(state.blocks.agent1?.advancedMode).toBe(true)
toggleBlockAdvancedMode('agent1')
state = useWorkflowStore.getState()
expect(state.blocks.agent1?.advancedMode).toBe(false)
})
it('should preserve systemPrompt and userPrompt when switching modes', () => {
const { addBlock, toggleBlockAdvancedMode } = useWorkflowStore.getState()
const { setState: setSubBlockState } = useSubBlockStore
useWorkflowRegistry.setState({ activeWorkflowId: 'test-workflow' })
addBlock('agent1', 'agent', 'Test Agent', { x: 0, y: 0 })
setSubBlockState({
workflowValues: {
'test-workflow': {
agent1: {
systemPrompt: 'You are a helpful assistant',
userPrompt: 'Hello, how are you?',
},
},
},
})
toggleBlockAdvancedMode('agent1')
let subBlockState = useSubBlockStore.getState()
expect(subBlockState.workflowValues['test-workflow'].agent1.systemPrompt).toBe(
'You are a helpful assistant'
)
expect(subBlockState.workflowValues['test-workflow'].agent1.userPrompt).toBe(
'Hello, how are you?'
)
toggleBlockAdvancedMode('agent1')
subBlockState = useSubBlockStore.getState()
expect(subBlockState.workflowValues['test-workflow'].agent1.systemPrompt).toBe(
'You are a helpful assistant'
)
expect(subBlockState.workflowValues['test-workflow'].agent1.userPrompt).toBe(
'Hello, how are you?'
)
})
it('should preserve memories when switching from advanced to basic mode', () => {
const { addBlock, toggleBlockAdvancedMode } = useWorkflowStore.getState()
const { setState: setSubBlockState } = useSubBlockStore
useWorkflowRegistry.setState({ activeWorkflowId: 'test-workflow' })
addBlock('agent1', 'agent', 'Test Agent', { x: 0, y: 0 })
toggleBlockAdvancedMode('agent1')
setSubBlockState({
workflowValues: {
'test-workflow': {
agent1: {
systemPrompt: 'You are a helpful assistant',
userPrompt: 'What did we discuss?',
memories: [
{ role: 'user', content: 'My name is John' },
{ role: 'assistant', content: 'Nice to meet you, John!' },
],
},
},
},
})
toggleBlockAdvancedMode('agent1')
const subBlockState = useSubBlockStore.getState()
expect(subBlockState.workflowValues['test-workflow'].agent1.systemPrompt).toBe(
'You are a helpful assistant'
)
expect(subBlockState.workflowValues['test-workflow'].agent1.userPrompt).toBe(
'What did we discuss?'
)
expect(subBlockState.workflowValues['test-workflow'].agent1.memories).toEqual([
{ role: 'user', content: 'My name is John' },
{ role: 'assistant', content: 'Nice to meet you, John!' },
])
})
it('should handle mode switching when no subblock values exist', () => {
const { addBlock, toggleBlockAdvancedMode } = useWorkflowStore.getState()
useWorkflowRegistry.setState({ activeWorkflowId: 'test-workflow' })
addBlock('agent1', 'agent', 'Test Agent', { x: 0, y: 0 })
expect(useWorkflowStore.getState().blocks.agent1?.advancedMode).toBe(false)
expect(() => toggleBlockAdvancedMode('agent1')).not.toThrow()
const state = useWorkflowStore.getState()
expect(state.blocks.agent1?.advancedMode).toBe(true)
})
it('should not throw when toggling non-existent block', () => {
const { toggleBlockAdvancedMode } = useWorkflowStore.getState()
expect(() => toggleBlockAdvancedMode('non-existent')).not.toThrow()
})
})
describe('workflow state management', () => {
it('should work with WorkflowBuilder for complex setups', () => {
const workflowState = WorkflowBuilder.linear(3).build()

View File

@@ -2,16 +2,20 @@ import { createLogger } from '@sim/logger'
import type { Edge } from 'reactflow'
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants'
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { getBlock } from '@/blocks'
import type { SubBlockConfig } from '@/blocks/types'
import { normalizeName, RESERVED_BLOCK_NAMES } from '@/executor/constants'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { filterNewEdges, filterValidEdges } from '@/stores/workflows/utils'
import {
filterNewEdges,
filterValidEdges,
getUniqueBlockName,
mergeSubblockState,
} from '@/stores/workflows/utils'
import type {
BlockState,
Position,
SubBlockState,
WorkflowState,
@@ -135,30 +139,30 @@ export const useWorkflowStore = create<WorkflowStore>()(
...(parentId && { parentId, extent: extent || 'parent' }),
}
const newBlocks = {
...get().blocks,
[id]: {
id,
type,
name,
position,
subBlocks: {},
outputs: {},
enabled: blockProperties?.enabled ?? true,
horizontalHandles: blockProperties?.horizontalHandles ?? true,
advancedMode: blockProperties?.advancedMode ?? false,
triggerMode: blockProperties?.triggerMode ?? false,
height: blockProperties?.height ?? 0,
data: nodeData,
const newState = {
blocks: {
...get().blocks,
[id]: {
id,
type,
name,
position,
subBlocks: {},
outputs: {},
enabled: blockProperties?.enabled ?? true,
horizontalHandles: blockProperties?.horizontalHandles ?? true,
advancedMode: blockProperties?.advancedMode ?? false,
triggerMode: blockProperties?.triggerMode ?? false,
height: blockProperties?.height ?? 0,
data: nodeData,
},
},
edges: [...get().edges],
loops: get().generateLoopBlocks(),
parallels: get().generateParallelBlocks(),
}
set({
blocks: newBlocks,
edges: [...get().edges],
loops: generateLoopBlocks(newBlocks),
parallels: generateParallelBlocks(newBlocks),
})
set(newState)
get().updateLastSaved()
return
}
@@ -211,31 +215,31 @@ export const useWorkflowStore = create<WorkflowStore>()(
const triggerMode = blockProperties?.triggerMode ?? false
const outputs = getBlockOutputs(type, subBlocks, triggerMode)
const newBlocks = {
...get().blocks,
[id]: {
id,
type,
name,
position,
subBlocks,
outputs,
enabled: blockProperties?.enabled ?? true,
horizontalHandles: blockProperties?.horizontalHandles ?? true,
advancedMode: blockProperties?.advancedMode ?? false,
triggerMode: triggerMode,
height: blockProperties?.height ?? 0,
layout: {},
data: nodeData,
const newState = {
blocks: {
...get().blocks,
[id]: {
id,
type,
name,
position,
subBlocks,
outputs,
enabled: blockProperties?.enabled ?? true,
horizontalHandles: blockProperties?.horizontalHandles ?? true,
advancedMode: blockProperties?.advancedMode ?? false,
triggerMode: triggerMode,
height: blockProperties?.height ?? 0,
layout: {},
data: nodeData,
},
},
edges: [...get().edges],
loops: get().generateLoopBlocks(),
parallels: get().generateParallelBlocks(),
}
set({
blocks: newBlocks,
edges: [...get().edges],
loops: generateLoopBlocks(newBlocks),
parallels: generateParallelBlocks(newBlocks),
})
set(newState)
get().updateLastSaved()
},
@@ -444,41 +448,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
delete newBlocks[blockId]
})
// Clean up orphaned nodes - blocks whose parent was removed but weren't descendants
// This can happen in edge cases (e.g., data inconsistency, external modifications)
const remainingBlockIds = new Set(Object.keys(newBlocks))
const CONTAINER_OFFSET = {
x: CONTAINER_DIMENSIONS.LEFT_PADDING,
y: CONTAINER_DIMENSIONS.HEADER_HEIGHT + CONTAINER_DIMENSIONS.TOP_PADDING,
}
Object.entries(newBlocks).forEach(([blockId, block]) => {
const parentId = block.data?.parentId
if (parentId && !remainingBlockIds.has(parentId)) {
// Parent was removed - convert to absolute position and clear parentId
// Child positions are relative to container content area (after header + padding)
let absoluteX = block.position.x
let absoluteY = block.position.y
// Traverse up the parent chain, adding position + container offset for each level
let currentParentId: string | undefined = parentId
while (currentParentId) {
const parent: BlockState | undefined = currentBlocks[currentParentId]
if (!parent) break
absoluteX += parent.position.x + CONTAINER_OFFSET.x
absoluteY += parent.position.y + CONTAINER_OFFSET.y
currentParentId = parent.data?.parentId
}
const { parentId: _removed, extent: _removedExtent, ...restData } = block.data || {}
newBlocks[blockId] = {
...block,
position: { x: absoluteX, y: absoluteY },
data: Object.keys(restData).length > 0 ? restData : undefined,
}
}
})
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (activeWorkflowId) {
const subBlockStore = useSubBlockStore.getState()
@@ -615,20 +584,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
options?: { updateLastSaved?: boolean }
) => {
set((state) => {
const incomingBlocks = workflowState.blocks || {}
const nextBlocks: typeof incomingBlocks = {}
for (const [id, block] of Object.entries(incomingBlocks)) {
if (block.data?.parentId && !incomingBlocks[block.data.parentId]) {
nextBlocks[id] = {
...block,
data: { ...block.data, parentId: undefined, extent: undefined },
}
} else {
nextBlocks[id] = block
}
}
const nextBlocks = workflowState.blocks || {}
const nextEdges = filterValidEdges(workflowState.edges || [], nextBlocks)
const nextLoops =
Object.keys(workflowState.loops || {}).length > 0
@@ -679,6 +635,66 @@ export const useWorkflowStore = create<WorkflowStore>()(
get().updateLastSaved()
},
duplicateBlock: (id: string) => {
const block = get().blocks[id]
if (!block) return
const newId = crypto.randomUUID()
const offsetPosition = {
x: block.position.x + DEFAULT_DUPLICATE_OFFSET.x,
y: block.position.y + DEFAULT_DUPLICATE_OFFSET.y,
}
const newName = getUniqueBlockName(block.name, get().blocks)
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
const mergedBlock = mergeSubblockState(get().blocks, activeWorkflowId || undefined, id)[id]
const newSubBlocks = Object.entries(mergedBlock.subBlocks).reduce(
(acc, [subId, subBlock]) => ({
...acc,
[subId]: {
...subBlock,
value: JSON.parse(JSON.stringify(subBlock.value)),
},
}),
{}
)
const newState = {
blocks: {
...get().blocks,
[newId]: {
...block,
id: newId,
name: newName,
position: offsetPosition,
subBlocks: newSubBlocks,
},
},
edges: [...get().edges],
loops: get().generateLoopBlocks(),
parallels: get().generateParallelBlocks(),
}
if (activeWorkflowId) {
const subBlockValues =
useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[id] || {}
useSubBlockStore.setState((state) => ({
workflowValues: {
...state.workflowValues,
[activeWorkflowId]: {
...state.workflowValues[activeWorkflowId],
[newId]: JSON.parse(JSON.stringify(subBlockValues)),
},
},
}))
}
set(newState)
get().updateLastSaved()
},
setBlockHandles: (id: string, horizontalHandles: boolean) => {
const block = get().blocks[id]
if (!block || block.horizontalHandles === horizontalHandles) return
@@ -874,10 +890,27 @@ export const useWorkflowStore = create<WorkflowStore>()(
get().updateLastSaved()
},
setBlockTriggerMode: (id: string, triggerMode: boolean) => {
set((state) => ({
blocks: {
...state.blocks,
[id]: {
...state.blocks[id],
triggerMode,
},
},
edges: [...state.edges],
loops: { ...state.loops },
}))
get().updateLastSaved()
// Note: Socket.IO handles real-time sync automatically
},
updateBlockLayoutMetrics: (id: string, dimensions: { width: number; height: number }) => {
set((state) => {
const block = state.blocks[id]
if (!block) {
logger.warn(`Cannot update layout metrics: Block ${id} not found in workflow store`)
return state
}
@@ -899,6 +932,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
}
})
get().updateLastSaved()
// No sync needed for layout changes, just visual
},
updateLoopCount: (loopId: string, count: number) =>
@@ -1016,6 +1050,30 @@ export const useWorkflowStore = create<WorkflowStore>()(
}
}),
updateLoopCollection: (loopId: string, collection: string) => {
const store = get()
const block = store.blocks[loopId]
if (!block || block.type !== 'loop') return
const loopType = block.data?.loopType || 'for'
if (loopType === 'while') {
store.setLoopWhileCondition(loopId, collection)
} else if (loopType === 'doWhile') {
store.setLoopDoWhileCondition(loopId, collection)
} else if (loopType === 'forEach') {
store.setLoopForEachItems(loopId, collection)
} else {
// Default to forEach-style storage for backward compatibility
store.setLoopForEachItems(loopId, collection)
}
},
// Function to convert UI loop blocks to execution format
generateLoopBlocks: () => {
return generateLoopBlocks(get().blocks)
},
triggerUpdate: () => {
set((state) => ({
...state,
@@ -1103,6 +1161,28 @@ export const useWorkflowStore = create<WorkflowStore>()(
}
},
toggleBlockAdvancedMode: (id: string) => {
const block = get().blocks[id]
if (!block) return
const newState = {
blocks: {
...get().blocks,
[id]: {
...block,
advancedMode: !block.advancedMode,
},
},
edges: [...get().edges],
loops: { ...get().loops },
}
set(newState)
get().triggerUpdate()
// Note: Socket.IO handles real-time sync automatically
},
// Parallel block methods implementation
updateParallelCount: (parallelId: string, count: number) => {
const block = get().blocks[parallelId]
@@ -1128,6 +1208,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
set(newState)
get().updateLastSaved()
// Note: Socket.IO handles real-time sync automatically
},
updateParallelCollection: (parallelId: string, collection: string) => {
@@ -1154,6 +1235,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
set(newState)
get().updateLastSaved()
// Note: Socket.IO handles real-time sync automatically
},
updateParallelType: (parallelId: string, parallelType: 'count' | 'collection') => {
@@ -1180,6 +1262,12 @@ export const useWorkflowStore = create<WorkflowStore>()(
set(newState)
get().updateLastSaved()
// Note: Socket.IO handles real-time sync automatically
},
// Function to convert UI parallel blocks to execution format
generateParallelBlocks: () => {
return generateParallelBlocks(get().blocks)
},
setDragStartPosition: (position) => {

View File

@@ -214,6 +214,7 @@ export interface WorkflowActions {
clear: () => Partial<WorkflowState>
updateLastSaved: () => void
setBlockEnabled: (id: string, enabled: boolean) => void
duplicateBlock: (id: string) => void
setBlockHandles: (id: string, horizontalHandles: boolean) => void
updateBlockName: (
id: string,
@@ -224,18 +225,23 @@ export interface WorkflowActions {
}
setBlockAdvancedMode: (id: string, advancedMode: boolean) => void
setBlockCanonicalMode: (id: string, canonicalId: string, mode: 'basic' | 'advanced') => void
setBlockTriggerMode: (id: string, triggerMode: boolean) => void
updateBlockLayoutMetrics: (id: string, dimensions: { width: number; height: number }) => void
triggerUpdate: () => void
updateLoopCount: (loopId: string, count: number) => void
updateLoopType: (loopId: string, loopType: 'for' | 'forEach' | 'while' | 'doWhile') => void
updateLoopCollection: (loopId: string, collection: string) => void
setLoopForEachItems: (loopId: string, items: any) => void
setLoopWhileCondition: (loopId: string, condition: string) => void
setLoopDoWhileCondition: (loopId: string, condition: string) => void
updateParallelCount: (parallelId: string, count: number) => void
updateParallelCollection: (parallelId: string, collection: string) => void
updateParallelType: (parallelId: string, parallelType: 'count' | 'collection') => void
generateLoopBlocks: () => Record<string, Loop>
generateParallelBlocks: () => Record<string, Parallel>
setNeedsRedeploymentFlag: (needsRedeployment: boolean) => void
revertToDeployedState: (deployedState: WorkflowState) => void
toggleBlockAdvancedMode: (id: string) => void
setDragStartPosition: (position: DragStartPosition | null) => void
getDragStartPosition: () => DragStartPosition | null
getWorkflowState: () => WorkflowState