Compare commits

...

53 Commits

Author SHA1 Message Date
Waleed Latif
463ef80490 improvement(mcp): extract extractStringHeaders helper for DRY 2026-02-27 14:13:24 -08:00
Waleed Latif
6a3e350ce4 fix(mcp): deduplicate json validation, remove redundant state resets, clean up timer 2026-02-27 14:10:51 -08:00
Waleed Latif
9b72b5c83b fix(mcp): validate json headers, add domain check, fix empty api key header 2026-02-27 13:35:46 -08:00
Waleed Latif
e41fbcc266 feat(mcp): add sim tab and json config input for mcp servers 2026-02-27 13:13:10 -08:00
Waleed
4fd0989264 v0.5.101: circular dependency mitigation, confluence enhancements, google tasks and bigquery integrations, workflow lock 2026-02-26 15:04:53 -08:00
Waleed
345a95f48d fix(confluence): prevent content erasure on page/blogpost update and fix space update (#3356)
- Add body-format=storage to GET-before-PUT for page and blogpost updates
  (without this, Confluence v2 API does not return body content, causing
  the fallback to erase content when only updating the title)
- Fetch current space name when updating only description (Confluence API
  requires name on PUT, so we preserve the existing name automatically)
2026-02-26 14:52:57 -08:00
Waleed
e07963f88c chore(db): drop 8 redundant indexes and add partial index for stale execution cleanup (#3354) 2026-02-26 13:17:39 -08:00
Waleed
25c59e3e2e feat(devin): add devin integration for autonomous coding sessions (#3352)
* feat(devin): add devin integration for autonomous coding sessions

* lint

* improvement(devin): update tool names and add manual docs description

* improvement(devin): rename tool files to snake_case and regenerate docs

* regen docs

* fix(devin): remove redundant Number() conversions in tool request bodies
2026-02-26 11:57:50 -08:00
Waleed
dde098e8e5 fix: prevent raw workflowInput from overwriting coerced start block values (#3347)
buildUnifiedStartOutput and buildIntegrationTriggerOutput first populate
output with schema-coerced structuredInput values (via coerceValue), then
iterate workflowInput and unconditionally overwrite those keys with raw
strings. This causes typed values (arrays, objects, numbers, booleans)
passed to child workflows to arrive as stringified versions.

Add a structuredKeys guard so the workflowInput loop skips keys already
set by the coerced structuredInput, letting coerceValue's type-aware
parsing (JSON.parse for objects/arrays, Number() for numbers, etc.)
take effect.

Fixes #3105

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 07:13:19 -08:00
Waleed
5ae0115444 feat(sidebar): add lock/unlock to workflow registry context menu (#3350)
* feat(sidebar): add lock/unlock to workflow registry context menu

* docs(tools): add manual descriptions to google_books and table

* docs(tools): add manual descriptions to google_bigquery and google_tasks

* fix(sidebar): avoid unnecessary store subscriptions and fix mixed lock state toggle

* fix(sidebar): use getWorkflowLockToggleIds utility for lock toggle

Replaces manual pivot-sorting logic with the existing utility function,
which handles block ordering and no-op guards consistently.

* lint
2026-02-25 23:40:30 -08:00
Waleed
fbafe204e5 fix(confluence): add input validation for SSRF-flagged parameters (#3351) 2026-02-25 23:35:45 -08:00
Waleed
ba7d6ff298 fix(credential-selector): remove reserved icon space when no credential selected (#3348) 2026-02-25 22:29:35 -08:00
Waleed
40016e79a1 feat(google-tasks): add Google Tasks integration (#3342)
* feat(google-tasks): add Google Tasks integration

* fix(google-tasks): return actual taskId in delete response

* fix(google-tasks): use absolute imports and fix registry order

* fix(google-tasks): rename list-task-lists to list_task_lists for doc generator

* improvement(google-tasks): destructure task and taskList outputs with typed schemas

* ran lint

* improvement(google-tasks): add wandConfig for due date timestamp generation
2026-02-25 21:52:34 -08:00
Waleed
e4fb8b2fdd feat(bigquery): add Google BigQuery integration (#3341)
* feat(bigquery): add Google BigQuery integration

* fix(bigquery): add auth provider, fix docsLink and insertedRows count

* fix(bigquery): set pageToken visibility to user-or-llm for pagination

* fix(bigquery): use prefixed export names to avoid aliased imports

* lint

* improvement(bigquery): destructure tool outputs with structured array/object types

* lint
2026-02-25 19:31:06 -08:00
Waleed
d98545d554 fix(terminal): thread executionOrder through child workflow SSE events for loop support (#3346)
* fix(terminal): thread executionOrder through child workflow SSE events for loop support

* ran lint

* fix(terminal): render iteration children through EntryNodeRow for workflow block expansion

IterationNodeRow was rendering all children as flat BlockRow components,
ignoring nodeType. Workflow blocks inside loop iterations were never
rendered as WorkflowNodeRow, so they had no expand chevron or child tree.

* fix(terminal): add childWorkflowBlockId to matchesEntryForUpdate

Sub-executors reset executionOrderCounter, so child blocks across loop
iterations share the same blockId + executionOrder. Without checking
childWorkflowBlockId, updateConsole for iteration N overwrites entries
from iterations 0..N-1, causing all child blocks to be grouped under
the last iteration's workflow instance.
2026-02-25 19:02:44 -08:00
Waleed
fadbad4085 feat(confluence): add get user by account ID tool (#3345)
* feat(confluence): add get user by account ID tool

* feat(confluence): add missing tools for tasks, blog posts, spaces, descendants, permissions, and properties

Add 16 new Confluence operations: list/get/update tasks, update/delete blog posts,
create/update/delete spaces, get page descendants, list space permissions,
list/create/delete space properties. Includes API routes, tool definitions,
block config wiring, OAuth scopes, and generated docs.

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

* fix(confluence): add missing OAuth scopes to auth.ts provider config

The OAuth authorization flow uses scopes from auth.ts, not oauth.ts.
The 9 new scopes were only added to oauth.ts and the block config but
not to the actual provider config in auth.ts, causing re-auth to still
return tokens without the new scopes.

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

* lint

* fix(confluence): fix truncated get_user tool description in docs

Remove apostrophe from description that caused MDX generation to
truncate at the escape character.

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

* fix(confluence): address PR review feedback

- Move get_user from GET to POST to avoid exposing access token in URL
- Add 400 validation for missing params in space-properties create/delete
- Add null check for blog post version before update to prevent TypeError

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

* feat(confluence): add missing response fields for descendants and tasks

- Add type and depth fields to page descendants (from Confluence API)
- Add body field (storage format) to task list/get/update responses

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

* lint

* fix(confluence): use validatePathSegment for Atlassian account IDs

validateAlphanumericId rejects valid Atlassian account IDs that contain
colons (e.g. 557058:6b9c9931-4693-49c1-8b3a-931f1af98134). Use
validatePathSegment with a custom pattern allowing colons instead.

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

* ran lint

* update mock

* upgrade turborepo

* fix(confluence): reject empty update body for space PUT

Return 400 when neither name nor description is provided for space
update, instead of sending an empty body to the Confluence API.

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

* fix(confluence): remove spaceId requirement for create_space and fix list_tasks pagination

- Remove create_space from spaceId condition array since creating a space
  doesn't require a space ID input
- Remove list_tasks from generic supportsCursor array so it uses its
  dedicated handler that correctly passes assignedTo and status filters
  during pagination

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

* ran lint

* fixed type errors

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 17:16:53 -08:00
Waleed
244e1ee495 feat(workflow): lock/unlock workflow from context menu and panel (#3336)
* feat(workflow): lock/unlock workflow from context menu and panel

* lint

* fix(workflow): prevent duplicate lock notifications, no-op guard, fix orphaned JSDoc

* improvement(workflow): memoize hasLockedBlocks to avoid inline recomputation

* feat(google-translate): add Google Translate integration (#3337)

* feat(google-translate): add Google Translate integration

* fix(google-translate): api key as query param, fix docsLink, rename tool file

* feat(google): add missing tools for Gmail, Drive, Sheets, and Calendar (#3338)

* feat(google): add missing tools for Gmail, Drive, Sheets, and Calendar

* fix(google-drive): remove dead transformResponse from move tool

* feat(confluence): return page content in get page version tool (#3344)

* feat(confluence): return page content in get page version tool

* lint

* feat(api): audit log read endpoints for admin and enterprise (#3343)

* feat(api): audit log read endpoints for admin and enterprise

* fix(api): address PR review — boolean coercion, cursor validation, detail scope

* ran lint

* unified list of languages for google translate

* fix(workflow): respect snapshot view for panel lock toggle, remove unused disableAdmin prop

* improvement(canvas-menu): remove lock icon from workflow lock toggle

* feat(audit): record audit log for workflow lock/unlock
2026-02-25 15:23:30 -08:00
Waleed
1f3dc52d15 feat(api): audit log read endpoints for admin and enterprise (#3343)
* feat(api): audit log read endpoints for admin and enterprise

* fix(api): address PR review — boolean coercion, cursor validation, detail scope

* ran lint
2026-02-25 13:46:37 -08:00
Waleed
f625482bcb feat(confluence): return page content in get page version tool (#3344)
* feat(confluence): return page content in get page version tool

* lint
2026-02-25 13:45:19 -08:00
Waleed
16f337f6fd feat(google): add missing tools for Gmail, Drive, Sheets, and Calendar (#3338)
* feat(google): add missing tools for Gmail, Drive, Sheets, and Calendar

* fix(google-drive): remove dead transformResponse from move tool
2026-02-25 13:38:35 -08:00
Waleed
063ec87ced feat(google-translate): add Google Translate integration (#3337)
* feat(google-translate): add Google Translate integration

* fix(google-translate): api key as query param, fix docsLink, rename tool file
2026-02-25 13:24:22 -08:00
Waleed
870d4b55c6 fix(templates): show description tagline on template cards (#3335) 2026-02-25 12:10:22 -08:00
Waleed
95304b2941 feat(google-sheets): add filter support to read operation (#3333)
* feat(google-sheets): add filter support to read operation

* ran lint
2026-02-25 11:34:12 -08:00
Waleed
8b0c47b06c chore(executor): extract shared utils and remove dead code from handlers (#3334) 2026-02-25 11:28:16 -08:00
Vikhyath Mondreti
774771fddd fix(call-chain): x-sim-via propagation for API blocks and MCP tools (#3332)
* fix(call-chain): x-sim-via propagation for API blocks and MCP tools

* addres bugbot comment
2026-02-25 08:41:54 -08:00
Waleed
67f8a687f6 v0.5.100: multiple credentials, 40% speedup, gong, attio, audit log improvements 2026-02-25 00:28:25 -08:00
Waleed
43c0f5b199 feat(api): retry configuration for api block (#3329)
* fix(api): add configurable request retries

The API block docs described automatic retries, but the block didn't expose any retry controls and requests were executed only once.

This adds tool-level retry support with exponential backoff (including Retry-After support) for timeouts, 429s, and 5xx responses, exposes retry settings in the API block and http_request tool, and updates the docs to match.

Fixes #3225

* remove unnecessary helpers, cleanup

* update desc

* ack comments

* ack comment

* ack

* handle timeouts

---------

Co-authored-by: Jay Prajapati <79649559+jayy-77@users.noreply.github.com>
2026-02-25 00:13:47 -08:00
Waleed
ff01825b20 docs(credentials): replace environment variables page with credentials docs (#3331) 2026-02-25 00:02:16 -08:00
Vikhyath Mondreti
58d0fda173 fix(serializer): default canonical modes construction (#3330)
* fix(serializer): default canonical modes construction

* defaults for copilot

* address bugbot comments
2026-02-24 22:05:17 -08:00
Waleed
ecdb133d1b improvement(creds): bulk paste functionality, save notification, error notif (#3328)
* improvement(creds): bulk paste functionality, save notification, error notif

* use effect anti patterns

* fix add to cursor button

* fix(attio): wrap webhook body in data object and include required filter field

* fixed and tested attio webhook lifecycle
2026-02-24 19:12:10 -08:00
Waleed
d06459f489 fix(attio): automatic webhook lifecycle management and tool fixes (#3327)
* fix(attio): use code subblock type for JSON input fields

* fix(attio): correct people name attribute format in wand prompt example

* fix(attio): improve wand prompt with correct attribute formats for all field types

* fix(attio): use array format with full_name for personal-name attribute in wand prompt

* fix(attio): use loose null checks to prevent sending null params to API

* fix(attio): add offset param and make pagination fields advanced mode

* fix(attio): remove redundant (optional) from placeholders

* fix(attio): always send required workspace_access and workspace_member_access in create list

* fix(attio): always send api_slug in create list, auto-generate from name if not provided

* fix(attio): update api slug placeholder text

* fix(tools): manage lifecycle for attio tools

* updated docs

* fix(attio): remove incorrect save button reference from setup instructions

* fix(attio): log debug message when signature verification is skipped
2026-02-24 17:30:52 -08:00
Waleed
0574427d45 fix(providers): propagate abort signal to all LLM SDK calls (#3325)
* fix(providers): propagate abort signal to all LLM SDK calls

* fix(providers): propagate abort signal to deep research interactions API

* fix(providers): clean up abort listener when sleep timer resolves
2026-02-24 14:59:02 -08:00
Emir Karabeg
8f9b859a53 improvement(credentials): ui (#3322)
* improvement(credentials): ui

* fix: credentials logic

* improvement(credentials): ui

* improvement(credentials): members UI

* improvement(secrets): ui

* fix(credentials): show error when OAuth deletion fails due to missing fields

- Add deleteError state to track and display deletion errors
- Keep confirmation dialog open when deletion fails
- Show user-friendly error message when accountId or providerId is missing
- Add loading state to delete button during deletion
- Display error message in confirmation dialog with proper styling

Co-authored-by: Emir Karabeg <emir-karabeg@users.noreply.github.com>

* ran lint

* removed worktree file

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Emir Karabeg <emir-karabeg@users.noreply.github.com>
Co-authored-by: Waleed Latif <walif6@gmail.com>
2026-02-24 14:48:13 -08:00
Waleed
60f9eb21bf feat(attio): add Attio CRM integration with 40 tools and 18 webhook triggers (#3324)
* feat(attio): add Attio CRM integration with 40 tools and 18 webhook triggers

* update docs

* fix(attio): use timestamp generationType for date wandConfig fields
2026-02-24 13:56:42 -08:00
Waleed
9a31c7d8ad improvement(processing): reduce redundant DB queries in execution preprocessing (#3320)
* improvement(processing): reduce redundant DB queries in execution preprocessing

* improvement(processing): add defensive ID check for prefetched workflow record

* improvement(processing): fix type safety in execution error logging

Replace `as any` cast in non-SSE error path with proper `buildTraceSpans()`
transformation, matching the SSE error path. Remove redundant `as any` cast
in preprocessing.ts where the types already align.

* improvement(processing): replace `as any` casts with proper types in logging

- logger.ts: cast JSONB cost column to `WorkflowExecutionLog['cost']` instead
  of `any` in both `completeWorkflowExecution` and `getWorkflowExecution`
- logger.ts: replace `(orgUsageBefore as any)?.toString?.()` with `String()`
  since COALESCE guarantees a non-null SQL aggregate value
- logging-session.ts: cast JSONB cost to `AccumulatedCost` (the local
  interface) instead of `any` in `loadExistingCost`

* improvement(processing): use exported HighestPrioritySubscription type in usage.ts

Replace inline `Awaited<ReturnType<typeof getHighestPrioritySubscription>>`
with the already-exported `HighestPrioritySubscription` type alias.

* improvement(processing): replace remaining `as any` casts with proper types

- preprocessing.ts: use exported `HighestPrioritySubscription` type instead
  of redeclaring via `Awaited<ReturnType<...>>`
- deploy/route.ts, status/route.ts: cast `hasWorkflowChanged` args to
  `WorkflowState` instead of `any` (JSONB + object literal narrowing)
- state/route.ts: type block sanitization and save with `BlockState` and
  `WorkflowState` instead of `any`
- search-suggestions.ts: remove 8 unnecessary `as any` casts on `'date'`
  literal that already satisfies the `Suggestion['category']` union

* fix(processing): prevent double-billing race in LoggingSession completion

When executeWorkflowCore throws, its catch block fire-and-forgets
safeCompleteWithError, then re-throws. The caller's catch block also
fire-and-forgets safeCompleteWithError on the same LoggingSession. Both
check this.completed (still false) before either's async DB write resolves,
so both proceed to completeWorkflowExecution which uses additive SQL for
billing — doubling the charged cost on every failed execution.

Fix: add a synchronous `completing` flag set immediately before the async
work begins. This blocks concurrent callers at the guard check. On failure,
the flag is reset so the safe* fallback path (completeWithCostOnlyLog) can
still attempt recovery.

* fix(processing): unblock error responses and isolate run-count failures

Remove unnecessary `await waitForCompletion()` from non-SSE and SSE error
paths where no `markAsFailed()` follows — these were blocking error responses
on log persistence for no reason. Wrap `updateWorkflowRunCounts` in its own
try/catch so a run-count DB failure cannot prevent session completion, billing,
and trace span persistence.

* improvement(processing): remove dead setupExecutor method

The method body was just a debug log with an `any` parameter — logging
now works entirely through trace spans with no executor integration.

* remove logger.debug

* fix(processing): guard completionPromise as write-once (singleton promise)

Prevent concurrent safeComplete* calls from overwriting completionPromise
with a no-op. The guard now lives at the assignment site — if a completion
is already in-flight, return its promise instead of starting a new one.
This ensures waitForCompletion() always awaits the real work.

* improvement(processing): remove empty else/catch blocks left by debug log cleanup

* fix(processing): enforce waitForCompletion inside markAsFailed to prevent completion races

Move waitForCompletion() into markAsFailed() so every call site is
automatically safe against in-flight fire-and-forget completions.
Remove the now-redundant external waitForCompletion() calls in route.ts.

* fix(processing): reset completing flag on fallback failure, clean up empty catch

- completeWithCostOnlyLog now resets this.completing = false when
  the fallback itself fails, preventing a permanently stuck session
- Use _disconnectError in MCP test-connection to signal intentional ignore

* fix(processing): restore disconnect error logging in MCP test-connection

Revert unrelated debug log removal — this file isn't part of the
processing improvements and the log aids connection leak detection.

* fix(processing): address audit findings across branch

- preprocessing.ts: use undefined (not null) for failed subscription
  fetch so getUserUsageLimit does a fresh lookup instead of silently
  falling back to free-tier limits
- deployed/route.ts: log warning on loadDeployedWorkflowState failure
  instead of silently swallowing the error
- schedule-execution.ts: remove dead successLog parameter and all
  call-site arguments left over from logger.debug cleanup
- mcp/middleware.ts: drop unused error binding in empty catch
- audit/log.ts, wand.ts: promote logger.debug to logger.warn in catch
  blocks where these are the only failure signal

* revert: undo unnecessary subscription null→undefined change

getHighestPrioritySubscription never throws (it catches internally
and returns null), so the catch block in preprocessExecution is dead
code. The null vs undefined distinction doesn't matter and the
coercions added unnecessary complexity.

* improvement(processing): remove dead try/catch around getHighestPrioritySubscription

getHighestPrioritySubscription catches internally and returns null
on error, so the wrapping try/catch was unreachable dead code.

* improvement(processing): remove dead getSnapshotByHash method

No longer called after createSnapshotWithDeduplication was refactored
to use a single upsert instead of select-then-insert.

---------
2026-02-24 11:55:59 -08:00
Jay Prajapati
9e817bc5b0 fix(auth): make DISABLE_AUTH work in web app (#3297)
Return an anonymous session using the same response envelope as Better Auth's get-session endpoint, and make the session provider tolerant to both wrapped and raw session payloads.

Fixes #2524
2026-02-24 09:52:44 -08:00
Waleed
d824ce5b07 feat(confluence): add webhook triggers for Confluence events (#3318)
* feat(confluence): add webhook triggers for Confluence events

Adds 16 Confluence triggers: page CRUD, comments, blogs, attachments,
spaces, and labels — plus a generic webhook trigger.

* feat(confluence): wire triggers into block and webhook processor

Add trigger subBlocks and triggers config to ConfluenceV2Block so
triggers appear in the UI. Add Confluence signature verification and
event filtering to the webhook processor.

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

* fix(confluence): align trigger outputs with actual webhook payloads

- Rewrite output builders to match real Confluence webhook payload
  structure (flat spaceKey, numeric version, actual API fields)
- Remove fabricated fields (nested space/version objects, comment.body)
- Add missing fields (creatorAccountId, lastModifierAccountId, self,
  creationDate, modificationDate, accountType)
- Add extractor functions (extractPageData, extractCommentData, etc.)
  following the same pattern as Jira
- Add formatWebhookInput handler for Confluence in utils.server.ts
  so payloads are properly destructured before reaching workflows
- Make event field matching resilient (check both event and webhookEvent)

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

* fix(confluence): handle generic webhook in formatWebhookInput

The generic webhook (confluence_webhook) was falling through to
extractPageData, which only returns the page field. For a catch-all
trigger that accepts all event types, preserve all entity fields
(page, comment, blog, attachment, space, label, content).

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

* fix(confluence): use payload-based filtering instead of nonexistent event field

Confluence Cloud webhooks don't include an event/webhookEvent field in the
body (unlike Jira). Replaced broken event string matching with structural
payload filtering that checks which entity key is present.

* lint

* fix(confluence): read webhookSecret instead of secret in signature verification

* fix(webhooks): read webhookSecret for jira, linear, and github signature verification

These providers define their secret subBlock with id: 'webhookSecret' but the
processor was reading providerConfig.secret which is always undefined, silently
skipping signature verification even when a secret is configured.

* fix(confluence): use event field for exact matching with entity-category fallback

Admin REST API webhooks (Settings > Webhooks) include an event field for
action-level filtering (page_created vs page_updated). Connect app webhooks
omit it, so we fall back to entity-category matching.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:36:43 -08:00
Waleed
9bd357f184 improvement(audit): enrich metadata across 23 audit log call sites (#3319)
* improvement(audit): enrich metadata across 23 audit log call sites

* improvement(audit): enrich metadata across 23 audit log call sites
2026-02-23 23:35:57 -08:00
Waleed
d4a014f423 feat(public-api): add env var and permission group controls to disable public API access (#3317)
Add DISABLE_PUBLIC_API / NEXT_PUBLIC_DISABLE_PUBLIC_API environment variables
and disablePublicApi permission group config option to allow self-hosted
deployments and enterprise admins to globally disable the public API toggle.

When disabled: the Access toggle is hidden in the Edit API Info modal,
the execute route blocks unauthenticated public access (401), and the
public-api PATCH route rejects enabling public API (403).

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:03:03 -08:00
Waleed
fe34d23a98 feat(gong): add Gong integration with 18 API tools (#3316)
* feat(gong): add Gong integration with 18 API tools

* fix(gong): make toDateTime optional for list_calls, add list_trackers to workspaceId condition

* chore(gong): regenerate docs

* fix(hex): update icon color and block bgColor
2026-02-23 17:57:10 -08:00
Waleed
b8dfb4dd20 fix(copy): preserve block names when pasting into workflows without conflicts (#3315) 2026-02-23 15:42:24 -08:00
Waleed
91666491cd fix(execution): scope X-Sim-Via header to internal routes and enforce depth limit (#3313)
* feat(execution): workflow cycle detection via X-Sim-Via header

* fix(execution): scope X-Sim-Via header to internal routes and add child workflow depth validation

- Move call chain header injection from HTTP tool layer (request.ts/utils.ts)
  to tool execution layer (tools/index.ts) gated on isInternalRoute, preventing
  internal workflow IDs from leaking to external third-party APIs
- Remove cycle detection from validateCallChain — depth limit alone prevents
  infinite loops while allowing legitimate self-recursion (pagination, tree
  processing, batch splitting)
- Add validateCallChain check in workflow-handler.ts before spawning child
  executor, closing the gap where in-process child workflows skipped validation
- Remove unsafe `(params as any)._context` type bypass in request.ts

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

* fix(execution): validate child call chain instead of parent chain

Validate childCallChain (after appending current workflow ID) rather
than ctx.callChain (parent). Prevents an off-by-one where a chain at
depth 10 could still spawn an 11th workflow.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:19:31 -08:00
Waleed
eafbb9fef4 fix(tag-dropdown): exclude downstream blocks in loops and parallel siblings (#3312)
* fix(tag-dropdown): exclude downstream blocks in loops and parallel siblings from reference picker

* chore(serializer): remove unused computeAccessibleBlockIds method

* chore(block-path-calculator): remove unused calculateAccessibleBlocksForWorkflow method

* chore(tag-dropdown): remove no-op loop node filter

* fix(tag-dropdown): remove parallel container from accessible references in parallel branches

* chore(tag-dropdown): remove no-op starter block filter

* fix(tag-dropdown): restore parallel container in accessible references for blocks inside parallel

* fix(copilot): exclude downstream loop nodes and parallel siblings from accessible references
2026-02-23 14:21:40 -08:00
Waleed
132fef06a1 fix(redis): tighten stale TCP connection detection and add fast lease deadline (#3311)
* fix(redis): tighten stale TCP connection detection and add fast lease deadline

* revert(redis): restore original retryStrategy logging

* fix(redis): clear deadline timer after Promise.race to prevent memory leak

* fix(redis): downgrade lease fallback log to warn — unavailable is expected fallback
2026-02-23 13:22:29 -08:00
Waleed
af592349d3 v0.5.99: local dev improvements, live workflow logs in terminal 2026-02-23 00:24:49 -08:00
Waleed
0d86ea01f0 v0.5.98: change detection improvements, rate limit and code execution fixes, removed retired models, hex integration 2026-02-21 18:07:40 -08:00
Waleed
115f04e989 v0.5.97: oidc discovery for copilot mcp 2026-02-21 02:06:25 -08:00
Waleed
34d92fae89 v0.5.96: sim oauth provider, slack ephemeral message tool and blockkit support 2026-02-20 18:22:20 -08:00
Waleed
67aa4bb332 v0.5.95: gemini 3.1 pro, cloudflare, dataverse, revenuecat, redis, upstash, algolia tools; isolated-vm robustness improvements, tables backend (#3271)
* feat(tools): advanced fields for youtube, vercel; added cloudflare and dataverse tools (#3257)

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

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

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

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

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

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

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

* addded desc for dataverse

* add more tools

* ack comment

* more

* ops

---------

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

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

* updates

* required

* trashy table viewer

* updates

* updates

* filtering ui

* updates

* updates

* updates

* one input mode

* format

* fix lints

* improved errors

* updates

* updates

* chages

* doc strings

* breaking down file

* update comments with ai

* updates

* comments

* changes

* revert

* updates

* dedupe

* updates

* updates

* updates

* refactoring

* renames & refactors

* refactoring

* updates

* undo

* update db

* wand

* updates

* fix comments

* fixes

* simplify comments

* u[dates

* renames

* better comments

* validation

* updates

* updates

* updates

* fix sorting

* fix appearnce

* updating prompt to make it user sort

* rm

* updates

* rename

* comments

* clean comments

* simplicifcaiton

* updates

* updates

* refactor

* reduced type confusion

* undo

* rename

* undo changes

* undo

* simplify

* updates

* updates

* revert

* updates

* db updates

* type fix

* fix

* fix error handling

* updates

* docs

* docs

* updates

* rename

* dedupe

* revert

* uncook

* updates

* fix

* fix

* fix

* fix

* prepare merge

* readd migrations

* add back missed code

* migrate enrichment logic to general abstraction

* address bugbot concerns

* adhere to size limits for tables

* remove conflicting migration

* add back migrations

* fix tables auth

* fix permissive auth

* fix lint

* reran migrations

* migrate to use tanstack query for all server state

* update table-selector

* update names

* added tables to permission groups, updated subblock types

---------

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

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

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

* fixed ci tests failing

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

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

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

* ack comment

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

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

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

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

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

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

Closes #3258

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* lint

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

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Lakee Sivaraya <71339072+lakeesiv@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
2026-02-20 13:43:07 -08:00
Waleed
15ace5e63f v0.5.94: vercel integration, folder insertion, migrated tracking redirects to rewrites 2026-02-18 16:53:34 -08:00
Waleed
fdca73679d v0.5.93: NextJS config changes, MCP and Blocks whitelisting, copilot keyboard shortcuts, audit logs 2026-02-18 12:10:05 -08:00
Waleed
da46a387c9 v0.5.92: shortlinks, copilot scrolling stickiness, pagination 2026-02-17 15:13:21 -08:00
Waleed
b7e377ec4b v0.5.91: docs i18n, turborepo upgrade 2026-02-16 00:36:05 -08:00
417 changed files with 55712 additions and 3236 deletions

4
.gitignore vendored
View File

@@ -73,3 +73,7 @@ start-collector.sh
## Helm Chart Tests
helm/sim/test
i18n.cache
## Claude Code
.claude/launch.json
.claude/worktrees/

View File

@@ -710,6 +710,17 @@ export function NotionIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function GongIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 55.4 60' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path
fill='currentColor'
d='M54.1,25.7H37.8c-0.9,0-1.6,1-1.3,1.8l3.9,10.1c0.2,0.4-0.2,0.9-0.7,0.9l-5-0.3c-0.2,0-0.4,0.1-0.6,0.3L30.3,44c-0.2,0.3-0.6,0.4-1,0.2l-5.8-3.9c-0.2-0.2-0.5-0.2-0.8,0l-8,5.4c-0.5,0.4-1.2-0.1-1-0.7L16,37c0.1-0.3-0.1-0.7-0.4-0.8l-4.2-1.7c-0.4-0.2-0.6-0.7-0.3-1l3.7-4.6c0.2-0.2,0.2-0.6,0-0.8l-3.1-4.5c-0.3-0.4,0-1,0.5-1l4.9-0.4c0.4,0,0.6-0.3,0.6-0.7l-0.4-6.8c0-0.5,0.5-0.8,0.9-0.7l6,2.5c0.3,0.1,0.6,0,0.8-0.2l4.2-4.6c0.3-0.4,0.9-0.3,1.1,0.2l2.5,6.4c0.3,0.8,1.3,1.1,2,0.6l9.8-7.3c1.1-0.8,0.4-2.6-1-2.4L37.3,10c-0.3,0-0.6-0.1-0.7-0.4l-3.4-8.7c-0.4-0.9-1.5-1.1-2.2-0.4l-7.4,8c-0.2,0.2-0.5,0.3-0.8,0.2l-9.7-4.1c-0.9-0.4-1.8,0.2-1.9,1.2l-0.4,10c0,0.4-0.3,0.6-0.6,0.6l-8.9,0.6c-1,0.1-1.6,1.2-1,2.1l5.9,8.7c0.2,0.2,0.2,0.6,0,0.8l-6,6.9C-0.3,36,0,37.1,0.8,37.4l6.9,3c0.3,0.1,0.5,0.5,0.4,0.8L3.7,58.3c-0.3,1.2,1.1,2.1,2.1,1.4l16.5-11.8c0.2-0.2,0.5-0.2,0.8,0l7.5,5.3c0.6,0.4,1.5,0.3,1.9-0.4l4.7-7.2c0.1-0.2,0.4-0.3,0.6-0.3l11.2,1.4c0.9,0.1,1.8-0.6,1.5-1.5l-4.7-12.1c-0.1-0.3,0-0.7,0.4-0.9l8.5-4C55.9,27.6,55.5,25.7,54.1,25.7z'
/>
</svg>
)
}
export function GmailIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
@@ -928,6 +939,25 @@ export function GoogleIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function DevinIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 500 500' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path
d='M59.29,209.39l48.87,28.21c1.75,1.01,3.71,1.51,5.67,1.51c1.95,0,3.92-0.52,5.67-1.51l48.87-28.21c0,0,0.14-0.11,0.2-0.16c0.74-0.45,1.44-0.99,2.07-1.6c0.09-0.09,0.18-0.2,0.27-0.29c0.54-0.58,1.03-1.21,1.44-1.89c0.06-0.11,0.16-0.2,0.2-0.32c0.43-0.74,0.74-1.53,0.99-2.37c0.05-0.18,0.09-0.36,0.14-0.54c0.2-0.86,0.36-1.74,0.36-2.66v-28.21c0-10.89,5.87-21.03,15.3-26.48c9.42-5.45,21.15-5.44,30.59,0l24.43,14.11c0.79,0.45,1.62,0.77,2.47,1.01c0.18,0.05,0.37,0.11,0.54,0.16c0.83,0.2,1.69,0.32,2.54,0.34c0.05,0,0.09,0,0.11,0c0.09,0,0.18-0.05,0.26-0.05c0.79,0,1.58-0.11,2.34-0.32c0.14-0.03,0.27-0.05,0.4-0.09c0.83-0.23,1.64-0.57,2.41-0.99c0.06-0.05,0.16-0.05,0.23-0.09l48.87-28.21c3.51-2.03,5.67-5.76,5.67-9.81V64.52c0-4.05-2.16-7.78-5.67-9.81l-48.91-28.19c-3.51-2.03-7.81-2.03-11.32,0l-48.87,28.21c0,0-0.14,0.11-0.2,0.16c-0.74,0.45-1.44,0.99-2.07,1.6c-0.09,0.09-0.18,0.2-0.27,0.29c-0.54,0.58-1.03,1.21-1.44,1.89c-0.06,0.11-0.16,0.2-0.2,0.31c-0.43,0.74-0.74,1.53-0.99,2.37c-0.05,0.18-0.09,0.36-0.14,0.54c-0.2,0.86-0.36,1.74-0.36,2.66v28.21c0,10.89-5.87,21.03-15.3,26.5c-9.42,5.44-21.15,5.44-30.59,0l-24.42-14.1c-0.79-0.45-1.63-0.77-2.47-1.01c-0.18-0.05-0.36-0.11-0.54-0.16c-0.84-0.2-1.69-0.31-2.55-0.34c-0.14,0-0.25,0-0.38,0c-0.81,0-1.6,0.11-2.37,0.31c-0.14,0.02-0.25,0.05-0.38,0.09c-0.82,0.23-1.63,0.57-2.4,1c-0.06,0.05-0.16,0.05-0.23,0.09l-48.84,28.24c-3.51,2.03-5.67,5.76-5.67,9.81v56.42c0,4.05,2.16,7.78,5.67,9.81C59.29,209.41,59.29,209.39,59.29,209.39z'
fill='#2A6DCE'
/>
<path
d='M325.46,223.49c9.42-5.44,21.15-5.44,30.59,0l24.43,14.11c0.79,0.45,1.62,0.77,2.47,1.01c0.18,0.05,0.36,0.11,0.54,0.16c0.83,0.2,1.69,0.31,2.54,0.34c0.05,0,0.09,0,0.11,0c0.09,0,0.18-0.03,0.26-0.05c0.79,0,1.58-0.11,2.34-0.31c0.14-0.03,0.27-0.05,0.4-0.09c0.83-0.23,1.62-0.57,2.41-0.99c0.06-0.05,0.16-0.05,0.25-0.09l48.87-28.21c3.51-2.03,5.67-5.76,5.67-9.81v-56.43c0-4.05-2.16-7.78-5.67-9.81l-48.84-28.22c-3.51-2.03-7.81-2.03-11.32,0l-48.87,28.21c0,0-0.14,0.11-0.2,0.16c-0.74,0.45-1.44,0.99-2.07,1.6c-0.09,0.09-0.18,0.2-0.26,0.29c-0.54,0.58-1.03,1.21-1.44,1.89c-0.06,0.11-0.16,0.2-0.2,0.32c-0.43,0.74-0.74,1.53-0.99,2.37c-0.05,0.18-0.09,0.36-0.14,0.54c-0.2,0.86-0.36,1.74-0.36,2.66v28.21c0,10.89-5.87,21.03-15.3,26.5c-9.42,5.44-21.15,5.44-30.59,0l-24.43-14.11c-0.79-0.45-1.62-0.77-2.47-1.01c-0.18-0.05-0.36-0.11-0.54-0.16c-0.83-0.2-1.69-0.32-2.54-0.34c-0.14,0-0.25,0-0.38,0c-0.81,0-1.6,0.11-2.37,0.32c-0.14,0.03-0.25,0.05-0.38,0.09c-0.83,0.23-1.64,0.57-2.41,0.99c-0.06,0.05-0.16,0.05-0.23,0.09l-48.87,28.21c-3.51,2.03-5.67,5.76-5.67,9.81v56.43c0,4.05,2.16,7.78,5.67,9.81l48.87,28.21c0,0,0.16,0.05,0.23,0.09c0.77,0.43,1.58,0.77,2.41,0.99c0.14,0.05,0.27,0.05,0.4,0.09c0.77,0.18,1.55,0.29,2.34,0.32c0.09,0,0.18,0.05,0.27,0.05c0.05,0,0.09,0,0.11,0c0.86,0,1.69-0.14,2.54-0.34c0.18-0.05,0.36-0.09,0.54-0.16c0.86-0.25,1.69-0.57,2.47-1.01l24.43-14.11c9.42-5.44,21.15-5.44,30.59,0c9.42,5.44,15.3,15.59,15.3,26.48v28.21c0,0.92,0.14,1.8,0.36,2.66c0.05,0.18,0.09,0.36,0.14,0.54c0.25,0.83,0.56,1.62,0.99,2.37c0.06,0.11,0.14,0.2,0.2,0.31c0.4,0.68,0.9,1.31,1.44,1.89c0.09,0.09,0.18,0.2,0.26,0.29c0.61,0.6,1.31,1.12,2.07,1.6c0.06,0.05,0.11,0.11,0.2,0.16l48.87,28.21c1.75,1.01,3.72,1.51,5.67,1.51s3.92-0.52,5.67-1.51l48.87-28.21c3.51-2.03,5.67-5.76,5.67-9.81v-56.43c0-4.05-2.16-7.78-5.67-9.81l-48.87-28.21c0,0-0.16-0.05-0.23-0.09c-0.77-0.43-1.58-0.77-2.41-0.99c-0.14-0.05-0.25-0.05-0.38-0.09c-0.79-0.18-1.57-0.29-2.38-0.32c-0.11,0-0.25,0-0.36,0c-0.86,0-1.71,0.14-2.54,0.34c-0.18,0.05-0.34,0.09-0.52,0.16c-0.86,0.25-1.69,0.57-2.47,1.01l-24.43,14.11c-9.42,5.44-21.15,5.44-30.58,0c-9.42-5.44-15.3-15.59-15.3-26.5c0-10.91,5.87-21.03,15.3-26.48C325.55,223.49,325.46,223.49,325.46,223.49z'
fill='#1DC19C'
/>
<path
d='M304.5,369.22l-48.87-28.21c0,0-0.16-0.05-0.23-0.09c-0.77-0.43-1.57-0.77-2.41-0.99c-0.14-0.05-0.27-0.05-0.4-0.09c-0.79-0.18-1.57-0.29-2.37-0.32c-0.14,0-0.25,0-0.38,0c-0.86,0-1.71,0.14-2.54,0.34c-0.18,0.05-0.34,0.09-0.52,0.16c-0.86,0.25-1.69,0.57-2.47,1.01l-24.43,14.11c-9.42,5.44-21.15,5.44-30.58,0c-9.42-5.44-15.3-15.59-15.3-26.5v-28.22c0-0.92-0.14-1.8-0.36-2.66c-0.05-0.18-0.09-0.36-0.14-0.54c-0.25-0.83-0.57-1.62-0.99-2.37c-0.06-0.11-0.14-0.2-0.2-0.32c-0.4-0.68-0.9-1.31-1.44-1.89c-0.09-0.09-0.18-0.2-0.27-0.29c-0.6-0.6-1.31-1.12-2.07-1.6c-0.06-0.05-0.11-0.11-0.2-0.16l-48.87-28.21c-3.51-2.03-7.81-2.03-11.32,0L59.28,290.6c-3.51,2.03-5.67,5.76-5.67,9.81v56.43c0,4.05,2.16,7.78,5.67,9.81l48.87,28.21c0,0,0.16,0.06,0.23,0.09c0.77,0.43,1.55,0.77,2.38,0.99c0.14,0.05,0.27,0.06,0.4,0.09c0.77,0.18,1.55,0.29,2.34,0.32c0.09,0,0.18,0.05,0.29,0.05c0.05,0,0.09,0,0.14,0c0.86,0,1.69-0.14,2.52-0.34c0.18-0.05,0.36-0.09,0.54-0.16c0.86-0.25,1.69-0.57,2.47-1.01l24.43-14.11c9.42-5.44,21.15-5.44,30.59,0c9.42,5.44,15.3,15.59,15.3,26.48v28.21c0,0.92,0.14,1.8,0.36,2.66c0.05,0.18,0.09,0.36,0.14,0.54c0.25,0.83,0.57,1.62,0.99,2.37c0.06,0.11,0.14,0.2,0.2,0.32c0.4,0.68,0.9,1.31,1.44,1.89c0.09,0.09,0.18,0.2,0.27,0.29c0.61,0.61,1.31,1.12,2.07,1.6c0.06,0.05,0.11,0.11,0.2,0.16l48.87,28.21c1.75,1.01,3.71,1.51,5.67,1.51c1.96,0,3.92-0.52,5.67-1.51l48.87-28.21c3.51-2.03,5.67-5.76,5.67-9.81v-56.43c0-4.05-2.16-7.78-5.67-9.81L304.5,369.22z'
fill='#1796E2'
/>
</svg>
)
}
export function DiscordIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
@@ -1291,6 +1321,21 @@ export function GoogleCalendarIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function GoogleTasksIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 527.1 500' xmlns='http://www.w3.org/2000/svg'>
<polygon
fill='#0066DA'
points='410.4,58.3 368.8,81.2 348.2,120.6 368.8,168.8 407.8,211 450,187.5 475.9,142.8 450,87.5'
/>
<path
fill='#2684FC'
d='M249.3,219.4l98.9-98.9c29.1,22.1,50.5,53.8,59.6,90.4L272.1,346.7c-12.2,12.2-32,12.2-44.2,0l-91.5-91.5 c-9.8-9.8-9.8-25.6,0-35.3l39-39c9.8-9.8,25.6-9.8,35.3,0L249.3,219.4z M519.8,63.6l-39.7-39.7c-9.7-9.7-25.6-9.7-35.3,0 l-34.4,34.4c27.5,23,49.9,51.8,65.5,84.5l43.9-43.9C529.6,89.2,529.6,73.3,519.8,63.6z M412.5,250c0,89.8-72.8,162.5-162.5,162.5 S87.5,339.8,87.5,250S160.2,87.5,250,87.5c36.9,0,70.9,12.3,98.2,33.1l62.2-62.2C367,21.9,311.1,0,250,0C111.9,0,0,111.9,0,250 s111.9,250,250,250s250-111.9,250-250c0-38.3-8.7-74.7-24.1-107.2L407.8,211C410.8,223.5,412.5,236.6,412.5,250z'
/>
</svg>
)
}
export function SupabaseIcon(props: SVGProps<SVGSVGElement>) {
const id = useId()
const gradient0 = `supabase_paint0_${id}`
@@ -3419,6 +3464,23 @@ export const ResendIcon = (props: SVGProps<SVGSVGElement>) => (
</svg>
)
export const GoogleBigQueryIcon = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'>
<path
d='M14.48 58.196L.558 34.082c-.744-1.288-.744-2.876 0-4.164L14.48 5.805c.743-1.287 2.115-2.08 3.6-2.082h27.857c1.48.007 2.845.8 3.585 2.082l13.92 24.113c.744 1.288.744 2.876 0 4.164L49.52 58.196c-.743 1.287-2.115 2.08-3.6 2.082H18.07c-1.483-.005-2.85-.798-3.593-2.082z'
fill='#4386fa'
/>
<path
d='M40.697 24.235s3.87 9.283-1.406 14.545-14.883 1.894-14.883 1.894L43.95 60.27h1.984c1.486-.002 2.858-.796 3.6-2.082L58.75 42.23z'
opacity='.1'
/>
<path
d='M45.267 43.23L41 38.953a.67.67 0 0 0-.158-.12 11.63 11.63 0 1 0-2.032 2.037.67.67 0 0 0 .113.15l4.277 4.277a.67.67 0 0 0 .947 0l1.12-1.12a.67.67 0 0 0 0-.947zM31.64 40.464a8.75 8.75 0 1 1 8.749-8.749 8.75 8.75 0 0 1-8.749 8.749zm-5.593-9.216v3.616c.557.983 1.363 1.803 2.338 2.375v-6.013zm4.375-2.998v9.772a6.45 6.45 0 0 0 2.338 0V28.25zm6.764 6.606v-2.142H34.85v4.5a6.43 6.43 0 0 0 2.338-2.368z'
fill='#fff'
/>
</svg>
)
export const GoogleVaultIcon = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 82 82'>
<path
@@ -3541,6 +3603,15 @@ export function TrelloIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function AttioIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 60.9 50' fill='currentColor'>
<path d='M60.3,34.8l-5.1-8.1c0,0,0,0,0,0L54.7,26c-0.8-1.2-2.1-1.9-3.5-1.9L43,24L42.5,25l-9.8,15.7l-0.5,0.9l4.1,6.6c0.8,1.2,2.1,1.9,3.5,1.9h11.5c1.4,0,2.8-0.7,3.5-1.9l0.4-0.6c0,0,0,0,0,0l5.1-8.2C61.1,37.9,61.1,36.2,60.3,34.8L60.3,34.8z M58.7,38.3l-5.1,8.2c0,0,0,0.1-0.1,0.1c-0.2,0.2-0.4,0.2-0.5,0.2c-0.1,0-0.4,0-0.6-0.3l-5.1-8.2c-0.1-0.1-0.1-0.2-0.2-0.3c0-0.1-0.1-0.2-0.1-0.3c-0.1-0.4-0.1-0.8,0-1.3c0.1-0.2,0.1-0.4,0.3-0.6l5.1-8.1c0,0,0,0,0,0c0.1-0.2,0.3-0.3,0.4-0.3c0.1,0,0.1,0,0.1,0c0,0,0,0,0.1,0c0.1,0,0.4,0,0.6,0.3l5.1,8.1C59.2,36.6,59.2,37.5,58.7,38.3L58.7,38.3z' />
<path d='M45.2,15.1c0.8-1.3,0.8-3.1,0-4.4l-5.1-8.1l-0.4-0.7C38.9,0.7,37.6,0,36.2,0H24.7c-1.4,0-2.7,0.7-3.5,1.9L0.6,34.9C0.2,35.5,0,36.3,0,37c0,0.8,0.2,1.5,0.6,2.2l5.5,8.8C6.9,49.3,8.2,50,9.7,50h11.5c1.4,0,2.8-0.7,3.5-1.9l0.4-0.7c0,0,0,0,0,0c0,0,0,0,0,0l4.1-6.6l12.1-19.4L45.2,15.1L45.2,15.1z M44,13c0,0.4-0.1,0.8-0.4,1.2L23.5,46.4c-0.2,0.3-0.5,0.3-0.6,0.3c-0.1,0-0.4,0-0.6-0.3l-5.1-8.2c-0.5-0.7-0.5-1.7,0-2.4L37.4,3.6c0.2-0.3,0.5-0.3,0.6-0.3c0.1,0,0.4,0,0.6,0.3l5.1,8.1C43.9,12.1,44,12.5,44,13z' />
</svg>
)
}
export function AsanaIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none'>
@@ -5425,6 +5496,34 @@ export function GoogleMapsIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function GoogleTranslateIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 998.1 998.3'>
<path
fill='#DBDBDB'
d='M931.7 998.3c36.5 0 66.4-29.4 66.4-65.4V265.8c0-36-29.9-65.4-66.4-65.4H283.6l260.1 797.9h388z'
/>
<path
fill='#DCDCDC'
d='M931.7 230.4c9.7 0 18.9 3.8 25.8 10.6 6.8 6.7 10.6 15.5 10.6 24.8v667.1c0 9.3-3.7 18.1-10.6 24.8-6.9 6.8-16.1 10.6-25.8 10.6H565.5L324.9 230.4h606.8m0-30H283.6l260.1 797.9h388c36.5 0 66.4-29.4 66.4-65.4V265.8c0-36-29.9-65.4-66.4-65.4z'
/>
<polygon fill='#4352B8' points='482.3,809.8 543.7,998.3 714.4,809.8' />
<path
fill='#607988'
d='M936.1 476.1V437H747.6v-63.2h-61.2V437H566.1v39.1h239.4c-12.8 45.1-41.1 87.7-68.7 120.8-48.9-57.9-49.1-76.7-49.1-76.7h-50.8s2.1 28.2 70.7 108.6c-22.3 22.8-39.2 36.3-39.2 36.3l15.6 48.8s23.6-20.3 53.1-51.6c29.6 32.1 67.8 70.7 117.2 116.7l32.1-32.1c-52.9-48-91.7-86.1-120.2-116.7 38.2-45.2 77-102.1 85.2-154.2H936v.1z'
/>
<path
fill='#4285F4'
d='M66.4 0C29.9 0 0 29.9 0 66.5v677c0 36.5 29.9 66.4 66.4 66.4h648.1L454.4 0h-388z'
/>
<path
fill='#EEEEEE'
d='M371.4 430.6c-2.5 30.3-28.4 75.2-91.1 75.2-54.3 0-98.3-44.9-98.3-100.2s44-100.2 98.3-100.2c30.9 0 51.5 13.4 63.3 24.3l41.2-39.6c-27.1-25-62.4-40.6-104.5-40.6-86.1 0-156 69.9-156 156s69.9 156 156 156c90.2 0 149.8-63.3 149.8-152.6 0-12.8-1.6-22.2-3.7-31.8h-146v53.4l91 .1z'
/>
</svg>
)
}
export function DsPyIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='30 28 185 175' fill='none'>
@@ -5824,7 +5923,7 @@ export function HexIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1450.3 600'>
<path
fill='#5F509D'
fill='#EDB9B8'
fillRule='evenodd'
d='m250.11,0v199.49h-50V0H0v600h200.11v-300.69h50v300.69h200.18V0h-200.18Zm249.9,0v600h450.29v-250.23h-200.2v149h-50v-199.46h250.2V0h-450.29Zm200.09,199.49v-99.49h50v99.49h-50Zm550.02,0V0h200.18v150l-100,100.09,100,100.09v249.82h-200.18v-300.69h-50v300.69h-200.11v-249.82l100.11-100.09-100.11-100.09V0h200.11v199.49h50Z'
/>

View File

@@ -13,6 +13,7 @@ import {
ApolloIcon,
ArxivIcon,
AsanaIcon,
AttioIcon,
BrainIcon,
BrowserUseIcon,
CalComIcon,
@@ -24,6 +25,7 @@ import {
ConfluenceIcon,
CursorIcon,
DatadogIcon,
DevinIcon,
DiscordIcon,
DocumentIcon,
DropboxIcon,
@@ -40,6 +42,8 @@ import {
GithubIcon,
GitLabIcon,
GmailIcon,
GongIcon,
GoogleBigQueryIcon,
GoogleBooksIcon,
GoogleCalendarIcon,
GoogleDocsIcon,
@@ -50,6 +54,8 @@ import {
GoogleMapsIcon,
GoogleSheetsIcon,
GoogleSlidesIcon,
GoogleTasksIcon,
GoogleTranslateIcon,
GoogleVaultIcon,
GrafanaIcon,
GrainIcon,
@@ -158,6 +164,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
apollo: ApolloIcon,
arxiv: ArxivIcon,
asana: AsanaIcon,
attio: AttioIcon,
browser_use: BrowserUseIcon,
calcom: CalComIcon,
calendly: CalendlyIcon,
@@ -168,6 +175,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
confluence_v2: ConfluenceIcon,
cursor_v2: CursorIcon,
datadog: DatadogIcon,
devin: DevinIcon,
discord: DiscordIcon,
dropbox: DropboxIcon,
dspy: DsPyIcon,
@@ -183,6 +191,8 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
github_v2: GithubIcon,
gitlab: GitLabIcon,
gmail_v2: GmailIcon,
gong: GongIcon,
google_bigquery: GoogleBigQueryIcon,
google_books: GoogleBooksIcon,
google_calendar_v2: GoogleCalendarIcon,
google_docs: GoogleDocsIcon,
@@ -193,6 +203,8 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
google_search: GoogleIcon,
google_sheets_v2: GoogleSheetsIcon,
google_slides_v2: GoogleSlidesIcon,
google_tasks: GoogleTasksIcon,
google_translate: GoogleTranslateIcon,
google_vault: GoogleVaultIcon,
grafana: GrafanaIcon,
grain: GrainIcon,

View File

@@ -1,96 +0,0 @@
---
title: Umgebungsvariablen
---
import { Callout } from 'fumadocs-ui/components/callout'
import { Image } from '@/components/ui/image'
Umgebungsvariablen bieten eine sichere Möglichkeit, Konfigurationswerte und Geheimnisse in Ihren Workflows zu verwalten, einschließlich API-Schlüssel und anderer sensibler Daten, auf die Ihre Workflows zugreifen müssen. Sie halten Geheimnisse aus Ihren Workflow-Definitionen heraus und machen sie während der Ausführung verfügbar.
## Variablentypen
Umgebungsvariablen in Sim funktionieren auf zwei Ebenen:
- **Persönliche Umgebungsvariablen**: Privat für Ihr Konto, nur Sie können sie sehen und verwenden
- **Workspace-Umgebungsvariablen**: Werden im gesamten Workspace geteilt und sind für alle Teammitglieder verfügbar
<Callout type="info">
Workspace-Umgebungsvariablen haben Vorrang vor persönlichen Variablen, wenn es einen Namenskonflikt gibt.
</Callout>
## Einrichten von Umgebungsvariablen
Navigieren Sie zu den Einstellungen, um Ihre Umgebungsvariablen zu konfigurieren:
<Image
src="/static/environment/environment-1.png"
alt="Umgebungsvariablen-Modal zum Erstellen neuer Variablen"
width={500}
height={350}
/>
In Ihren Workspace-Einstellungen können Sie sowohl persönliche als auch Workspace-Umgebungsvariablen erstellen und verwalten. Persönliche Variablen sind privat für Ihr Konto, während Workspace-Variablen mit allen Teammitgliedern geteilt werden.
### Variablen auf Workspace-Ebene setzen
Verwenden Sie den Workspace-Bereichsschalter, um Variablen für Ihr gesamtes Team verfügbar zu machen:
<Image
src="/static/environment/environment-2.png"
alt="Workspace-Bereich für Umgebungsvariablen umschalten"
width={500}
height={350}
/>
Wenn Sie den Workspace-Bereich aktivieren, wird die Variable für alle Workspace-Mitglieder verfügbar und kann in jedem Workflow innerhalb dieses Workspaces verwendet werden.
### Ansicht der Workspace-Variablen
Sobald Sie Workspace-Variablen haben, erscheinen sie in Ihrer Liste der Umgebungsvariablen:
<Image
src="/static/environment/environment-3.png"
alt="Workspace-Variablen in der Liste der Umgebungsvariablen"
width={500}
height={350}
/>
## Verwendung von Variablen in Workflows
Um Umgebungsvariablen in Ihren Workflows zu referenzieren, verwenden Sie die `{{}}` Notation. Wenn Sie `{{` in ein beliebiges Eingabefeld eingeben, erscheint ein Dropdown-Menü mit Ihren persönlichen und Workspace-Umgebungsvariablen. Wählen Sie einfach die Variable aus, die Sie verwenden möchten.
<Image
src="/static/environment/environment-4.png"
alt="Verwendung von Umgebungsvariablen mit doppelter Klammernotation"
width={500}
height={350}
/>
## Wie Variablen aufgelöst werden
**Workspace-Variablen haben immer Vorrang** vor persönlichen Variablen, unabhängig davon, wer den Workflow ausführt.
Wenn keine Workspace-Variable für einen Schlüssel existiert, werden persönliche Variablen verwendet:
- **Manuelle Ausführungen (UI)**: Ihre persönlichen Variablen
- **Automatisierte Ausführungen (API, Webhook, Zeitplan, bereitgestellter Chat)**: Persönliche Variablen des Workflow-Besitzers
<Callout type="info">
Persönliche Variablen eignen sich am besten zum Testen. Verwenden Sie Workspace-Variablen für Produktions-Workflows.
</Callout>
## Sicherheits-Best-Practices
### Für sensible Daten
- Speichern Sie API-Schlüssel, Tokens und Passwörter als Umgebungsvariablen anstatt sie im Code festzuschreiben
- Verwenden Sie Workspace-Variablen für gemeinsam genutzte Ressourcen, die mehrere Teammitglieder benötigen
- Bewahren Sie persönliche Anmeldedaten in persönlichen Variablen auf
### Variablenbenennung
- Verwenden Sie beschreibende Namen: `DATABASE_URL` anstatt `DB`
- Folgen Sie einheitlichen Benennungskonventionen in Ihrem Team
- Erwägen Sie Präfixe, um Konflikte zu vermeiden: `PROD_API_KEY`, `DEV_API_KEY`
### Zugriffskontrolle
- Workspace-Umgebungsvariablen respektieren Workspace-Berechtigungen
- Nur Benutzer mit Schreibzugriff oder höher können Workspace-Variablen erstellen/ändern
- Persönliche Variablen sind immer privat für den einzelnen Benutzer

View File

@@ -95,11 +95,17 @@ const apiUrl = `https://api.example.com/users/${userId}/profile`;
### Request Retries
The API block automatically handles:
- Network timeouts with exponential backoff
- Rate limit responses (429 status codes)
- Server errors (5xx status codes) with retry logic
- Connection failures with reconnection attempts
The API block supports **configurable retries** (see the blocks **Advanced** settings):
- **Retries**: Number of retry attempts (additional tries after the first request)
- **Retry delay (ms)**: Initial delay before retrying (uses exponential backoff)
- **Max retry delay (ms)**: Maximum delay between retries
- **Retry non-idempotent methods**: Allow retries for **POST/PATCH** (may create duplicate requests)
Retries are attempted for:
- Network/connection failures and timeouts (with exponential backoff)
- Rate limits (**429**) and server errors (**5xx**)
### Response Validation

View File

@@ -0,0 +1,192 @@
---
title: Credentials
description: Manage secrets, API keys, and OAuth connections for your workflows
---
import { Callout } from 'fumadocs-ui/components/callout'
import { Image } from '@/components/ui/image'
import { Step, Steps } from 'fumadocs-ui/components/steps'
Credentials provide a secure way to manage API keys, tokens, and third-party service connections across your workflows. Instead of hardcoding sensitive values into your workflow, you store them as credentials and reference them at runtime.
Sim supports two categories of credentials: **secrets** for static values like API keys, and **OAuth accounts** for authenticated service connections like Google or Slack.
## Getting Started
To manage credentials, open your workspace **Settings** and navigate to the **Secrets** tab.
<Image
src="/static/credentials/settings-secrets.png"
alt="Settings modal showing the Secrets tab with a list of saved credentials"
width={700}
height={200}
/>
From here you can search, create, and delete both secrets and OAuth connections.
## Secrets
Secrets are key-value pairs that store sensitive data like API keys, tokens, and passwords. Each secret has a **key** (used to reference it in workflows) and a **value** (the actual secret).
### Creating a Secret
<Image
src="/static/credentials/create-secret.png"
alt="Create Secret dialog with fields for key, value, description, and scope toggle"
width={500}
height={400}
/>
<Steps>
<Step>
Click **+ Add** and select **Secret** as the type
</Step>
<Step>
Enter a **Key** name (letters, numbers, and underscores only, e.g. `OPENAI_API_KEY`)
</Step>
<Step>
Enter the **Value**
</Step>
<Step>
Optionally add a **Description** to help your team understand what the secret is for
</Step>
<Step>
Choose the **Scope** — Workspace or Personal
</Step>
<Step>
Click **Create**
</Step>
</Steps>
### Using Secrets in Workflows
To reference a secret in any input field, type `{{` to open the dropdown. It will show your available secrets grouped by scope.
<Image
src="/static/credentials/secret-dropdown.png"
alt="Typing {{ in a code block opens a dropdown showing available workspace secrets"
width={400}
height={250}
/>
Select the secret you want to use. The reference will appear highlighted in blue, indicating it will be resolved at runtime.
<Image
src="/static/credentials/secret-resolved.png"
alt="A resolved secret reference shown in blue text as {{OPENAI_API_KEY}}"
width={400}
height={200}
/>
<Callout type="warn">
Secret values are never exposed in the workflow editor or logs. They are only resolved during execution.
</Callout>
### Bulk Import
You can import multiple secrets at once by pasting `.env`-style content:
1. Click **+ Add**, then switch to **Bulk** mode
2. Paste your environment variables in `KEY=VALUE` format
3. Choose the scope for all imported secrets
4. Click **Create**
The parser supports standard `KEY=VALUE` pairs, quoted values, comments (`#`), and blank lines.
## OAuth Accounts
OAuth accounts are authenticated connections to third-party services like Google, Slack, GitHub, and more. Sim handles the OAuth flow, token storage, and automatic refresh.
You can connect **multiple accounts per provider** — for example, two separate Gmail accounts for different workflows.
### Connecting an OAuth Account
<Image
src="/static/credentials/create-oauth.png"
alt="Create Secret dialog with OAuth Account type selected, showing display name and provider dropdown"
width={500}
height={400}
/>
<Steps>
<Step>
Click **+ Add** and select **OAuth Account** as the type
</Step>
<Step>
Enter a **Display name** to identify this connection (e.g. "Work Gmail" or "Marketing Slack")
</Step>
<Step>
Optionally add a **Description**
</Step>
<Step>
Select the **Account** provider from the dropdown
</Step>
<Step>
Click **Connect** and complete the authorization flow
</Step>
</Steps>
### Using OAuth Accounts in Workflows
Blocks that require authentication (e.g. Gmail, Slack, Google Sheets) display a credential selector dropdown. Select the OAuth account you want the block to use.
<Image
src="/static/credentials/oauth-selector.png"
alt="Gmail block showing the account selector dropdown with a connected account and option to connect another"
width={500}
height={350}
/>
You can also connect additional accounts directly from the block by selecting **Connect another account** at the bottom of the dropdown.
<Callout type="info">
If a block requires an OAuth connection and none is selected, the workflow will fail at that step.
</Callout>
## Workspace vs. Personal
Credentials can be scoped to your **workspace** (shared with your team) or kept **personal** (private to you).
| | Workspace | Personal |
|---|---|---|
| **Visibility** | All workspace members | Only you |
| **Use in workflows** | Any member can use | Only you can use |
| **Best for** | Production workflows, shared services | Testing, personal API keys |
| **Who can edit** | Workspace admins | Only you |
| **Auto-shared** | Yes — all members get access on creation | No — only you have access |
<Callout type="info">
When a workspace and personal secret share the same key name, the **workspace secret takes precedence**.
</Callout>
### Resolution Order
When a workflow runs, Sim resolves secrets in this order:
1. **Workspace secrets** are checked first
2. **Personal secrets** are used as a fallback — from the user who triggered the run (manual) or the workflow owner (automated runs via API, webhook, or schedule)
## Access Control
Each credential has role-based access control:
- **Admin** — can view, edit, delete, and manage who has access
- **Member** — can use the credential in workflows (read-only)
When you create a workspace secret, all current workspace members are automatically granted access. Personal secrets are only accessible to you by default.
### Sharing a Credential
To share a credential with specific team members:
1. Click **Details** on the credential
2. Invite members by email
3. Assign them an **Admin** or **Member** role
## Best Practices
- **Use workspace credentials for production** so workflows work regardless of who triggers them
- **Use personal credentials for development** to keep your test keys separate
- **Name keys descriptively** — `STRIPE_SECRET_KEY` over `KEY1`
- **Connect multiple OAuth accounts** when you need different permissions or identities per workflow
- **Never hardcode secrets** in workflow input fields — always use `{{KEY}}` references

View File

@@ -97,6 +97,7 @@ Understanding these core principles will help you build better workflows:
3. **Smart Data Flow**: Outputs flow automatically to connected blocks
4. **Error Handling**: Failed blocks stop their execution path but don't affect independent paths
5. **State Persistence**: All block outputs and execution details are preserved for debugging
6. **Cycle Protection**: Workflows that call other workflows (via Workflow blocks, MCP tools, or API blocks) are tracked with a call chain. If the chain exceeds 25 hops, execution is stopped to prevent infinite loops
## Next Steps

View File

@@ -13,6 +13,7 @@
"skills",
"knowledgebase",
"variables",
"credentials",
"execution",
"permissions",
"sdks",

File diff suppressed because it is too large Load Diff

View File

@@ -326,6 +326,8 @@ Get details about a specific version of a Confluence page.
| --------- | ---- | ----------- |
| `ts` | string | ISO 8601 timestamp of the operation |
| `pageId` | string | ID of the page |
| `title` | string | Page title at this version |
| `content` | string | Page content with HTML tags stripped at this version |
| `version` | object | Detailed version information |
| ↳ `number` | number | Version number |
| ↳ `message` | string | Version message |
@@ -336,6 +338,9 @@ Get details about a specific version of a Confluence page.
| ↳ `collaborators` | array | List of collaborator account IDs for this version |
| ↳ `prevVersion` | number | Previous version number |
| ↳ `nextVersion` | number | Next version number |
| `body` | object | Raw page body content in storage format at this version |
| ↳ `value` | string | The content value in the specified format |
| ↳ `representation` | string | Content representation type |
### `confluence_list_page_properties`
@@ -1008,6 +1013,85 @@ Get details about a specific Confluence space.
| ↳ `value` | string | Description text content |
| ↳ `representation` | string | Content representation format \(e.g., plain, view, storage\) |
### `confluence_create_space`
Create a new Confluence space.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `name` | string | Yes | Name for the new space |
| `key` | string | Yes | Unique key for the space \(uppercase, no spaces\) |
| `description` | string | No | Description for the new space |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | ISO 8601 timestamp of the operation |
| `spaceId` | string | Created space ID |
| `name` | string | Space name |
| `key` | string | Space key |
| `type` | string | Space type |
| `status` | string | Space status |
| `url` | string | URL to view the space |
| `homepageId` | string | Homepage ID |
| `description` | object | Space description |
| ↳ `value` | string | Description text content |
| ↳ `representation` | string | Content representation format \(e.g., plain, view, storage\) |
### `confluence_update_space`
Update a Confluence space name or description.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `spaceId` | string | Yes | ID of the space to update |
| `name` | string | No | New name for the space |
| `description` | string | No | New description for the space |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | ISO 8601 timestamp of the operation |
| `spaceId` | string | Updated space ID |
| `name` | string | Space name |
| `key` | string | Space key |
| `type` | string | Space type |
| `status` | string | Space status |
| `url` | string | URL to view the space |
| `description` | object | Space description |
| ↳ `value` | string | Description text content |
| ↳ `representation` | string | Content representation format \(e.g., plain, view, storage\) |
### `confluence_delete_space`
Delete a Confluence space.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `spaceId` | string | Yes | ID of the space to delete |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | ISO 8601 timestamp of the operation |
| `spaceId` | string | Deleted space ID |
| `deleted` | boolean | Deletion status |
### `confluence_list_spaces`
List all Confluence spaces accessible to the user.
@@ -1040,4 +1124,311 @@ List all Confluence spaces accessible to the user.
| ↳ `representation` | string | Content representation format \(e.g., plain, view, storage\) |
| `nextCursor` | string | Cursor for fetching the next page of results |
### `confluence_list_space_properties`
List properties on a Confluence space.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `spaceId` | string | Yes | Space ID to list properties for |
| `limit` | number | No | Maximum number of properties to return \(default: 50, max: 250\) |
| `cursor` | string | No | Pagination cursor from previous response |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | ISO 8601 timestamp of the operation |
| `properties` | array | Array of space properties |
| ↳ `id` | string | Property ID |
| ↳ `key` | string | Property key |
| ↳ `value` | json | Property value |
| `spaceId` | string | Space ID |
| `nextCursor` | string | Cursor for fetching the next page of results |
### `confluence_create_space_property`
Create a property on a Confluence space.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `spaceId` | string | Yes | Space ID to create the property on |
| `key` | string | Yes | Property key/name |
| `value` | json | No | Property value \(JSON\) |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | ISO 8601 timestamp of the operation |
| `propertyId` | string | Created property ID |
| `key` | string | Property key |
| `value` | json | Property value |
| `spaceId` | string | Space ID |
### `confluence_delete_space_property`
Delete a property from a Confluence space.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `spaceId` | string | Yes | Space ID the property belongs to |
| `propertyId` | string | Yes | Property ID to delete |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | ISO 8601 timestamp of the operation |
| `spaceId` | string | Space ID |
| `propertyId` | string | Deleted property ID |
| `deleted` | boolean | Deletion status |
### `confluence_list_space_permissions`
List permissions for a Confluence space.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `spaceId` | string | Yes | Space ID to list permissions for |
| `limit` | number | No | Maximum number of permissions to return \(default: 50, max: 250\) |
| `cursor` | string | No | Pagination cursor from previous response |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | ISO 8601 timestamp of the operation |
| `permissions` | array | Array of space permissions |
| ↳ `id` | string | Permission ID |
| ↳ `principalType` | string | Principal type \(user, group, role\) |
| ↳ `principalId` | string | Principal ID |
| ↳ `operationKey` | string | Operation key \(read, create, delete, etc.\) |
| ↳ `operationTargetType` | string | Target type \(page, blogpost, space, etc.\) |
| ↳ `anonymousAccess` | boolean | Whether anonymous access is allowed |
| ↳ `unlicensedAccess` | boolean | Whether unlicensed access is allowed |
| `spaceId` | string | Space ID |
| `nextCursor` | string | Cursor for fetching the next page of results |
### `confluence_get_page_descendants`
Get all descendants of a Confluence page recursively.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `pageId` | string | Yes | Page ID to get descendants for |
| `limit` | number | No | Maximum number of descendants to return \(default: 50, max: 250\) |
| `cursor` | string | No | Pagination cursor from previous response |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | ISO 8601 timestamp of the operation |
| `descendants` | array | Array of descendant pages |
| ↳ `id` | string | Page ID |
| ↳ `title` | string | Page title |
| ↳ `type` | string | Content type \(page, whiteboard, database, etc.\) |
| ↳ `status` | string | Page status |
| ↳ `spaceId` | string | Space ID |
| ↳ `parentId` | string | Parent page ID |
| ↳ `childPosition` | number | Position among siblings |
| ↳ `depth` | number | Depth in the hierarchy |
| `pageId` | string | Parent page ID |
| `nextCursor` | string | Cursor for fetching the next page of results |
### `confluence_list_tasks`
List inline tasks from Confluence. Optionally filter by page, space, assignee, or status.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `pageId` | string | No | Filter tasks by page ID |
| `spaceId` | string | No | Filter tasks by space ID |
| `assignedTo` | string | No | Filter tasks by assignee account ID |
| `status` | string | No | Filter tasks by status \(complete or incomplete\) |
| `limit` | number | No | Maximum number of tasks to return \(default: 50, max: 250\) |
| `cursor` | string | No | Pagination cursor from previous response |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | ISO 8601 timestamp of the operation |
| `tasks` | array | Array of Confluence tasks |
| ↳ `id` | string | Task ID |
| ↳ `localId` | string | Local task ID |
| ↳ `spaceId` | string | Space ID |
| ↳ `pageId` | string | Page ID |
| ↳ `blogPostId` | string | Blog post ID |
| ↳ `status` | string | Task status \(complete or incomplete\) |
| ↳ `body` | string | Task body content in storage format |
| ↳ `createdBy` | string | Creator account ID |
| ↳ `assignedTo` | string | Assignee account ID |
| ↳ `completedBy` | string | Completer account ID |
| ↳ `createdAt` | string | Creation timestamp |
| ↳ `updatedAt` | string | Last update timestamp |
| ↳ `dueAt` | string | Due date |
| ↳ `completedAt` | string | Completion timestamp |
| `nextCursor` | string | Cursor for fetching the next page of results |
### `confluence_get_task`
Get a specific Confluence inline task by ID.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `taskId` | string | Yes | The ID of the task to retrieve |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | ISO 8601 timestamp of the operation |
| `id` | string | Task ID |
| `localId` | string | Local task ID |
| `spaceId` | string | Space ID |
| `pageId` | string | Page ID |
| `blogPostId` | string | Blog post ID |
| `status` | string | Task status \(complete or incomplete\) |
| `body` | string | Task body content in storage format |
| `createdBy` | string | Creator account ID |
| `assignedTo` | string | Assignee account ID |
| `completedBy` | string | Completer account ID |
| `createdAt` | string | Creation timestamp |
| `updatedAt` | string | Last update timestamp |
| `dueAt` | string | Due date |
| `completedAt` | string | Completion timestamp |
### `confluence_update_task`
Update the status of a Confluence inline task (complete or incomplete).
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `taskId` | string | Yes | The ID of the task to update |
| `status` | string | Yes | New status for the task \(complete or incomplete\) |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | ISO 8601 timestamp of the operation |
| `id` | string | Task ID |
| `localId` | string | Local task ID |
| `spaceId` | string | Space ID |
| `pageId` | string | Page ID |
| `blogPostId` | string | Blog post ID |
| `status` | string | Updated task status |
| `body` | string | Task body content in storage format |
| `createdBy` | string | Creator account ID |
| `assignedTo` | string | Assignee account ID |
| `completedBy` | string | Completer account ID |
| `createdAt` | string | Creation timestamp |
| `updatedAt` | string | Last update timestamp |
| `dueAt` | string | Due date |
| `completedAt` | string | Completion timestamp |
### `confluence_update_blogpost`
Update an existing Confluence blog post title and/or content.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `blogPostId` | string | Yes | The ID of the blog post to update |
| `title` | string | No | New title for the blog post |
| `content` | string | No | New content for the blog post in storage format |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | ISO 8601 timestamp of the operation |
| `blogPostId` | string | Updated blog post ID |
| `title` | string | Blog post title |
| `status` | string | Blog post status |
| `spaceId` | string | Space ID |
| `version` | json | Version information |
| `url` | string | URL to view the blog post |
### `confluence_delete_blogpost`
Delete a Confluence blog post.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `blogPostId` | string | Yes | The ID of the blog post to delete |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | ISO 8601 timestamp of the operation |
| `blogPostId` | string | Deleted blog post ID |
| `deleted` | boolean | Deletion status |
### `confluence_get_user`
Get display name and profile info for a Confluence user by account ID.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `accountId` | string | Yes | The Atlassian account ID of the user to look up |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | ISO 8601 timestamp of the operation |
| `accountId` | string | Atlassian account ID of the user |
| `displayName` | string | Display name of the user |
| `email` | string | Email address of the user |
| `accountType` | string | Account type \(e.g., atlassian, app, customer\) |
| `profilePicture` | string | Path to the user profile picture |
| `publicName` | string | Public name of the user |

View File

@@ -0,0 +1,157 @@
---
title: Devin
description: Autonomous AI software engineer
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="devin"
color="#12141A"
/>
{/* MANUAL-CONTENT-START:intro */}
[Devin](https://devin.ai/) is an autonomous AI software engineer by Cognition that can independently write, run, debug, and deploy code.
With Devin, you can:
- **Automate coding tasks**: Assign software engineering tasks and let Devin autonomously write, test, and iterate on code
- **Manage sessions**: Create, monitor, and interact with Devin sessions to track progress on assigned tasks
- **Guide active work**: Send messages to running sessions to provide additional context, redirect efforts, or answer questions
- **Retrieve structured output**: Poll completed sessions for pull requests, structured results, and detailed status
- **Control costs**: Set ACU (Autonomous Compute Unit) limits to cap spending on long-running tasks
- **Standardize workflows**: Use playbook IDs to apply repeatable task patterns across sessions
In Sim, the Devin integration enables your agents to programmatically manage Devin sessions as part of their workflows:
- **Create sessions**: Kick off new Devin sessions with a prompt describing the task, optional playbook, ACU limits, and tags
- **Get session details**: Retrieve the full state of a session including status, pull requests, structured output, and resource consumption
- **List sessions**: Query all sessions in your organization with optional pagination
- **Send messages**: Communicate with active or suspended sessions to provide guidance, and automatically resume suspended sessions
This allows for powerful automation scenarios such as triggering code generation from upstream events, polling for completion before consuming results, orchestrating multi-step development pipelines, and integrating Devin's output into broader agent workflows.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate Devin into your workflow. Create sessions to assign coding tasks, send messages to guide active sessions, and retrieve session status and results. Devin autonomously writes, runs, and tests code.
## Tools
### `devin_create_session`
Create a new Devin session with a prompt. Devin will autonomously work on the task described in the prompt.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Devin API key \(service user credential starting with cog_\) |
| `prompt` | string | Yes | The task prompt for Devin to work on |
| `playbookId` | string | No | Optional playbook ID to guide the session |
| `maxAcuLimit` | number | No | Maximum ACU limit for the session |
| `tags` | string | No | Comma-separated tags for the session |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `sessionId` | string | Unique identifier for the session |
| `url` | string | URL to view the session in the Devin UI |
| `status` | string | Session status \(new, claimed, running, exit, error, suspended, resuming\) |
| `statusDetail` | string | Detailed status \(working, waiting_for_user, waiting_for_approval, finished, inactivity, etc.\) |
| `title` | string | Session title |
| `createdAt` | number | Unix timestamp when the session was created |
| `updatedAt` | number | Unix timestamp when the session was last updated |
| `acusConsumed` | number | ACUs consumed by the session |
| `tags` | json | Tags associated with the session |
| `pullRequests` | json | Pull requests created during the session |
| `structuredOutput` | json | Structured output from the session |
| `playbookId` | string | Associated playbook ID |
### `devin_get_session`
Retrieve details of an existing Devin session including status, tags, pull requests, and structured output.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Devin API key \(service user credential starting with cog_\) |
| `sessionId` | string | Yes | The session ID to retrieve |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `sessionId` | string | Unique identifier for the session |
| `url` | string | URL to view the session in the Devin UI |
| `status` | string | Session status \(new, claimed, running, exit, error, suspended, resuming\) |
| `statusDetail` | string | Detailed status \(working, waiting_for_user, waiting_for_approval, finished, inactivity, etc.\) |
| `title` | string | Session title |
| `createdAt` | number | Unix timestamp when the session was created |
| `updatedAt` | number | Unix timestamp when the session was last updated |
| `acusConsumed` | number | ACUs consumed by the session |
| `tags` | json | Tags associated with the session |
| `pullRequests` | json | Pull requests created during the session |
| `structuredOutput` | json | Structured output from the session |
| `playbookId` | string | Associated playbook ID |
### `devin_list_sessions`
List Devin sessions in the organization. Returns up to 100 sessions by default.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Devin API key \(service user credential starting with cog_\) |
| `limit` | number | No | Maximum number of sessions to return \(1-200, default: 100\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `sessions` | array | List of Devin sessions |
| ↳ `sessionId` | string | Unique identifier for the session |
| ↳ `url` | string | URL to view the session |
| ↳ `status` | string | Session status |
| ↳ `statusDetail` | string | Detailed status |
| ↳ `title` | string | Session title |
| ↳ `createdAt` | number | Creation timestamp \(Unix\) |
| ↳ `updatedAt` | number | Last updated timestamp \(Unix\) |
| ↳ `tags` | json | Session tags |
### `devin_send_message`
Send a message to a Devin session. If the session is suspended, it will be automatically resumed. Returns the updated session state.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Devin API key \(service user credential starting with cog_\) |
| `sessionId` | string | Yes | The session ID to send the message to |
| `message` | string | Yes | The message to send to Devin |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `sessionId` | string | Unique identifier for the session |
| `url` | string | URL to view the session in the Devin UI |
| `status` | string | Session status \(new, claimed, running, exit, error, suspended, resuming\) |
| `statusDetail` | string | Detailed status \(working, waiting_for_user, waiting_for_approval, finished, inactivity, etc.\) |
| `title` | string | Session title |
| `createdAt` | number | Unix timestamp when the session was created |
| `updatedAt` | number | Unix timestamp when the session was last updated |
| `acusConsumed` | number | ACUs consumed by the session |
| `tags` | json | Tags associated with the session |
| `pullRequests` | json | Pull requests created during the session |
| `structuredOutput` | json | Structured output from the session |
| `playbookId` | string | Associated playbook ID |

View File

@@ -0,0 +1,774 @@
---
title: Gong
description: Revenue intelligence and conversation analytics
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="gong"
color="#8039DF"
/>
{/* MANUAL-CONTENT-START:intro */}
[Gong](https://www.gong.io/) is a revenue intelligence platform that captures and analyzes customer interactions across calls, emails, and meetings. By integrating Gong with Sim, your agents can access conversation data, user analytics, coaching metrics, and more through automated workflows.
The Gong integration in Sim provides tools to:
- **List and retrieve calls:** Fetch calls by date range, get individual call details, or retrieve extensive call data including trackers, topics, interaction stats, and points of interest.
- **Access call transcripts:** Retrieve full transcripts with speaker turns, topics, and sentence-level timestamps for any recorded call.
- **Manage users:** List all Gong users in your account or retrieve detailed information for a specific user, including settings, spoken languages, and contact details.
- **Analyze activity and performance:** Pull aggregated activity statistics, interaction stats (longest monologue, interactivity, patience, question rate), and answered scorecard data for your team.
- **Work with scorecards and trackers:** List scorecard definitions and keyword tracker configurations to understand how your team's conversations are being evaluated and monitored.
- **Browse the call library:** List library folders and retrieve their contents, including call snippets and notes curated by your team.
- **Access coaching metrics:** Retrieve coaching data for managers and their direct reports to track team development.
- **List Engage flows:** Fetch sales engagement sequences (flows) with visibility and ownership details.
- **Look up contacts by email or phone:** Find all Gong references to a specific email address or phone number, including related calls, emails, meetings, CRM data, and customer engagement events.
By combining these capabilities, you can automate sales coaching workflows, extract conversation insights, monitor team performance, sync Gong data with other systems, and build intelligent pipelines around your organization's revenue conversations -- all securely using your Gong API credentials.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate Gong into your workflow. Access call recordings, transcripts, user data, activity stats, scorecards, trackers, library content, coaching metrics, and more via the Gong API.
## Tools
### `gong_list_calls`
Retrieve call data by date range from Gong.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accessKey` | string | Yes | Gong API Access Key |
| `accessKeySecret` | string | Yes | Gong API Access Key Secret |
| `fromDateTime` | string | Yes | Start date/time in ISO-8601 format \(e.g., 2024-01-01T00:00:00Z\) |
| `toDateTime` | string | No | End date/time in ISO-8601 format \(e.g., 2024-01-31T23:59:59Z\). If omitted, lists calls up to the most recent. |
| `cursor` | string | No | Pagination cursor from a previous response |
| `workspaceId` | string | No | Gong workspace ID to filter calls |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `calls` | array | List of calls matching the date range |
| ↳ `id` | string | Gong's unique numeric identifier for the call |
| ↳ `title` | string | Call title |
| ↳ `scheduled` | string | Scheduled call time in ISO-8601 format |
| ↳ `started` | string | Recording start time in ISO-8601 format |
| ↳ `duration` | number | Call duration in seconds |
| ↳ `direction` | string | Call direction \(Inbound/Outbound\) |
| ↳ `system` | string | Communication platform used \(e.g., Outreach\) |
| ↳ `scope` | string | Call scope: 'Internal', 'External', or 'Unknown' |
| ↳ `media` | string | Media type \(e.g., Video\) |
| ↳ `language` | string | Language code in ISO-639-2B format |
| ↳ `url` | string | URL to the call in the Gong web app |
| ↳ `primaryUserId` | string | Host team member identifier |
| ↳ `workspaceId` | string | Workspace identifier |
| ↳ `sdrDisposition` | string | SDR disposition classification |
| ↳ `clientUniqueId` | string | Call identifier from the origin recording system |
| ↳ `customData` | string | Metadata provided during call creation |
| ↳ `purpose` | string | Call purpose |
| ↳ `meetingUrl` | string | Web conference provider URL |
| ↳ `isPrivate` | boolean | Whether the call is private |
| ↳ `calendarEventId` | string | Calendar event identifier |
| `cursor` | string | Pagination cursor for the next page |
| `totalRecords` | number | Total number of records matching the filter |
### `gong_get_call`
Retrieve detailed data for a specific call from Gong.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accessKey` | string | Yes | Gong API Access Key |
| `accessKeySecret` | string | Yes | Gong API Access Key Secret |
| `callId` | string | Yes | The Gong call ID to retrieve |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Gong's unique numeric identifier for the call |
| `title` | string | Call title |
| `url` | string | URL to the call in the Gong web app |
| `scheduled` | string | Scheduled call time in ISO-8601 format |
| `started` | string | Recording start time in ISO-8601 format |
| `duration` | number | Call duration in seconds |
| `direction` | string | Call direction \(Inbound/Outbound\) |
| `system` | string | Communication platform used \(e.g., Outreach\) |
| `scope` | string | Call scope: 'Internal', 'External', or 'Unknown' |
| `media` | string | Media type \(e.g., Video\) |
| `language` | string | Language code in ISO-639-2B format |
| `primaryUserId` | string | Host team member identifier |
| `workspaceId` | string | Workspace identifier |
| `sdrDisposition` | string | SDR disposition classification |
| `clientUniqueId` | string | Call identifier from the origin recording system |
| `customData` | string | Metadata provided during call creation |
| `purpose` | string | Call purpose |
| `meetingUrl` | string | Web conference provider URL |
| `isPrivate` | boolean | Whether the call is private |
| `calendarEventId` | string | Calendar event identifier |
### `gong_get_call_transcript`
Retrieve transcripts of calls from Gong by call IDs or date range.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accessKey` | string | Yes | Gong API Access Key |
| `accessKeySecret` | string | Yes | Gong API Access Key Secret |
| `callIds` | string | No | Comma-separated list of call IDs to retrieve transcripts for |
| `fromDateTime` | string | No | Start date/time filter in ISO-8601 format |
| `toDateTime` | string | No | End date/time filter in ISO-8601 format |
| `workspaceId` | string | No | Gong workspace ID to filter calls |
| `cursor` | string | No | Pagination cursor from a previous response |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `callTranscripts` | array | List of call transcripts with speaker turns and sentences |
| ↳ `callId` | string | Gong's unique numeric identifier for the call |
| ↳ `transcript` | array | List of monologues in the call |
| ↳ `speakerId` | string | Unique ID of the speaker, cross-reference with parties |
| ↳ `topic` | string | Name of the topic being discussed |
| ↳ `sentences` | array | List of sentences spoken in the monologue |
| ↳ `start` | number | Start time of the sentence in milliseconds from call start |
| ↳ `end` | number | End time of the sentence in milliseconds from call start |
| ↳ `text` | string | The sentence text |
| `cursor` | string | Pagination cursor for the next page |
### `gong_get_extensive_calls`
Retrieve detailed call data including trackers, topics, and highlights from Gong.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accessKey` | string | Yes | Gong API Access Key |
| `accessKeySecret` | string | Yes | Gong API Access Key Secret |
| `callIds` | string | No | Comma-separated list of call IDs to retrieve detailed data for |
| `fromDateTime` | string | No | Start date/time filter in ISO-8601 format |
| `toDateTime` | string | No | End date/time filter in ISO-8601 format |
| `workspaceId` | string | No | Gong workspace ID to filter calls |
| `primaryUserIds` | string | No | Comma-separated list of user IDs to filter calls by host |
| `cursor` | string | No | Pagination cursor from a previous response |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `calls` | array | List of detailed call objects with metadata, content, interaction stats, and collaboration data |
| ↳ `metaData` | object | Call metadata \(same fields as CallBasicData\) |
| ↳ `id` | string | Call ID |
| ↳ `title` | string | Call title |
| ↳ `scheduled` | string | Scheduled time in ISO-8601 |
| ↳ `started` | string | Start time in ISO-8601 |
| ↳ `duration` | number | Duration in seconds |
| ↳ `direction` | string | Call direction |
| ↳ `system` | string | Communication platform |
| ↳ `scope` | string | Internal/External/Unknown |
| ↳ `media` | string | Media type |
| ↳ `language` | string | Language code \(ISO-639-2B\) |
| ↳ `url` | string | Gong web app URL |
| ↳ `primaryUserId` | string | Host user ID |
| ↳ `workspaceId` | string | Workspace ID |
| ↳ `sdrDisposition` | string | SDR disposition |
| ↳ `clientUniqueId` | string | Origin system call ID |
| ↳ `customData` | string | Custom metadata |
| ↳ `purpose` | string | Call purpose |
| ↳ `meetingUrl` | string | Meeting URL |
| ↳ `isPrivate` | boolean | Whether call is private |
| ↳ `calendarEventId` | string | Calendar event ID |
| ↳ `context` | array | Links to external systems \(CRM, Dialer, etc.\) |
| ↳ `system` | string | External system name \(e.g., Salesforce\) |
| ↳ `objects` | array | List of objects within the external system |
| ↳ `parties` | array | List of call participants |
| ↳ `id` | string | Unique participant ID in the call |
| ↳ `name` | string | Participant name |
| ↳ `emailAddress` | string | Email address |
| ↳ `title` | string | Job title |
| ↳ `phoneNumber` | string | Phone number |
| ↳ `speakerId` | string | Speaker ID for transcript cross-reference |
| ↳ `userId` | string | Gong user ID |
| ↳ `affiliation` | string | Company or non-company |
| ↳ `methods` | array | Whether invited or attended |
| ↳ `context` | array | Links to external systems for this party |
| ↳ `content` | object | Call content data |
| ↳ `structure` | array | Call agenda parts |
| ↳ `name` | string | Agenda name |
| ↳ `duration` | number | Duration of this part in seconds |
| ↳ `topics` | array | Topics and their durations |
| ↳ `name` | string | Topic name \(e.g., Pricing\) |
| ↳ `duration` | number | Time spent on topic in seconds |
| ↳ `trackers` | array | Trackers found in the call |
| ↳ `id` | string | Tracker ID |
| ↳ `name` | string | Tracker name |
| ↳ `count` | number | Number of occurrences |
| ↳ `type` | string | Keyword or Smart |
| ↳ `occurrences` | array | Details for each occurrence |
| ↳ `speakerId` | string | Speaker who said it |
| ↳ `startTime` | number | Seconds from call start |
| ↳ `phrases` | array | Per-phrase occurrence counts |
| ↳ `phrase` | string | Specific phrase |
| ↳ `count` | number | Occurrences of this phrase |
| ↳ `occurrences` | array | Details per occurrence |
| ↳ `highlights` | array | AI-generated highlights including next steps, action items, and key moments |
| ↳ `title` | string | Title of the highlight |
| ↳ `interaction` | object | Interaction statistics |
| ↳ `interactionStats` | array | Interaction stats per user |
| ↳ `userId` | string | Gong user ID |
| ↳ `userEmailAddress` | string | User email |
| ↳ `personInteractionStats` | array | Stats list \(Longest Monologue, Interactivity, Patience, etc.\) |
| ↳ `name` | string | Stat name |
| ↳ `value` | number | Stat value |
| ↳ `speakers` | array | Talk duration per speaker |
| ↳ `id` | string | Participant ID |
| ↳ `userId` | string | Gong user ID |
| ↳ `talkTime` | number | Talk duration in seconds |
| ↳ `video` | array | Video statistics |
| ↳ `name` | string | Segment type: Browser, Presentation, WebcamPrimaryUser, WebcamNonCompany, Webcam |
| ↳ `duration` | number | Total segment duration in seconds |
| ↳ `questions` | object | Question counts |
| ↳ `companyCount` | number | Questions by company speakers |
| ↳ `nonCompanyCount` | number | Questions by non-company speakers |
| ↳ `collaboration` | object | Collaboration data |
| ↳ `publicComments` | array | Public comments on the call |
| ↳ `id` | string | Comment ID |
| ↳ `commenterUserId` | string | Commenter user ID |
| ↳ `comment` | string | Comment text |
| ↳ `posted` | string | Posted time in ISO-8601 |
| ↳ `audioStartTime` | number | Seconds from call start the comment refers to |
| ↳ `audioEndTime` | number | Seconds from call start the comment end refers to |
| ↳ `duringCall` | boolean | Whether the comment was posted during the call |
| ↳ `inReplyTo` | string | ID of original comment if this is a reply |
| ↳ `media` | object | Media download URLs \(available for 8 hours\) |
| ↳ `audioUrl` | string | Audio download URL |
| ↳ `videoUrl` | string | Video download URL |
| `cursor` | string | Pagination cursor for the next page |
### `gong_list_users`
List all users in your Gong account.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accessKey` | string | Yes | Gong API Access Key |
| `accessKeySecret` | string | Yes | Gong API Access Key Secret |
| `cursor` | string | No | Pagination cursor from a previous response |
| `includeAvatars` | string | No | Whether to include avatar URLs \(true/false\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `users` | array | List of Gong users |
| ↳ `id` | string | Unique numeric user ID \(up to 20 digits\) |
| ↳ `emailAddress` | string | User email address |
| ↳ `created` | string | User creation timestamp \(ISO-8601\) |
| ↳ `active` | boolean | Whether the user is active |
| ↳ `emailAliases` | array | Alternative email addresses for the user |
| ↳ `trustedEmailAddress` | string | Trusted email address for the user |
| ↳ `firstName` | string | First name |
| ↳ `lastName` | string | Last name |
| ↳ `title` | string | Job title |
| ↳ `phoneNumber` | string | Phone number |
| ↳ `extension` | string | Phone extension number |
| ↳ `personalMeetingUrls` | array | Personal meeting URLs |
| ↳ `settings` | object | User settings |
| ↳ `webConferencesRecorded` | boolean | Whether web conferences are recorded |
| ↳ `preventWebConferenceRecording` | boolean | Whether web conference recording is prevented |
| ↳ `telephonyCallsImported` | boolean | Whether telephony calls are imported |
| ↳ `emailsImported` | boolean | Whether emails are imported |
| ↳ `preventEmailImport` | boolean | Whether email import is prevented |
| ↳ `nonRecordedMeetingsImported` | boolean | Whether non-recorded meetings are imported |
| ↳ `gongConnectEnabled` | boolean | Whether Gong Connect is enabled |
| ↳ `managerId` | string | Manager user ID |
| ↳ `meetingConsentPageUrl` | string | Meeting consent page URL |
| ↳ `spokenLanguages` | array | Languages spoken by the user |
| ↳ `language` | string | Language code |
| ↳ `primary` | boolean | Whether this is the primary language |
| `cursor` | string | Pagination cursor for the next page |
| `totalRecords` | number | Total number of user records |
| `currentPageSize` | number | Number of records in the current page |
| `currentPageNumber` | number | Current page number |
### `gong_get_user`
Retrieve details for a specific user from Gong.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accessKey` | string | Yes | Gong API Access Key |
| `accessKeySecret` | string | Yes | Gong API Access Key Secret |
| `userId` | string | Yes | The Gong user ID to retrieve |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Unique numeric user ID \(up to 20 digits\) |
| `emailAddress` | string | User email address |
| `created` | string | User creation timestamp \(ISO-8601\) |
| `active` | boolean | Whether the user is active |
| `emailAliases` | array | Alternative email addresses for the user |
| `trustedEmailAddress` | string | Trusted email address for the user |
| `firstName` | string | First name |
| `lastName` | string | Last name |
| `title` | string | Job title |
| `phoneNumber` | string | Phone number |
| `extension` | string | Phone extension number |
| `personalMeetingUrls` | array | Personal meeting URLs |
| `settings` | object | User settings |
| ↳ `webConferencesRecorded` | boolean | Whether web conferences are recorded |
| ↳ `preventWebConferenceRecording` | boolean | Whether web conference recording is prevented |
| ↳ `telephonyCallsImported` | boolean | Whether telephony calls are imported |
| ↳ `emailsImported` | boolean | Whether emails are imported |
| ↳ `preventEmailImport` | boolean | Whether email import is prevented |
| ↳ `nonRecordedMeetingsImported` | boolean | Whether non-recorded meetings are imported |
| ↳ `gongConnectEnabled` | boolean | Whether Gong Connect is enabled |
| `managerId` | string | Manager user ID |
| `meetingConsentPageUrl` | string | Meeting consent page URL |
| `spokenLanguages` | array | Languages spoken by the user |
| ↳ `language` | string | Language code |
| ↳ `primary` | boolean | Whether this is the primary language |
### `gong_aggregate_activity`
Retrieve aggregated activity statistics for users by date range from Gong.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accessKey` | string | Yes | Gong API Access Key |
| `accessKeySecret` | string | Yes | Gong API Access Key Secret |
| `userIds` | string | No | Comma-separated list of Gong user IDs \(up to 20 digits each\) |
| `fromDate` | string | Yes | Start date in YYYY-MM-DD format \(inclusive, in company timezone\) |
| `toDate` | string | Yes | End date in YYYY-MM-DD format \(exclusive, in company timezone, cannot exceed current day\) |
| `cursor` | string | No | Pagination cursor from a previous response |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `usersActivity` | array | Aggregated activity statistics per user |
| ↳ `userId` | string | Gong's unique numeric identifier for the user |
| ↳ `userEmailAddress` | string | Email address of the Gong user |
| ↳ `callsAsHost` | number | Number of recorded calls this user hosted |
| ↳ `callsAttended` | number | Number of calls where this user was a participant \(not host\) |
| ↳ `callsGaveFeedback` | number | Number of recorded calls the user gave feedback on |
| ↳ `callsReceivedFeedback` | number | Number of recorded calls the user received feedback on |
| ↳ `callsRequestedFeedback` | number | Number of recorded calls the user requested feedback on |
| ↳ `callsScorecardsFilled` | number | Number of scorecards the user completed |
| ↳ `callsScorecardsReceived` | number | Number of calls where someone filled a scorecard on the user's calls |
| ↳ `ownCallsListenedTo` | number | Number of the user's own calls the user listened to |
| ↳ `othersCallsListenedTo` | number | Number of other users' calls the user listened to |
| ↳ `callsSharedInternally` | number | Number of calls the user shared internally |
| ↳ `callsSharedExternally` | number | Number of calls the user shared externally |
| ↳ `callsCommentsGiven` | number | Number of calls where the user provided at least one comment |
| ↳ `callsCommentsReceived` | number | Number of calls where the user received at least one comment |
| ↳ `callsMarkedAsFeedbackGiven` | number | Number of calls where the user selected Mark as reviewed |
| ↳ `callsMarkedAsFeedbackReceived` | number | Number of calls where others selected Mark as reviewed on the user's calls |
| `timeZone` | string | The company's defined timezone in Gong |
| `fromDateTime` | string | Start of results in ISO-8601 format |
| `toDateTime` | string | End of results in ISO-8601 format |
| `cursor` | string | Pagination cursor for the next page |
### `gong_interaction_stats`
Retrieve interaction statistics for users by date range from Gong. Only includes calls with Whisper enabled.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accessKey` | string | Yes | Gong API Access Key |
| `accessKeySecret` | string | Yes | Gong API Access Key Secret |
| `userIds` | string | No | Comma-separated list of Gong user IDs \(up to 20 digits each\) |
| `fromDate` | string | Yes | Start date in YYYY-MM-DD format \(inclusive, in company timezone\) |
| `toDate` | string | Yes | End date in YYYY-MM-DD format \(exclusive, in company timezone, cannot exceed current day\) |
| `cursor` | string | No | Pagination cursor from a previous response |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `peopleInteractionStats` | array | Email address of the Gong user |
| ↳ `userId` | string | Gong's unique numeric identifier for the user |
| ↳ `userEmailAddress` | string | Email address of the Gong user |
| ↳ `personInteractionStats` | array | List of interaction stat measurements for this user |
| ↳ `name` | string | Stat name \(e.g. Longest Monologue, Interactivity, Patience, Question Rate\) |
| ↳ `value` | number | Stat measurement value \(can be double or integer\) |
| `timeZone` | string | The company's defined timezone in Gong |
| `fromDateTime` | string | Start of results in ISO-8601 format |
| `toDateTime` | string | End of results in ISO-8601 format |
| `cursor` | string | Pagination cursor for the next page |
### `gong_answered_scorecards`
Retrieve answered scorecards for reviewed users or by date range from Gong.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accessKey` | string | Yes | Gong API Access Key |
| `accessKeySecret` | string | Yes | Gong API Access Key Secret |
| `callFromDate` | string | No | Start date for calls in YYYY-MM-DD format \(inclusive, in company timezone\). Defaults to earliest recorded call. |
| `callToDate` | string | No | End date for calls in YYYY-MM-DD format \(exclusive, in company timezone\). Defaults to latest recorded call. |
| `reviewFromDate` | string | No | Start date for reviews in YYYY-MM-DD format \(inclusive, in company timezone\). Defaults to earliest reviewed call. |
| `reviewToDate` | string | No | End date for reviews in YYYY-MM-DD format \(exclusive, in company timezone\). Defaults to latest reviewed call. |
| `scorecardIds` | string | No | Comma-separated list of scorecard IDs to filter by |
| `reviewedUserIds` | string | No | Comma-separated list of reviewed user IDs to filter by |
| `cursor` | string | No | Pagination cursor from a previous response |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `answeredScorecards` | array | List of answered scorecards with scores and answers |
| ↳ `answeredScorecardId` | number | Identifier of the answered scorecard |
| ↳ `scorecardId` | number | Identifier of the scorecard |
| ↳ `scorecardName` | string | Scorecard name |
| ↳ `callId` | number | Gong's unique numeric identifier for the call |
| ↳ `callStartTime` | string | Date/time of the call in ISO-8601 format |
| ↳ `reviewedUserId` | number | User ID of the team member being reviewed |
| ↳ `reviewerUserId` | number | User ID of the team member who completed the scorecard |
| ↳ `reviewTime` | string | Date/time when the review was completed in ISO-8601 format |
| ↳ `visibilityType` | string | Visibility type of the scorecard answer |
| ↳ `answers` | array | Answers in the answered scorecard |
| ↳ `questionId` | number | Identifier of the question |
| ↳ `questionRevisionId` | number | Identifier of the revision version of the question |
| ↳ `isOverall` | boolean | Whether this is the overall question |
| ↳ `score` | number | Score between 1 to 5 if answered, null otherwise |
| ↳ `answerText` | string | The answer's text if answered, null otherwise |
| ↳ `notApplicable` | boolean | Whether the question is not applicable to this call |
| `cursor` | string | Pagination cursor for the next page |
### `gong_list_library_folders`
Retrieve library folders from Gong.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accessKey` | string | Yes | Gong API Access Key |
| `accessKeySecret` | string | Yes | Gong API Access Key Secret |
| `workspaceId` | string | No | Gong workspace ID to filter folders |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `folders` | array | List of library folders with id, name, and parent relationships |
| ↳ `id` | string | Gong unique numeric identifier for the folder |
| ↳ `name` | string | Display name of the folder |
| ↳ `parentFolderId` | string | Gong unique numeric identifier for the parent folder \(null for root folder\) |
| ↳ `createdBy` | string | Gong unique numeric identifier for the user who added the folder |
| ↳ `updated` | string | Folder's last update time in ISO-8601 format |
### `gong_get_folder_content`
Retrieve the list of calls in a specific library folder from Gong.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accessKey` | string | Yes | Gong API Access Key |
| `accessKeySecret` | string | Yes | Gong API Access Key Secret |
| `folderId` | string | Yes | The library folder ID to retrieve content for |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `folderId` | string | Gong's unique numeric identifier for the folder |
| `folderName` | string | Display name of the folder |
| `createdBy` | string | Gong's unique numeric identifier for the user who added the folder |
| `updated` | string | Folder's last update time in ISO-8601 format |
| `calls` | array | List of calls in the library folder |
| ↳ `id` | string | Gong unique numeric identifier of the call |
| ↳ `title` | string | The title of the call |
| ↳ `note` | string | A note attached to the call in the folder |
| ↳ `addedBy` | string | Gong unique numeric identifier for the user who added the call |
| ↳ `created` | string | Date and time the call was added to folder in ISO-8601 format |
| ↳ `url` | string | URL of the call |
| ↳ `snippet` | object | Call snippet time range |
| ↳ `fromSec` | number | Snippet start in seconds relative to call start |
| ↳ `toSec` | number | Snippet end in seconds relative to call start |
### `gong_list_scorecards`
Retrieve scorecard definitions from Gong settings.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accessKey` | string | Yes | Gong API Access Key |
| `accessKeySecret` | string | Yes | Gong API Access Key Secret |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `scorecards` | array | List of scorecard definitions with questions |
| ↳ `scorecardId` | string | Unique identifier for the scorecard |
| ↳ `scorecardName` | string | Display name of the scorecard |
| ↳ `workspaceId` | string | Workspace identifier associated with this scorecard |
| ↳ `enabled` | boolean | Whether the scorecard is active |
| ↳ `updaterUserId` | string | ID of the user who last modified the scorecard |
| ↳ `created` | string | Creation timestamp in ISO-8601 format |
| ↳ `updated` | string | Last update timestamp in ISO-8601 format |
| ↳ `questions` | array | List of questions in the scorecard |
| ↳ `questionId` | string | Unique identifier for the question |
| ↳ `questionText` | string | The text content of the question |
| ↳ `questionRevisionId` | string | Identifier for the specific revision of the question |
| ↳ `isOverall` | boolean | Whether this is the primary overall question |
| ↳ `created` | string | Question creation timestamp in ISO-8601 format |
| ↳ `updated` | string | Question last update timestamp in ISO-8601 format |
| ↳ `updaterUserId` | string | ID of the user who last modified the question |
### `gong_list_trackers`
Retrieve smart tracker and keyword tracker definitions from Gong settings.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accessKey` | string | Yes | Gong API Access Key |
| `accessKeySecret` | string | Yes | Gong API Access Key Secret |
| `workspaceId` | string | No | The ID of the workspace the keyword trackers are in. When empty, all trackers in all workspaces are returned. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `trackers` | array | List of keyword tracker definitions |
| ↳ `trackerId` | string | Unique identifier for the tracker |
| ↳ `trackerName` | string | Display name of the tracker |
| ↳ `workspaceId` | string | ID of the workspace containing the tracker |
| ↳ `languageKeywords` | array | Keywords organized by language |
| ↳ `language` | string | ISO 639-2/B language code \("mul" means keywords apply across all languages\) |
| ↳ `keywords` | array | Words and phrases in the designated language |
| ↳ `includeRelatedForms` | boolean | Whether to include different word forms |
| ↳ `affiliation` | string | Speaker affiliation filter: "Anyone", "Company", or "NonCompany" |
| ↳ `partOfQuestion` | boolean | Whether to track keywords only within questions |
| ↳ `saidAt` | string | Position in call: "Anytime", "First", or "Last" |
| ↳ `saidAtInterval` | number | Duration to search \(in minutes or percentage\) |
| ↳ `saidAtUnit` | string | Unit for saidAtInterval |
| ↳ `saidInTopics` | array | Topics where keywords should be detected |
| ↳ `saidInCallParts` | array | Specific call segments to monitor |
| ↳ `filterQuery` | string | JSON-formatted call filtering criteria |
| ↳ `created` | string | Creation timestamp in ISO-8601 format |
| ↳ `creatorUserId` | string | ID of the user who created the tracker \(null for built-in trackers\) |
| ↳ `updated` | string | Last modification timestamp in ISO-8601 format |
| ↳ `updaterUserId` | string | ID of the user who last modified the tracker |
### `gong_list_workspaces`
List all company workspaces in Gong.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accessKey` | string | Yes | Gong API Access Key |
| `accessKeySecret` | string | Yes | Gong API Access Key Secret |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `workspaces` | array | List of Gong workspaces |
| ↳ `id` | string | Gong unique numeric identifier for the workspace |
| ↳ `name` | string | Display name of the workspace |
| ↳ `description` | string | Description of the workspace's purpose or content |
### `gong_list_flows`
List Gong Engage flows (sales engagement sequences).
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accessKey` | string | Yes | Gong API Access Key |
| `accessKeySecret` | string | Yes | Gong API Access Key Secret |
| `flowOwnerEmail` | string | Yes | Email of a Gong user. The API will return 'PERSONAL' flows belonging to this user in addition to 'COMPANY' flows. |
| `workspaceId` | string | No | Optional workspace ID to filter flows to a specific workspace |
| `cursor` | string | No | Pagination cursor from a previous API call to retrieve the next page of records |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `requestId` | string | A Gong request reference ID for troubleshooting purposes |
| `flows` | array | List of Gong Engage flows |
| ↳ `id` | string | The ID of the flow |
| ↳ `name` | string | The name of the flow |
| ↳ `folderId` | string | The ID of the folder this flow is under |
| ↳ `folderName` | string | The name of the folder this flow is under |
| ↳ `visibility` | string | The flow visibility type \(COMPANY, PERSONAL, or SHARED\) |
| ↳ `creationDate` | string | Creation time of the flow in ISO-8601 format |
| ↳ `exclusive` | boolean | Indicates whether a prospect in this flow can be added to other flows |
| `totalRecords` | number | Total number of flow records available |
| `currentPageSize` | number | Number of records returned in the current page |
| `currentPageNumber` | number | Current page number |
| `cursor` | string | Pagination cursor for retrieving the next page of records |
### `gong_get_coaching`
Retrieve coaching metrics for a manager from Gong.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accessKey` | string | Yes | Gong API Access Key |
| `accessKeySecret` | string | Yes | Gong API Access Key Secret |
| `managerId` | string | Yes | Gong user ID of the manager |
| `workspaceId` | string | Yes | Gong workspace ID |
| `fromDate` | string | Yes | Start date in ISO-8601 format |
| `toDate` | string | Yes | End date in ISO-8601 format |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `requestId` | string | A Gong request reference ID for troubleshooting purposes |
| `coachingData` | array | The manager user information |
| ↳ `manager` | object | The manager user information |
| ↳ `id` | string | Gong unique numeric identifier for the user |
| ↳ `emailAddress` | string | Email address of the Gong user |
| ↳ `firstName` | string | First name of the Gong user |
| ↳ `lastName` | string | Last name of the Gong user |
| ↳ `title` | string | Job title of the Gong user |
| ↳ `directReportsMetrics` | array | Coaching metrics for each direct report |
| ↳ `report` | object | The direct report user information |
| ↳ `id` | string | Gong unique numeric identifier for the user |
| ↳ `emailAddress` | string | Email address of the Gong user |
| ↳ `firstName` | string | First name of the Gong user |
| ↳ `lastName` | string | Last name of the Gong user |
| ↳ `title` | string | Job title of the Gong user |
| ↳ `metrics` | json | A map of metric names to arrays of string values representing coaching metrics |
### `gong_lookup_email`
Find all references to an email address in Gong (calls, email messages, meetings, CRM data, engagement).
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accessKey` | string | Yes | Gong API Access Key |
| `accessKeySecret` | string | Yes | Gong API Access Key Secret |
| `emailAddress` | string | Yes | Email address to look up |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `requestId` | string | Gong request reference ID for troubleshooting |
| `calls` | array | Related calls referencing this email address |
| ↳ `id` | string | Gong's unique numeric identifier for the call \(up to 20 digits\) |
| ↳ `status` | string | Call status |
| ↳ `externalSystems` | array | Links to external systems such as CRM, Telephony System, etc. |
| ↳ `system` | string | External system name |
| ↳ `objects` | array | List of objects within the external system |
| ↳ `objectType` | string | Object type |
| ↳ `externalId` | string | External ID |
| `emails` | array | Related email messages referencing this email address |
| ↳ `id` | string | Gong's unique 32 character identifier for the email message |
| ↳ `from` | string | The sender's email address |
| ↳ `sentTime` | string | Date and time the email was sent in ISO-8601 format |
| ↳ `mailbox` | string | The mailbox from which the email was retrieved |
| ↳ `messageHash` | string | Hash code of the email message |
| `meetings` | array | Related meetings referencing this email address |
| ↳ `id` | string | Gong's unique identifier for the meeting |
| `customerData` | array | Links to data from external systems \(CRM, Telephony, etc.\) that reference this email |
| ↳ `system` | string | External system name |
| ↳ `objects` | array | List of objects in the external system |
| ↳ `id` | string | Gong's unique numeric identifier for the Lead or Contact \(up to 20 digits\) |
| ↳ `objectType` | string | Object type |
| ↳ `externalId` | string | External ID |
| ↳ `mirrorId` | string | CRM Mirror ID |
| ↳ `fields` | array | Object fields |
| ↳ `name` | string | Field name |
| ↳ `value` | json | Field value |
| `customerEngagement` | array | Customer engagement events \(such as viewing external shared calls\) |
| ↳ `eventType` | string | Event type |
| ↳ `eventName` | string | Event name |
| ↳ `timestamp` | string | Date and time the event occurred in ISO-8601 format |
| ↳ `contentId` | string | Event content ID |
| ↳ `contentUrl` | string | Event content URL |
| ↳ `reportingSystem` | string | Event reporting system |
| ↳ `sourceEventId` | string | Source event ID |
### `gong_lookup_phone`
Find all references to a phone number in Gong (calls, email messages, meetings, CRM data, and associated contacts).
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accessKey` | string | Yes | Gong API Access Key |
| `accessKeySecret` | string | Yes | Gong API Access Key Secret |
| `phoneNumber` | string | Yes | Phone number to look up \(must start with + followed by country code\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `requestId` | string | Gong request reference ID for troubleshooting |
| `suppliedPhoneNumber` | string | The phone number that was supplied in the request |
| `matchingPhoneNumbers` | array | Phone numbers found in the system that match the supplied number |
| `emailAddresses` | array | Email addresses associated with the phone number |
| `calls` | array | Related calls referencing this phone number |
| ↳ `id` | string | Gong's unique numeric identifier for the call \(up to 20 digits\) |
| ↳ `status` | string | Call status |
| ↳ `externalSystems` | array | Links to external systems such as CRM, Telephony System, etc. |
| ↳ `system` | string | External system name |
| ↳ `objects` | array | List of objects within the external system |
| ↳ `objectType` | string | Object type |
| ↳ `externalId` | string | External ID |
| `emails` | array | Related email messages associated with contacts matching this phone number |
| ↳ `id` | string | Gong's unique 32 character identifier for the email message |
| ↳ `from` | string | The sender's email address |
| ↳ `sentTime` | string | Date and time the email was sent in ISO-8601 format |
| ↳ `mailbox` | string | The mailbox from which the email was retrieved |
| ↳ `messageHash` | string | Hash code of the email message |
| `meetings` | array | Related meetings associated with this phone number |
| ↳ `id` | string | Gong's unique identifier for the meeting |
| `customerData` | array | Links to data from external systems \(CRM, Telephony, etc.\) that reference this phone number |
| ↳ `system` | string | External system name |
| ↳ `objects` | array | List of objects in the external system |
| ↳ `id` | string | Gong's unique numeric identifier for the Lead or Contact \(up to 20 digits\) |
| ↳ `objectType` | string | Object type |
| ↳ `externalId` | string | External ID |
| ↳ `mirrorId` | string | CRM Mirror ID |
| ↳ `fields` | array | Object fields |
| ↳ `name` | string | Field name |
| ↳ `value` | json | Field value |

View File

@@ -0,0 +1,168 @@
---
title: Google BigQuery
description: Query, list, and insert data in Google BigQuery
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="google_bigquery"
color="#E0E0E0"
/>
{/* MANUAL-CONTENT-START:intro */}
[Google BigQuery](https://cloud.google.com/bigquery) is Google Cloud's fully managed, serverless data warehouse designed for large-scale data analytics. BigQuery lets you run fast SQL queries on massive datasets, making it ideal for business intelligence, data exploration, and machine learning pipelines. It supports standard SQL, streaming inserts, and integrates with the broader Google Cloud ecosystem.
In Sim, the Google BigQuery integration allows your agents to query datasets, list tables, inspect schemas, and insert rows as part of automated workflows. This enables use cases such as automated reporting, data pipeline orchestration, real-time data ingestion, and analytics-driven decision making. By connecting Sim with BigQuery, your agents can pull insights from petabytes of data, write results back to tables, and keep your analytics workflows running without manual intervention.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Connect to Google BigQuery to run SQL queries, list datasets and tables, get table metadata, and insert rows.
## Tools
### `google_bigquery_query`
Run a SQL query against Google BigQuery and return the results
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `projectId` | string | Yes | Google Cloud project ID |
| `query` | string | Yes | SQL query to execute |
| `useLegacySql` | boolean | No | Whether to use legacy SQL syntax \(default: false\) |
| `maxResults` | number | No | Maximum number of rows to return |
| `defaultDatasetId` | string | No | Default dataset for unqualified table names |
| `location` | string | No | Processing location \(e.g., "US", "EU"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `columns` | array | Array of column names from the query result |
| `rows` | array | Array of row objects keyed by column name |
| `totalRows` | string | Total number of rows in the complete result set |
| `jobComplete` | boolean | Whether the query completed within the timeout |
| `totalBytesProcessed` | string | Total bytes processed by the query |
| `cacheHit` | boolean | Whether the query result was served from cache |
| `jobReference` | object | Job reference \(useful when jobComplete is false\) |
| ↳ `projectId` | string | Project ID containing the job |
| ↳ `jobId` | string | Unique job identifier |
| ↳ `location` | string | Geographic location of the job |
| `pageToken` | string | Token for fetching additional result pages |
### `google_bigquery_list_datasets`
List all datasets in a Google BigQuery project
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `projectId` | string | Yes | Google Cloud project ID |
| `maxResults` | number | No | Maximum number of datasets to return |
| `pageToken` | string | No | Token for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `datasets` | array | Array of dataset objects |
| ↳ `datasetId` | string | Unique dataset identifier |
| ↳ `projectId` | string | Project ID containing this dataset |
| ↳ `friendlyName` | string | Descriptive name for the dataset |
| ↳ `location` | string | Geographic location where the data resides |
| `nextPageToken` | string | Token for fetching next page of results |
### `google_bigquery_list_tables`
List all tables in a Google BigQuery dataset
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `projectId` | string | Yes | Google Cloud project ID |
| `datasetId` | string | Yes | BigQuery dataset ID |
| `maxResults` | number | No | Maximum number of tables to return |
| `pageToken` | string | No | Token for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `tables` | array | Array of table objects |
| ↳ `tableId` | string | Table identifier |
| ↳ `datasetId` | string | Dataset ID containing this table |
| ↳ `projectId` | string | Project ID containing this table |
| ↳ `type` | string | Table type \(TABLE, VIEW, EXTERNAL, etc.\) |
| ↳ `friendlyName` | string | User-friendly name for the table |
| ↳ `creationTime` | string | Time when created, in milliseconds since epoch |
| `totalItems` | number | Total number of tables in the dataset |
| `nextPageToken` | string | Token for fetching next page of results |
### `google_bigquery_get_table`
Get metadata and schema for a Google BigQuery table
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `projectId` | string | Yes | Google Cloud project ID |
| `datasetId` | string | Yes | BigQuery dataset ID |
| `tableId` | string | Yes | BigQuery table ID |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `tableId` | string | Table ID |
| `datasetId` | string | Dataset ID |
| `projectId` | string | Project ID |
| `type` | string | Table type \(TABLE, VIEW, SNAPSHOT, MATERIALIZED_VIEW, EXTERNAL\) |
| `description` | string | Table description |
| `numRows` | string | Total number of rows |
| `numBytes` | string | Total size in bytes, excluding data in streaming buffer |
| `schema` | array | Array of column definitions |
| ↳ `name` | string | Column name |
| ↳ `type` | string | Data type \(STRING, INTEGER, FLOAT, BOOLEAN, TIMESTAMP, RECORD, etc.\) |
| ↳ `mode` | string | Column mode \(NULLABLE, REQUIRED, or REPEATED\) |
| ↳ `description` | string | Column description |
| `creationTime` | string | Table creation time \(milliseconds since epoch\) |
| `lastModifiedTime` | string | Last modification time \(milliseconds since epoch\) |
| `location` | string | Geographic location where the table resides |
### `google_bigquery_insert_rows`
Insert rows into a Google BigQuery table using streaming insert
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `projectId` | string | Yes | Google Cloud project ID |
| `datasetId` | string | Yes | BigQuery dataset ID |
| `tableId` | string | Yes | BigQuery table ID |
| `rows` | string | Yes | JSON array of row objects to insert |
| `skipInvalidRows` | boolean | No | Whether to insert valid rows even if some are invalid |
| `ignoreUnknownValues` | boolean | No | Whether to ignore columns not in the table schema |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `insertedRows` | number | Number of rows successfully inserted |
| `errors` | array | Array of per-row insertion errors \(empty if all succeeded\) |
| ↳ `index` | number | Zero-based index of the row that failed |
| ↳ `errors` | array | Error details for this row |
| ↳ `reason` | string | Short error code summarizing the error |
| ↳ `location` | string | Where the error occurred |
| ↳ `message` | string | Human-readable error description |

View File

@@ -10,6 +10,13 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="#E0E0E0"
/>
{/* MANUAL-CONTENT-START:intro */}
[Google Books](https://books.google.com) is Google's comprehensive book discovery and metadata service, providing access to millions of books from publishers, libraries, and digitized collections worldwide. The Google Books API enables programmatic search and retrieval of detailed book information including titles, authors, descriptions, ratings, and publication details.
In Sim, the Google Books integration allows your agents to search for books and retrieve volume details as part of automated workflows. This enables use cases such as content research, reading list curation, bibliographic data enrichment, ISBN lookups, and knowledge gathering from published works. By connecting Sim with Google Books, your agents can discover and analyze book metadata, filter by availability or format, and incorporate literary references into their outputs—all without manual research.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Search for books using the Google Books API. Find volumes by title, author, ISBN, or keywords, and retrieve detailed information about specific books including descriptions, ratings, and publication details.

View File

@@ -0,0 +1,205 @@
---
title: Google Tasks
description: Manage Google Tasks
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="google_tasks"
color="#E0E0E0"
/>
{/* MANUAL-CONTENT-START:intro */}
[Google Tasks](https://support.google.com/tasks) is Google's lightweight task management service, integrated into Gmail, Google Calendar, and the standalone Google Tasks app. It provides a simple way to create, organize, and track to-do items with support for due dates, subtasks, and task lists. As part of Google Workspace, Google Tasks keeps your action items synchronized across all your devices.
In Sim, the Google Tasks integration allows your agents to create, read, update, delete, and list tasks and task lists as part of automated workflows. This enables use cases such as automated task creation from incoming data, to-do list management based on workflow triggers, task status tracking, and deadline monitoring. By connecting Sim with Google Tasks, your agents can manage action items programmatically, keep teams organized, and ensure nothing falls through the cracks.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate Google Tasks into your workflow. Create, read, update, delete, and list tasks and task lists.
## Tools
### `google_tasks_create`
Create a new task in a Google Tasks list
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `taskListId` | string | No | Task list ID \(defaults to primary task list "@default"\) |
| `title` | string | Yes | Title of the task \(max 1024 characters\) |
| `notes` | string | No | Notes/description for the task \(max 8192 characters\) |
| `due` | string | No | Due date in RFC 3339 format \(e.g., 2025-06-03T00:00:00.000Z\) |
| `status` | string | No | Task status: "needsAction" or "completed" |
| `parent` | string | No | Parent task ID to create this task as a subtask. Omit for top-level tasks. |
| `previous` | string | No | Previous sibling task ID to position after. Omit to place first among siblings. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Task ID |
| `title` | string | Task title |
| `notes` | string | Task notes |
| `status` | string | Task status \(needsAction or completed\) |
| `due` | string | Due date |
| `updated` | string | Last modification time |
| `selfLink` | string | URL for the task |
| `webViewLink` | string | Link to task in Google Tasks UI |
| `parent` | string | Parent task ID |
| `position` | string | Position among sibling tasks |
| `completed` | string | Completion date |
| `deleted` | boolean | Whether the task is deleted |
### `google_tasks_list`
List all tasks in a Google Tasks list
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `taskListId` | string | No | Task list ID \(defaults to primary task list "@default"\) |
| `maxResults` | number | No | Maximum number of tasks to return \(default 20, max 100\) |
| `pageToken` | string | No | Token for pagination |
| `showCompleted` | boolean | No | Whether to show completed tasks \(default true\) |
| `showDeleted` | boolean | No | Whether to show deleted tasks \(default false\) |
| `showHidden` | boolean | No | Whether to show hidden tasks \(default false\) |
| `dueMin` | string | No | Lower bound for due date filter \(RFC 3339 timestamp\) |
| `dueMax` | string | No | Upper bound for due date filter \(RFC 3339 timestamp\) |
| `completedMin` | string | No | Lower bound for task completion date \(RFC 3339 timestamp\) |
| `completedMax` | string | No | Upper bound for task completion date \(RFC 3339 timestamp\) |
| `updatedMin` | string | No | Lower bound for last modification time \(RFC 3339 timestamp\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `tasks` | array | List of tasks |
| ↳ `id` | string | Task identifier |
| ↳ `title` | string | Title of the task |
| ↳ `notes` | string | Notes/description for the task |
| ↳ `status` | string | Task status: "needsAction" or "completed" |
| ↳ `due` | string | Due date \(RFC 3339 timestamp\) |
| ↳ `completed` | string | Completion date \(RFC 3339 timestamp\) |
| ↳ `updated` | string | Last modification time \(RFC 3339 timestamp\) |
| ↳ `selfLink` | string | URL pointing to this task |
| ↳ `webViewLink` | string | Link to task in Google Tasks UI |
| ↳ `parent` | string | Parent task identifier |
| ↳ `position` | string | Position among sibling tasks \(string-based ordering\) |
| ↳ `hidden` | boolean | Whether the task is hidden |
| ↳ `deleted` | boolean | Whether the task is deleted |
| ↳ `links` | array | Collection of links associated with the task |
| ↳ `type` | string | Link type \(e.g., "email", "generic", "chat_message"\) |
| ↳ `description` | string | Link description |
| ↳ `link` | string | The URL |
| `nextPageToken` | string | Token for retrieving the next page of results |
### `google_tasks_get`
Retrieve a specific task by ID from a Google Tasks list
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `taskListId` | string | No | Task list ID \(defaults to primary task list "@default"\) |
| `taskId` | string | Yes | The ID of the task to retrieve |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Task ID |
| `title` | string | Task title |
| `notes` | string | Task notes |
| `status` | string | Task status \(needsAction or completed\) |
| `due` | string | Due date |
| `updated` | string | Last modification time |
| `selfLink` | string | URL for the task |
| `webViewLink` | string | Link to task in Google Tasks UI |
| `parent` | string | Parent task ID |
| `position` | string | Position among sibling tasks |
| `completed` | string | Completion date |
| `deleted` | boolean | Whether the task is deleted |
### `google_tasks_update`
Update an existing task in a Google Tasks list
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `taskListId` | string | No | Task list ID \(defaults to primary task list "@default"\) |
| `taskId` | string | Yes | The ID of the task to update |
| `title` | string | No | New title for the task |
| `notes` | string | No | New notes for the task |
| `due` | string | No | New due date in RFC 3339 format |
| `status` | string | No | New status: "needsAction" or "completed" |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Task ID |
| `title` | string | Task title |
| `notes` | string | Task notes |
| `status` | string | Task status \(needsAction or completed\) |
| `due` | string | Due date |
| `updated` | string | Last modification time |
| `selfLink` | string | URL for the task |
| `webViewLink` | string | Link to task in Google Tasks UI |
| `parent` | string | Parent task ID |
| `position` | string | Position among sibling tasks |
| `completed` | string | Completion date |
| `deleted` | boolean | Whether the task is deleted |
### `google_tasks_delete`
Delete a task from a Google Tasks list
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `taskListId` | string | No | Task list ID \(defaults to primary task list "@default"\) |
| `taskId` | string | Yes | The ID of the task to delete |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `taskId` | string | Deleted task ID |
| `deleted` | boolean | Whether deletion was successful |
### `google_tasks_list_task_lists`
Retrieve all task lists for the authenticated user
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `maxResults` | number | No | Maximum number of task lists to return \(default 20, max 100\) |
| `pageToken` | string | No | Token for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `taskLists` | array | List of task lists |
| ↳ `id` | string | Task list identifier |
| ↳ `title` | string | Title of the task list |
| ↳ `updated` | string | Last modification time \(RFC 3339 timestamp\) |
| ↳ `selfLink` | string | URL pointing to this task list |
| `nextPageToken` | string | Token for retrieving the next page of results |

View File

@@ -0,0 +1,60 @@
---
title: Google Translate
description: Translate text using Google Cloud Translation
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="google_translate"
color="#E0E0E0"
/>
## Usage Instructions
Translate and detect languages using the Google Cloud Translation API. Supports auto-detection of the source language.
## Tools
### `google_translate_text`
Translate text between languages using the Google Cloud Translation API. Supports auto-detection of the source language.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Google Cloud API key with Cloud Translation API enabled |
| `text` | string | Yes | The text to translate |
| `target` | string | Yes | Target language code \(e.g., "es", "fr", "de", "ja"\) |
| `source` | string | No | Source language code. If omitted, the API will auto-detect the source language. |
| `format` | string | No | Format of the text: "text" for plain text, "html" for HTML content |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `translatedText` | string | The translated text |
| `detectedSourceLanguage` | string | The detected source language code \(if source was not specified\) |
### `google_translate_detect`
Detect the language of text using the Google Cloud Translation API.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Google Cloud API key with Cloud Translation API enabled |
| `text` | string | Yes | The text to detect the language of |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `language` | string | The detected language code \(e.g., "en", "es", "fr"\) |
| `confidence` | number | Confidence score of the detection |

View File

@@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="hex"
color="#F5E6FF"
color="#14151A"
/>
{/* MANUAL-CONTENT-START:intro */}

View File

@@ -10,6 +10,7 @@
"apollo",
"arxiv",
"asana",
"attio",
"browser_use",
"calcom",
"calendly",
@@ -20,6 +21,7 @@
"confluence",
"cursor",
"datadog",
"devin",
"discord",
"dropbox",
"dspy",
@@ -35,6 +37,8 @@
"github",
"gitlab",
"gmail",
"gong",
"google_bigquery",
"google_books",
"google_calendar",
"google_docs",
@@ -45,6 +49,8 @@
"google_search",
"google_sheets",
"google_slides",
"google_tasks",
"google_translate",
"google_vault",
"grafana",
"grain",

View File

@@ -5,11 +5,12 @@ description: User-defined data tables for storing and querying structured data
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
<BlockInfoCard
type="table"
color="#10B981"
/>
{/* MANUAL-CONTENT-START:intro */}
Tables allow you to create and manage custom data tables directly within Sim. Store, query, and manipulate structured data within your workflows without needing external database integrations.
**Why Use Tables?**
@@ -26,6 +27,7 @@ Tables allow you to create and manage custom data tables directly within Sim. St
- Batch operations for bulk inserts
- Bulk updates and deletes by filter
- Up to 10,000 rows per table, 100 tables per workspace
{/* MANUAL-CONTENT-END */}
## Creating Tables

View File

@@ -1,96 +0,0 @@
---
title: Environment Variables
---
import { Callout } from 'fumadocs-ui/components/callout'
import { Image } from '@/components/ui/image'
Environment variables provide a secure way to manage configuration values and secrets across your workflows, including API keys and other sensitive data that your workflows need to access. They keep secrets out of your workflow definitions while making them available during execution.
## Variable Types
Environment variables in Sim work at two levels:
- **Personal Environment Variables**: Private to your account, only you can see and use them
- **Workspace Environment Variables**: Shared across the entire workspace, available to all team members
<Callout type="info">
Workspace environment variables take precedence over personal ones when there's a naming conflict.
</Callout>
## Setting up Environment Variables
Navigate to Settings to configure your environment variables:
<Image
src="/static/environment/environment-1.png"
alt="Environment variables modal for creating new variables"
width={500}
height={350}
/>
From your workspace settings, you can create and manage both personal and workspace-level environment variables. Personal variables are private to your account, while workspace variables are shared with all team members.
### Making Variables Workspace-Scoped
Use the workspace scope toggle to make variables available to your entire team:
<Image
src="/static/environment/environment-2.png"
alt="Toggle workspace scope for environment variables"
width={500}
height={350}
/>
When you enable workspace scope, the variable becomes available to all workspace members and can be used in any workflow within that workspace.
### Workspace Variables View
Once you have workspace-scoped variables, they appear in your environment variables list:
<Image
src="/static/environment/environment-3.png"
alt="Workspace-scoped variables in the environment variables list"
width={500}
height={350}
/>
## Using Variables in Workflows
To reference environment variables in your workflows, use the `{{}}` notation. When you type `{{` in any input field, a dropdown will appear showing both your personal and workspace-level environment variables. Simply select the variable you want to use.
<Image
src="/static/environment/environment-4.png"
alt="Using environment variables with double brace notation"
width={500}
height={350}
/>
## How Variables are Resolved
**Workspace variables always take precedence** over personal variables, regardless of who runs the workflow.
When no workspace variable exists for a key, personal variables are used:
- **Manual runs (UI)**: Your personal variables
- **Automated runs (API, webhook, schedule, deployed chat)**: Workflow owner's personal variables
<Callout type="info">
Personal variables are best for testing. Use workspace variables for production workflows.
</Callout>
## Security Best Practices
### For Sensitive Data
- Store API keys, tokens, and passwords as environment variables instead of hardcoding them
- Use workspace variables for shared resources that multiple team members need
- Keep personal credentials in personal variables
### Variable Naming
- Use descriptive names: `DATABASE_URL` instead of `DB`
- Follow consistent naming conventions across your team
- Consider prefixes to avoid conflicts: `PROD_API_KEY`, `DEV_API_KEY`
### Access Control
- Workspace environment variables respect workspace permissions
- Only users with write access or higher can create/modify workspace variables
- Personal variables are always private to the individual user

View File

@@ -1,96 +0,0 @@
---
title: Variables de entorno
---
import { Callout } from 'fumadocs-ui/components/callout'
import { Image } from '@/components/ui/image'
Las variables de entorno proporcionan una forma segura de gestionar valores de configuración y secretos en tus flujos de trabajo, incluyendo claves API y otros datos sensibles que tus flujos de trabajo necesitan acceder. Mantienen los secretos fuera de las definiciones de tu flujo de trabajo mientras los hacen disponibles durante la ejecución.
## Tipos de variables
Las variables de entorno en Sim funcionan en dos niveles:
- **Variables de entorno personales**: Privadas para tu cuenta, solo tú puedes verlas y usarlas
- **Variables de entorno del espacio de trabajo**: Compartidas en todo el espacio de trabajo, disponibles para todos los miembros del equipo
<Callout type="info">
Las variables de entorno del espacio de trabajo tienen prioridad sobre las personales cuando hay un conflicto de nombres.
</Callout>
## Configuración de variables de entorno
Navega a Configuración para configurar tus variables de entorno:
<Image
src="/static/environment/environment-1.png"
alt="Modal de variables de entorno para crear nuevas variables"
width={500}
height={350}
/>
Desde la configuración de tu espacio de trabajo, puedes crear y gestionar variables de entorno tanto personales como a nivel de espacio de trabajo. Las variables personales son privadas para tu cuenta, mientras que las variables del espacio de trabajo se comparten con todos los miembros del equipo.
### Hacer variables con ámbito de espacio de trabajo
Usa el interruptor de ámbito del espacio de trabajo para hacer que las variables estén disponibles para todo tu equipo:
<Image
src="/static/environment/environment-2.png"
alt="Interruptor de ámbito del espacio de trabajo para variables de entorno"
width={500}
height={350}
/>
Cuando habilitas el ámbito del espacio de trabajo, la variable se vuelve disponible para todos los miembros del espacio de trabajo y puede ser utilizada en cualquier flujo de trabajo dentro de ese espacio de trabajo.
### Vista de variables del espacio de trabajo
Una vez que tienes variables con ámbito de espacio de trabajo, aparecen en tu lista de variables de entorno:
<Image
src="/static/environment/environment-3.png"
alt="Variables con ámbito de espacio de trabajo en la lista de variables de entorno"
width={500}
height={350}
/>
## Uso de variables en flujos de trabajo
Para hacer referencia a variables de entorno en tus flujos de trabajo, utiliza la notación `{{}}`. Cuando escribas `{{` en cualquier campo de entrada, aparecerá un menú desplegable mostrando tanto tus variables de entorno personales como las del espacio de trabajo. Simplemente selecciona la variable que deseas utilizar.
<Image
src="/static/environment/environment-4.png"
alt="Uso de variables de entorno con notación de doble llave"
width={500}
height={350}
/>
## Cómo se resuelven las variables
**Las variables del espacio de trabajo siempre tienen prioridad** sobre las variables personales, independientemente de quién ejecute el flujo de trabajo.
Cuando no existe una variable de espacio de trabajo para una clave, se utilizan las variables personales:
- **Ejecuciones manuales (UI)**: Tus variables personales
- **Ejecuciones automatizadas (API, webhook, programación, chat implementado)**: Variables personales del propietario del flujo de trabajo
<Callout type="info">
Las variables personales son mejores para pruebas. Usa variables de espacio de trabajo para flujos de trabajo de producción.
</Callout>
## Mejores prácticas de seguridad
### Para datos sensibles
- Almacena claves API, tokens y contraseñas como variables de entorno en lugar de codificarlos directamente
- Usa variables de espacio de trabajo para recursos compartidos que varios miembros del equipo necesitan
- Mantén las credenciales personales en variables personales
### Nomenclatura de variables
- Usa nombres descriptivos: `DATABASE_URL` en lugar de `DB`
- Sigue convenciones de nomenclatura consistentes en todo tu equipo
- Considera usar prefijos para evitar conflictos: `PROD_API_KEY`, `DEV_API_KEY`
### Control de acceso
- Las variables de entorno del espacio de trabajo respetan los permisos del espacio de trabajo
- Solo los usuarios con acceso de escritura o superior pueden crear/modificar variables del espacio de trabajo
- Las variables personales siempre son privadas para el usuario individual

View File

@@ -1,96 +0,0 @@
---
title: Variables d'environnement
---
import { Callout } from 'fumadocs-ui/components/callout'
import { Image } from '@/components/ui/image'
Les variables d'environnement offrent un moyen sécurisé de gérer les valeurs de configuration et les secrets dans vos workflows, y compris les clés API et autres données sensibles dont vos workflows ont besoin. Elles gardent les secrets en dehors de vos définitions de workflow tout en les rendant disponibles pendant l'exécution.
## Types de variables
Les variables d'environnement dans Sim fonctionnent à deux niveaux :
- **Variables d'environnement personnelles** : privées à votre compte, vous seul pouvez les voir et les utiliser
- **Variables d'environnement d'espace de travail** : partagées dans tout l'espace de travail, disponibles pour tous les membres de l'équipe
<Callout type="info">
Les variables d'environnement d'espace de travail ont priorité sur les variables personnelles en cas de conflit de noms.
</Callout>
## Configuration des variables d'environnement
Accédez aux Paramètres pour configurer vos variables d'environnement :
<Image
src="/static/environment/environment-1.png"
alt="Fenêtre modale de variables d'environnement pour créer de nouvelles variables"
width={500}
height={350}
/>
Depuis les paramètres de votre espace de travail, vous pouvez créer et gérer des variables d'environnement personnelles et au niveau de l'espace de travail. Les variables personnelles sont privées à votre compte, tandis que les variables d'espace de travail sont partagées avec tous les membres de l'équipe.
### Définir des variables au niveau de l'espace de travail
Utilisez le bouton de portée d'espace de travail pour rendre les variables disponibles à toute votre équipe :
<Image
src="/static/environment/environment-2.png"
alt="Activer la portée d'espace de travail pour les variables d'environnement"
width={500}
height={350}
/>
Lorsque vous activez la portée d'espace de travail, la variable devient disponible pour tous les membres de l'espace de travail et peut être utilisée dans n'importe quel workflow au sein de cet espace de travail.
### Vue des variables d'espace de travail
Une fois que vous avez des variables à portée d'espace de travail, elles apparaissent dans votre liste de variables d'environnement :
<Image
src="/static/environment/environment-3.png"
alt="Variables à portée d'espace de travail dans la liste des variables d'environnement"
width={500}
height={350}
/>
## Utilisation des variables dans les workflows
Pour référencer des variables d'environnement dans vos workflows, utilisez la notation `{{}}`. Lorsque vous tapez `{{` dans n'importe quel champ de saisie, un menu déroulant apparaîtra affichant à la fois vos variables d'environnement personnelles et celles au niveau de l'espace de travail. Sélectionnez simplement la variable que vous souhaitez utiliser.
<Image
src="/static/environment/environment-4.png"
alt="Utilisation des variables d'environnement avec la notation à double accolade"
width={500}
height={350}
/>
## Comment les variables sont résolues
**Les variables d'espace de travail ont toujours la priorité** sur les variables personnelles, quel que soit l'utilisateur qui exécute le flux de travail.
Lorsqu'aucune variable d'espace de travail n'existe pour une clé, les variables personnelles sont utilisées :
- **Exécutions manuelles (UI)** : Vos variables personnelles
- **Exécutions automatisées (API, webhook, planification, chat déployé)** : Variables personnelles du propriétaire du flux de travail
<Callout type="info">
Les variables personnelles sont idéales pour les tests. Utilisez les variables d'espace de travail pour les flux de travail en production.
</Callout>
## Bonnes pratiques de sécurité
### Pour les données sensibles
- Stockez les clés API, les jetons et les mots de passe comme variables d'environnement au lieu de les coder en dur
- Utilisez des variables d'espace de travail pour les ressources partagées dont plusieurs membres de l'équipe ont besoin
- Conservez vos identifiants personnels dans des variables personnelles
### Nommage des variables
- Utilisez des noms descriptifs : `DATABASE_URL` au lieu de `DB`
- Suivez des conventions de nommage cohérentes au sein de votre équipe
- Envisagez des préfixes pour éviter les conflits : `PROD_API_KEY`, `DEV_API_KEY`
### Contrôle d'accès
- Les variables d'environnement de l'espace de travail respectent les permissions de l'espace de travail
- Seuls les utilisateurs disposant d'un accès en écriture ou supérieur peuvent créer/modifier les variables d'espace de travail
- Les variables personnelles sont toujours privées pour l'utilisateur individuel

View File

@@ -1,96 +0,0 @@
---
title: 環境変数
---
import { Callout } from 'fumadocs-ui/components/callout'
import { Image } from '@/components/ui/image'
環境変数は、APIキーやワークフローがアクセスする必要のあるその他の機密データなど、ワークフロー全体で設定値や機密情報を安全に管理する方法を提供します。これにより、実行中にそれらを利用可能にしながら、ワークフロー定義から機密情報を切り離すことができます。
## 変数タイプ
Simの環境変数は2つのレベルで機能します
- **個人環境変数**:あなたのアカウントに限定され、あなただけが閲覧・使用できます
- **ワークスペース環境変数**:ワークスペース全体で共有され、すべてのチームメンバーが利用できます
<Callout type="info">
名前の競合がある場合、ワークスペース環境変数は個人環境変数よりも優先されます。
</Callout>
## 環境変数の設定
設定に移動して環境変数を構成します:
<Image
src="/static/environment/environment-1.png"
alt="新しい変数を作成するための環境変数モーダル"
width={500}
height={350}
/>
ワークスペース設定から、個人レベルとワークスペースレベルの両方の環境変数を作成・管理できます。個人変数はあなたのアカウントに限定されますが、ワークスペース変数はすべてのチームメンバーと共有されます。
### 変数をワークスペーススコープにする
ワークスペーススコープトグルを使用して、変数をチーム全体で利用可能にします:
<Image
src="/static/environment/environment-2.png"
alt="環境変数のワークスペーススコープを切り替えるトグル"
width={500}
height={350}
/>
ワークスペーススコープを有効にすると、その変数はすべてのワークスペースメンバーが利用でき、そのワークスペース内のあらゆるワークフローで使用できるようになります。
### ワークスペース変数ビュー
ワークスペーススコープの変数を作成すると、環境変数リストに表示されます:
<Image
src="/static/environment/environment-3.png"
alt="環境変数リスト内のワークスペーススコープ変数"
width={500}
height={350}
/>
## ワークフローでの変数の使用
ワークフローで環境変数を参照するには、`{{}}`表記を使用します。任意の入力フィールドで`{{`と入力すると、個人用とワークスペースレベルの両方の環境変数を表示するドロップダウンが表示されます。使用したい変数を選択するだけです。
<Image
src="/static/environment/environment-4.png"
alt="二重括弧表記を使用した環境変数の使用方法"
width={500}
height={350}
/>
## 変数の解決方法
**ワークスペース変数は常に優先されます**。誰がワークフローを実行するかに関わらず、個人変数よりも優先されます。
キーに対するワークスペース変数が存在しない場合、個人変数が使用されます:
- **手動実行UI**:あなたの個人変数
- **自動実行API、ウェブフック、スケジュール、デプロイされたチャット**:ワークフロー所有者の個人変数
<Callout type="info">
個人変数はテストに最適です。本番環境のワークフローにはワークスペース変数を使用してください。
</Callout>
## セキュリティのベストプラクティス
### 機密データについて
- APIキー、トークン、パスワードはハードコーディングせず、環境変数として保存してください
- 複数のチームメンバーが必要とする共有リソースにはワークスペース変数を使用してください
- 個人の認証情報は個人変数に保管してください
### 変数の命名
- 説明的な名前を使用する:`DATABASE_URL`ではなく`DB`
- チーム全体で一貫した命名規則に従う
- 競合を避けるために接頭辞を検討する:`PROD_API_KEY`、`DEV_API_KEY`
### アクセス制御
- ワークスペース環境変数はワークスペースの権限を尊重します
- 書き込みアクセス権以上を持つユーザーのみがワークスペース変数を作成/変更できます
- 個人変数は常に個々のユーザーにプライベートです

View File

@@ -1,96 +0,0 @@
---
title: 环境变量
---
import { Callout } from 'fumadocs-ui/components/callout'
import { Image } from '@/components/ui/image'
环境变量为管理工作流中的配置值和密钥(包括 API 密钥和其他敏感数据)提供了一种安全的方式。它们可以在执行期间使用,同时将敏感信息从工作流定义中隔离开来。
## 变量类型
Sim 中的环境变量分为两个级别:
- **个人环境变量**:仅限于您的账户,只有您可以查看和使用
- **工作区环境变量**:在整个工作区内共享,所有团队成员都可以使用
<Callout type="info">
当命名冲突时,工作区环境变量优先于个人环境变量。
</Callout>
## 设置环境变量
前往设置页面配置您的环境变量:
<Image
src="/static/environment/environment-1.png"
alt="用于创建新变量的环境变量弹窗"
width={500}
height={350}
/>
在工作区设置中,您可以创建和管理个人及工作区级别的环境变量。个人变量仅限于您的账户,而工作区变量会与所有团队成员共享。
### 将变量设为工作区范围
使用工作区范围切换按钮,使变量对整个团队可用:
<Image
src="/static/environment/environment-2.png"
alt="切换环境变量的工作区范围"
width={500}
height={350}
/>
启用工作区范围后,该变量将对所有工作区成员可用,并可在该工作区内的任何工作流中使用。
### 工作区变量视图
一旦您拥有了工作区范围的变量,它们将显示在您的环境变量列表中:
<Image
src="/static/environment/environment-3.png"
alt="环境变量列表中的工作区范围变量"
width={500}
height={350}
/>
## 在工作流中使用变量
要在工作流中引用环境变量,请使用 `{{}}` 表示法。当您在任何输入字段中键入 `{{` 时,将会出现一个下拉菜单,显示您的个人和工作区级别的环境变量。只需选择您想要使用的变量即可。
<Image
src="/static/environment/environment-4.png"
alt="使用双大括号表示法的环境变量"
width={500}
height={350}
/>
## 变量的解析方式
**工作区变量始终优先于**个人变量,无论是谁运行工作流。
当某个键没有工作区变量时,将使用个人变量:
- **手动运行UI**:使用您的个人变量
- **自动运行API、Webhook、计划任务、已部署的聊天**:使用工作流所有者的个人变量
<Callout type="info">
个人变量最适合用于测试。生产环境的工作流请使用工作区变量。
</Callout>
## 安全最佳实践
### 针对敏感数据
- 将 API 密钥、令牌和密码存储为环境变量,而不是硬编码它们
- 对于多个团队成员需要的共享资源,使用工作区变量
- 将个人凭据保存在个人变量中
### 变量命名
- 使用描述性名称:`DATABASE_URL` 而不是 `DB`
- 在团队中遵循一致的命名约定
- 考虑使用前缀以避免冲突:`PROD_API_KEY`、`DEV_API_KEY`
### 访问控制
- 工作区环境变量遵循工作区权限
- 只有具有写入权限或更高权限的用户才能创建/修改工作区变量
- 个人变量始终对个人用户私有

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

@@ -5,6 +5,7 @@ import { createContext, useCallback, useEffect, useMemo, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import posthog from 'posthog-js'
import { client } from '@/lib/auth/auth-client'
import { extractSessionDataFromAuthClientResult } from '@/lib/auth/session-response'
export type AppSession = {
user: {
@@ -45,7 +46,8 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
const res = bypassCache
? await client.getSession({ query: { disableCookieCache: true } })
: await client.getSession()
setData(res?.data ?? null)
const session = extractSessionDataFromAuthClientResult(res) as AppSession
setData(session)
} catch (e) {
setError(e instanceof Error ? e : new Error('Failed to fetch session'))
} finally {

View File

@@ -0,0 +1,93 @@
/**
* @vitest-environment node
*/
import { createMockRequest, setupCommonApiMocks } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const handlerMocks = vi.hoisted(() => ({
betterAuthGET: vi.fn(),
betterAuthPOST: vi.fn(),
ensureAnonymousUserExists: vi.fn(),
createAnonymousGetSessionResponse: vi.fn(() => ({
data: {
user: { id: 'anon' },
session: { id: 'anon-session' },
},
})),
}))
vi.mock('better-auth/next-js', () => ({
toNextJsHandler: () => ({
GET: handlerMocks.betterAuthGET,
POST: handlerMocks.betterAuthPOST,
}),
}))
vi.mock('@/lib/auth', () => ({
auth: { handler: {} },
}))
vi.mock('@/lib/auth/anonymous', () => ({
ensureAnonymousUserExists: handlerMocks.ensureAnonymousUserExists,
createAnonymousGetSessionResponse: handlerMocks.createAnonymousGetSessionResponse,
}))
describe('auth catch-all route (DISABLE_AUTH get-session)', () => {
beforeEach(() => {
vi.resetModules()
setupCommonApiMocks()
handlerMocks.betterAuthGET.mockReset()
handlerMocks.betterAuthPOST.mockReset()
handlerMocks.ensureAnonymousUserExists.mockReset()
handlerMocks.createAnonymousGetSessionResponse.mockClear()
})
it('returns anonymous session in better-auth response envelope when auth is disabled', async () => {
vi.doMock('@/lib/core/config/feature-flags', () => ({ isAuthDisabled: true }))
const req = createMockRequest(
'GET',
undefined,
{},
'http://localhost:3000/api/auth/get-session'
)
const { GET } = await import('@/app/api/auth/[...all]/route')
const res = await GET(req as any)
const json = await res.json()
expect(handlerMocks.ensureAnonymousUserExists).toHaveBeenCalledTimes(1)
expect(handlerMocks.betterAuthGET).not.toHaveBeenCalled()
expect(json).toEqual({
data: {
user: { id: 'anon' },
session: { id: 'anon-session' },
},
})
})
it('delegates to better-auth handler when auth is enabled', async () => {
vi.doMock('@/lib/core/config/feature-flags', () => ({ isAuthDisabled: false }))
handlerMocks.betterAuthGET.mockResolvedValueOnce(
new (await import('next/server')).NextResponse(JSON.stringify({ data: { ok: true } }), {
headers: { 'content-type': 'application/json' },
}) as any
)
const req = createMockRequest(
'GET',
undefined,
{},
'http://localhost:3000/api/auth/get-session'
)
const { GET } = await import('@/app/api/auth/[...all]/route')
const res = await GET(req as any)
const json = await res.json()
expect(handlerMocks.ensureAnonymousUserExists).not.toHaveBeenCalled()
expect(handlerMocks.betterAuthGET).toHaveBeenCalledTimes(1)
expect(json).toEqual({ data: { ok: true } })
})
})

View File

@@ -1,7 +1,7 @@
import { toNextJsHandler } from 'better-auth/next-js'
import { type NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { createAnonymousSession, ensureAnonymousUserExists } from '@/lib/auth/anonymous'
import { createAnonymousGetSessionResponse, ensureAnonymousUserExists } from '@/lib/auth/anonymous'
import { isAuthDisabled } from '@/lib/core/config/feature-flags'
export const dynamic = 'force-dynamic'
@@ -14,7 +14,7 @@ export async function GET(request: NextRequest) {
if (path === 'get-session' && isAuthDisabled) {
await ensureAnonymousUserExists()
return NextResponse.json(createAnonymousSession())
return NextResponse.json(createAnonymousGetSessionResponse())
}
return betterAuthGET(request)

View File

@@ -33,7 +33,6 @@ export async function POST(req: NextRequest) {
logger.info(`[${requestId}] Update cost request started`)
if (!isBillingEnabled) {
logger.debug(`[${requestId}] Billing is disabled, skipping cost update`)
return NextResponse.json({
success: true,
message: 'Billing disabled, cost update skipped',

View File

@@ -117,8 +117,6 @@ export async function POST(
const requestId = generateRequestId()
try {
logger.debug(`[${requestId}] Processing OTP request for identifier: ${identifier}`)
const body = await request.json()
const { email } = otpRequestSchema.parse(body)
@@ -211,8 +209,6 @@ export async function PUT(
const requestId = generateRequestId()
try {
logger.debug(`[${requestId}] Verifying OTP for identifier: ${identifier}`)
const body = await request.json()
const { email, otp } = otpVerifySchema.parse(body)

View File

@@ -42,8 +42,6 @@ export async function POST(
const requestId = generateRequestId()
try {
logger.debug(`[${requestId}] Processing chat request for identifier: ${identifier}`)
let parsedBody
try {
const rawBody = await request.json()
@@ -294,8 +292,6 @@ export async function GET(
const requestId = generateRequestId()
try {
logger.debug(`[${requestId}] Fetching chat info for identifier: ${identifier}`)
const deploymentResult = await db
.select({
id: chat.id,

View File

@@ -95,11 +95,6 @@ export async function POST(request: NextRequest) {
const body = await request.json()
const data = CreateCreatorProfileSchema.parse(body)
logger.debug(`[${requestId}] Creating creator profile:`, {
referenceType: data.referenceType,
referenceId: data.referenceId,
})
// Validate permissions
if (data.referenceType === 'user') {
if (data.referenceId !== session.user.id) {

View File

@@ -150,6 +150,7 @@ export async function POST(
})
recordAudit({
workspaceId: null,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
@@ -158,7 +159,7 @@ export async function POST(
resourceId: id,
resourceName: result.set.name,
description: `Resent credential set invitation to ${invitation.email}`,
metadata: { invitationId, email: invitation.email },
metadata: { invitationId, targetEmail: invitation.email },
request: req,
})

View File

@@ -186,6 +186,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
actorEmail: session.user.email ?? undefined,
resourceName: result.set.name,
description: `Created invitation for credential set "${result.set.name}"${email ? ` to ${email}` : ''}`,
metadata: { targetEmail: email || undefined },
request: req,
})
@@ -239,7 +240,7 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 })
}
await db
const [revokedInvitation] = await db
.update(credentialSetInvitation)
.set({ status: 'cancelled' })
.where(
@@ -248,6 +249,7 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
eq(credentialSetInvitation.credentialSetId, id)
)
)
.returning({ email: credentialSetInvitation.email })
recordAudit({
workspaceId: null,
@@ -259,6 +261,7 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
actorEmail: session.user.email ?? undefined,
resourceName: result.set.name,
description: `Revoked invitation "${invitationId}" for credential set "${result.set.name}"`,
metadata: { targetEmail: revokedInvitation?.email ?? undefined },
request: req,
})

View File

@@ -151,8 +151,15 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
}
const [memberToRemove] = await db
.select()
.select({
id: credentialSetMember.id,
credentialSetId: credentialSetMember.credentialSetId,
userId: credentialSetMember.userId,
status: credentialSetMember.status,
email: user.email,
})
.from(credentialSetMember)
.innerJoin(user, eq(credentialSetMember.userId, user.id))
.where(and(eq(credentialSetMember.id, memberId), eq(credentialSetMember.credentialSetId, id)))
.limit(1)
@@ -189,6 +196,7 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
actorEmail: session.user.email ?? undefined,
resourceName: result.set.name,
description: `Removed member from credential set "${result.set.name}"`,
metadata: { targetEmail: memberToRemove.email ?? undefined },
request: req,
})

View File

@@ -148,6 +148,10 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
actorEmail: session.user.email ?? undefined,
resourceName: name,
description: `Duplicated folder "${sourceFolder.name}" as "${name}"`,
metadata: {
sourceId: sourceFolder.id,
affected: { workflows: workflowStats.succeeded, folders: folderMapping.size },
},
request: req,
})

View File

@@ -178,6 +178,12 @@ export async function DELETE(
resourceId: id,
resourceName: existingFolder.name,
description: `Deleted folder "${existingFolder.name}"`,
metadata: {
affected: {
workflows: deletionStats.workflows,
subfolders: deletionStats.folders - 1,
},
},
request,
})

View File

@@ -58,8 +58,6 @@ export async function POST(
const requestId = generateRequestId()
try {
logger.debug(`[${requestId}] Processing form submission for identifier: ${identifier}`)
let parsedBody
try {
const rawBody = await request.json()
@@ -300,8 +298,6 @@ export async function GET(
const requestId = generateRequestId()
try {
logger.debug(`[${requestId}] Fetching form info for identifier: ${identifier}`)
const deploymentResult = await db
.select({
id: form.id,

View File

@@ -77,8 +77,6 @@ export async function POST(req: NextRequest) {
}
}
logger.debug(`[${requestId}] Help request includes ${images.length} images`)
const userId = session.user.id
let emailText = `
Type: ${type}

View File

@@ -281,6 +281,7 @@ export async function DELETE(
resourceId: documentId,
resourceName: accessCheck.document?.filename,
description: `Deleted document "${documentId}" from knowledge base "${knowledgeBaseId}"`,
metadata: { fileName: accessCheck.document?.filename },
request: req,
})

View File

@@ -255,6 +255,10 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
resourceId: knowledgeBaseId,
resourceName: `${createdDocuments.length} document(s)`,
description: `Uploaded ${createdDocuments.length} document(s) to knowledge base "${knowledgeBaseId}"`,
metadata: {
fileCount: createdDocuments.length,
fileNames: createdDocuments.map((doc) => doc.filename),
},
request: req,
})
@@ -316,6 +320,11 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
resourceId: knowledgeBaseId,
resourceName: validatedData.filename,
description: `Uploaded document "${validatedData.filename}" to knowledge base "${knowledgeBaseId}"`,
metadata: {
fileName: validatedData.filename,
fileType: validatedData.mimeType,
fileSize: validatedData.fileSize,
},
request: req,
})

View File

@@ -186,8 +186,6 @@ export async function POST(request: NextRequest) {
valueTo: filter.valueTo,
}
})
logger.debug(`[${requestId}] Processed ${structuredFilters.length} structured filters`)
}
if (accessibleKbIds.length === 0) {
@@ -220,7 +218,6 @@ export async function POST(request: NextRequest) {
if (!hasQuery && hasFilters) {
// Tag-only search without vector similarity
logger.debug(`[${requestId}] Executing tag-only search with filters:`, structuredFilters)
results = await handleTagOnlySearch({
knowledgeBaseIds: accessibleKbIds,
topK: validatedData.topK,
@@ -244,7 +241,6 @@ export async function POST(request: NextRequest) {
})
} else if (hasQuery && !hasFilters) {
// Vector-only search
logger.debug(`[${requestId}] Executing vector-only search`)
const strategy = getQueryStrategy(accessibleKbIds.length, validatedData.topK)
const queryVector = JSON.stringify(await queryEmbeddingPromise)

View File

@@ -1,11 +1,8 @@
import { db } from '@sim/db'
import { document, embedding } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray, isNull, sql } from 'drizzle-orm'
import type { StructuredFilter } from '@/lib/knowledge/types'
const logger = createLogger('KnowledgeSearchUtils')
export async function getDocumentNamesByIds(
documentIds: string[]
): Promise<Record<string, string>> {
@@ -140,17 +137,12 @@ function buildFilterCondition(filter: StructuredFilter, embeddingTable: any) {
const { tagSlot, fieldType, operator, value, valueTo } = filter
if (!isTagSlotKey(tagSlot)) {
logger.debug(`[getStructuredTagFilters] Unknown tag slot: ${tagSlot}`)
return null
}
const column = embeddingTable[tagSlot]
if (!column) return null
logger.debug(
`[getStructuredTagFilters] Processing ${tagSlot} (${fieldType}) ${operator} ${value}`
)
// Handle text operators
if (fieldType === 'text') {
const stringValue = String(value)
@@ -208,7 +200,6 @@ function buildFilterCondition(filter: StructuredFilter, embeddingTable: any) {
const dateStr = String(value)
// Validate YYYY-MM-DD format
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
logger.debug(`[getStructuredTagFilters] Invalid date format: ${dateStr}, expected YYYY-MM-DD`)
return null
}
@@ -287,9 +278,6 @@ function getStructuredTagFilters(filters: StructuredFilter[], embeddingTable: an
conditions.push(slotConditions[0])
} else {
// Multiple conditions for same slot - OR them together
logger.debug(
`[getStructuredTagFilters] OR'ing ${slotConditions.length} conditions for ${slot}`
)
conditions.push(sql`(${sql.join(slotConditions, sql` OR `)})`)
}
}
@@ -380,8 +368,6 @@ export async function handleTagOnlySearch(params: SearchParams): Promise<SearchR
throw new Error('Tag filters are required for tag-only search')
}
logger.debug(`[handleTagOnlySearch] Executing tag-only search with filters:`, structuredFilters)
const strategy = getQueryStrategy(knowledgeBaseIds.length, topK)
const tagFilterConditions = getStructuredTagFilters(structuredFilters, embedding)
@@ -431,8 +417,6 @@ export async function handleVectorOnlySearch(params: SearchParams): Promise<Sear
throw new Error('Query vector and distance threshold are required for vector-only search')
}
logger.debug(`[handleVectorOnlySearch] Executing vector-only search`)
const strategy = getQueryStrategy(knowledgeBaseIds.length, topK)
const distanceExpr = sql<number>`${embedding.embedding} <=> ${queryVector}::vector`.as('distance')
@@ -489,23 +473,13 @@ export async function handleTagAndVectorSearch(params: SearchParams): Promise<Se
throw new Error('Query vector and distance threshold are required for tag and vector search')
}
logger.debug(
`[handleTagAndVectorSearch] Executing tag + vector search with filters:`,
structuredFilters
)
// Step 1: Filter by tags first
const tagFilteredIds = await executeTagFilterQuery(knowledgeBaseIds, structuredFilters)
if (tagFilteredIds.length === 0) {
logger.debug(`[handleTagAndVectorSearch] No results found after tag filtering`)
return []
}
logger.debug(
`[handleTagAndVectorSearch] Found ${tagFilteredIds.length} results after tag filtering`
)
// Step 2: Perform vector search only on tag-filtered results
return await executeVectorSearchOnIds(
tagFilteredIds.map((r) => r.id),

View File

@@ -34,10 +34,6 @@ export async function GET(
const authenticatedUserId = authResult.userId
logger.debug(
`[${requestId}] Fetching execution data for: ${executionId} (auth: ${authResult.authType})`
)
const [workflowLog] = await db
.select({
id: workflowExecutionLogs.id,
@@ -125,11 +121,6 @@ export async function GET(
},
}
logger.debug(`[${requestId}] Successfully fetched execution data for: ${executionId}`)
logger.debug(
`[${requestId}] Workflow state contains ${Object.keys((snapshot.stateData as any)?.blocks || {}).length} blocks`
)
return NextResponse.json(response)
} catch (error) {
logger.error(`[${requestId}] Error fetching execution data:`, error)

View File

@@ -23,6 +23,7 @@ import { type AuthResult, checkHybridAuth } from '@/lib/auth/hybrid'
import { generateInternalToken } from '@/lib/auth/internal'
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
import { SIM_VIA_HEADER } from '@/lib/execution/call-chain'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkflowMcpServeAPI')
@@ -181,7 +182,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<R
serverId,
rpcParams as { name: string; arguments?: Record<string, unknown> },
executeAuthContext,
server.isPublic ? server.createdBy : undefined
server.isPublic ? server.createdBy : undefined,
request.headers.get(SIM_VIA_HEADER)
)
default:
@@ -244,7 +246,8 @@ async function handleToolsCall(
serverId: string,
params: { name: string; arguments?: Record<string, unknown> } | undefined,
executeAuthContext?: ExecuteAuthContext | null,
publicServerOwnerId?: string
publicServerOwnerId?: string,
simViaHeader?: string | null
): Promise<NextResponse> {
try {
if (!params?.name) {
@@ -300,6 +303,10 @@ async function handleToolsCall(
}
}
if (simViaHeader) {
headers[SIM_VIA_HEADER] = simViaHeader
}
logger.info(`Executing workflow ${tool.workflowId} via MCP tool ${params.name}`)
const response = await fetch(executeUrl, {

View File

@@ -3,6 +3,7 @@ import type { NextRequest } from 'next/server'
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
import { getExecutionTimeout } from '@/lib/core/execution-limits'
import type { SubscriptionPlan } from '@/lib/core/rate-limiter/types'
import { SIM_VIA_HEADER } from '@/lib/execution/call-chain'
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
import { mcpService } from '@/lib/mcp/service'
import type { McpTool, McpToolCall, McpToolResult } from '@/lib/mcp/types'
@@ -83,7 +84,6 @@ export const POST = withMcpAuth('read')(
serverId: serverId,
serverName: 'provided-schema',
} as McpTool
logger.debug(`[${requestId}] Using provided schema for ${toolName}, skipping discovery`)
} else {
const tools = await mcpService.discoverServerTools(userId, serverId, workspaceId)
tool = tools.find((t) => t.name === toolName) ?? null
@@ -179,8 +179,14 @@ export const POST = withMcpAuth('read')(
'sync'
)
const simViaHeader = request.headers.get(SIM_VIA_HEADER)
const extraHeaders: Record<string, string> = {}
if (simViaHeader) {
extraHeaders[SIM_VIA_HEADER] = simViaHeader
}
const result = await Promise.race([
mcpService.executeTool(userId, serverId, toolCall, workspaceId),
mcpService.executeTool(userId, serverId, toolCall, workspaceId, extraHeaders),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Tool execution timeout')), executionTimeout)
),

View File

@@ -598,7 +598,12 @@ export async function PUT(
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
description: `Organization invitation ${status} for ${orgInvitation.email}`,
metadata: { invitationId, email: orgInvitation.email, status },
metadata: {
invitationId,
targetEmail: orgInvitation.email,
targetRole: orgInvitation.role,
status,
},
request: req,
})

View File

@@ -423,7 +423,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
actorEmail: session.user.email ?? undefined,
resourceName: organizationEntry[0]?.name,
description: `Invited ${inv.email} to organization as ${role}`,
metadata: { invitationId: inv.id, email: inv.email, role },
metadata: { invitationId: inv.id, targetEmail: inv.email, targetRole: role },
request,
})
}
@@ -558,7 +558,7 @@ export async function DELETE(
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
description: `Revoked organization invitation for ${result[0].email}`,
metadata: { invitationId, email: result[0].email },
metadata: { invitationId, targetEmail: result[0].email },
request,
})

View File

@@ -173,8 +173,15 @@ export async function PUT(
}
const targetMember = await db
.select()
.select({
id: member.id,
role: member.role,
userId: member.userId,
email: user.email,
name: user.name,
})
.from(member)
.innerJoin(user, eq(member.userId, user.id))
.where(and(eq(member.organizationId, organizationId), eq(member.userId, memberId)))
.limit(1)
@@ -223,7 +230,12 @@ export async function PUT(
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
description: `Changed role for member ${memberId} to ${role}`,
metadata: { targetUserId: memberId, newRole: role },
metadata: {
targetUserId: memberId,
targetEmail: targetMember[0].email ?? undefined,
targetName: targetMember[0].name ?? undefined,
changes: [{ field: 'role', from: targetMember[0].role, to: role }],
},
request,
})
@@ -286,8 +298,9 @@ export async function DELETE(
}
const targetMember = await db
.select({ id: member.id, role: member.role })
.select({ id: member.id, role: member.role, email: user.email, name: user.name })
.from(member)
.innerJoin(user, eq(member.userId, user.id))
.where(and(eq(member.organizationId, organizationId), eq(member.userId, targetUserId)))
.limit(1)
@@ -331,7 +344,12 @@ export async function DELETE(
session.user.id === targetUserId
? 'Left the organization'
: `Removed member ${targetUserId} from organization`,
metadata: { targetUserId, wasSelfRemoval: session.user.id === targetUserId },
metadata: {
targetUserId,
targetEmail: targetMember[0].email ?? undefined,
targetName: targetMember[0].name ?? undefined,
wasSelfRemoval: session.user.id === targetUserId,
},
request,
})

View File

@@ -295,7 +295,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
description: `Invited ${normalizedEmail} to organization as ${role}`,
metadata: { invitationId, email: normalizedEmail, role },
metadata: { invitationId, targetEmail: normalizedEmail, targetRole: role },
request,
})

View File

@@ -100,8 +100,9 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
const { userId } = addMemberSchema.parse(body)
const [orgMember] = await db
.select({ id: member.id })
.select({ id: member.id, email: user.email })
.from(member)
.innerJoin(user, eq(member.userId, user.id))
.where(and(eq(member.userId, userId), eq(member.organizationId, result.group.organizationId)))
.limit(1)
@@ -163,7 +164,11 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
description: `Added member ${userId} to permission group "${result.group.name}"`,
metadata: { targetUserId: userId, permissionGroupId: id },
metadata: {
targetUserId: userId,
targetEmail: orgMember.email ?? undefined,
permissionGroupId: id,
},
request: req,
})
@@ -218,8 +223,14 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
}
const [memberToRemove] = await db
.select()
.select({
id: permissionGroupMember.id,
permissionGroupId: permissionGroupMember.permissionGroupId,
userId: permissionGroupMember.userId,
email: user.email,
})
.from(permissionGroupMember)
.innerJoin(user, eq(permissionGroupMember.userId, user.id))
.where(
and(eq(permissionGroupMember.id, memberId), eq(permissionGroupMember.permissionGroupId, id))
)
@@ -247,7 +258,12 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
description: `Removed member ${memberToRemove.userId} from permission group "${result.group.name}"`,
metadata: { targetUserId: memberToRemove.userId, memberId, permissionGroupId: id },
metadata: {
targetUserId: memberToRemove.userId,
targetEmail: memberToRemove.email ?? undefined,
memberId,
permissionGroupId: id,
},
request: req,
})

View File

@@ -26,7 +26,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
try {
const { id: scheduleId } = await params
logger.debug(`[${requestId}] Reactivating schedule with ID: ${scheduleId}`)
const session = await getSession()
if (!session?.user?.id) {
@@ -116,6 +115,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
description: `Reactivated schedule for workflow ${schedule.workflowId}`,
metadata: { cronExpression: schedule.cronExpression, timezone: schedule.timezone },
request,
})

View File

@@ -51,7 +51,6 @@ export async function GET(request: NextRequest) {
lastQueuedAt: workflowSchedule.lastQueuedAt,
})
logger.debug(`[${requestId}] Successfully queried schedules: ${dueSchedules.length} found`)
logger.info(`[${requestId}] Processing ${dueSchedules.length} due scheduled workflows`)
const jobQueue = await getJobQueue()

View File

@@ -24,8 +24,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
try {
const session = await getSession()
logger.debug(`[${requestId}] Fetching template: ${id}`)
const result = await db
.select({
template: templates,
@@ -74,8 +72,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
views: sql`${templates.views} + 1`,
})
.where(eq(templates.id, id))
logger.debug(`[${requestId}] Incremented view count for template: ${id}`)
} catch (viewError) {
logger.warn(`[${requestId}] Failed to increment view count for template: ${id}`, viewError)
}

View File

@@ -58,8 +58,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
logger.debug(`[${requestId}] Adding star for template: ${id}, user: ${session.user.id}`)
// Verify the template exists
const templateExists = await db
.select({ id: templates.id })
@@ -133,8 +131,6 @@ export async function DELETE(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
logger.debug(`[${requestId}] Removing star for template: ${id}, user: ${session.user.id}`)
// Check if the star exists
const existingStar = await db
.select({ id: templateStars.id })

View File

@@ -68,8 +68,6 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const params = QueryParamsSchema.parse(Object.fromEntries(searchParams.entries()))
logger.debug(`[${requestId}] Fetching templates with params:`, params)
// Check if user is a super user
const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id)
const isSuperUser = effectiveSuperUser
@@ -187,11 +185,6 @@ export async function POST(request: NextRequest) {
const body = await request.json()
const data = CreateTemplateSchema.parse(body)
logger.debug(`[${requestId}] Creating template:`, {
name: data.name,
workflowId: data.workflowId,
})
// Verify the workflow exists and belongs to the user
const workflowExists = await db
.select({ id: workflow.id })

View File

@@ -283,3 +283,165 @@ export async function POST(request: NextRequest) {
)
}
}
/**
* Update a blog post
*/
export async function PUT(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, blogPostId, title, content, cloudId: providedCloudId } = body
if (!domain || !accessToken || !blogPostId) {
return NextResponse.json(
{ error: 'Domain, access token, and blog post ID are required' },
{ status: 400 }
)
}
const blogPostIdValidation = validateAlphanumericId(blogPostId, 'blogPostId', 255)
if (!blogPostIdValidation.isValid) {
return NextResponse.json({ error: blogPostIdValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
// Fetch current blog post to get version number
const currentUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/blogposts/${blogPostId}?body-format=storage`
const currentResponse = await fetch(currentUrl, {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!currentResponse.ok) {
throw new Error(`Failed to fetch current blog post: ${currentResponse.status}`)
}
const currentPost = await currentResponse.json()
if (!currentPost.version?.number) {
return NextResponse.json(
{ error: 'Unable to determine current blog post version' },
{ status: 422 }
)
}
const currentVersion = currentPost.version.number
const updateBody: Record<string, unknown> = {
id: blogPostId,
version: { number: currentVersion + 1 },
status: 'current',
title: title || currentPost.title,
body: {
representation: 'storage',
value: content || currentPost.body?.storage?.value || '',
},
}
const response = await fetch(currentUrl, {
method: 'PUT',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify(updateBody),
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: JSON.stringify(errorData, null, 2),
})
const errorMessage = errorData?.message || `Failed to update blog post (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
logger.error('Error updating blog post:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
}
/**
* Delete a blog post
*/
export async function DELETE(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, blogPostId, cloudId: providedCloudId } = body
if (!domain || !accessToken || !blogPostId) {
return NextResponse.json(
{ error: 'Domain, access token, and blog post ID are required' },
{ status: 400 }
)
}
const blogPostIdValidation = validateAlphanumericId(blogPostId, 'blogPostId', 255)
if (!blogPostIdValidation.isValid) {
return NextResponse.json({ error: blogPostIdValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/blogposts/${blogPostId}`
const response = await fetch(url, {
method: 'DELETE',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: JSON.stringify(errorData, null, 2),
})
const errorMessage = errorData?.message || `Failed to delete blog post (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
return NextResponse.json({ blogPostId, deleted: true })
} catch (error) {
logger.error('Error deleting blog post:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,115 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import {
validateAlphanumericId,
validateJiraCloudId,
validatePaginationCursor,
} from '@/lib/core/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
const logger = createLogger('ConfluencePageDescendantsAPI')
export const dynamic = 'force-dynamic'
/**
* Get all descendants of a Confluence page recursively.
* Uses GET /wiki/api/v2/pages/{id}/descendants
*/
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, pageId, cloudId: providedCloudId, limit = 50, cursor } = body
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!pageId) {
return NextResponse.json({ error: 'Page ID is required' }, { status: 400 })
}
const pageIdValidation = validateAlphanumericId(pageId, 'pageId', 255)
if (!pageIdValidation.isValid) {
return NextResponse.json({ error: pageIdValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const queryParams = new URLSearchParams()
queryParams.append('limit', String(Math.min(limit, 250)))
if (cursor) {
const cursorValidation = validatePaginationCursor(cursor, 'cursor')
if (!cursorValidation.isValid) {
return NextResponse.json({ error: cursorValidation.error }, { status: 400 })
}
queryParams.append('cursor', cursor)
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/descendants?${queryParams.toString()}`
logger.info(`Fetching descendants for page ${pageId}`)
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: JSON.stringify(errorData, null, 2),
})
const errorMessage =
errorData?.message || `Failed to get page descendants (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const data = await response.json()
const descendants = (data.results || []).map((page: any) => ({
id: page.id,
title: page.title,
type: page.type ?? null,
status: page.status ?? null,
spaceId: page.spaceId ?? null,
parentId: page.parentId ?? null,
childPosition: page.childPosition ?? null,
depth: page.depth ?? null,
}))
return NextResponse.json({
descendants,
pageId,
nextCursor: data._links?.next
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
: null,
})
} catch (error) {
logger.error('Error getting page descendants:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -1,8 +1,13 @@
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 { getConfluenceCloudId } from '@/tools/confluence/utils'
import {
validateAlphanumericId,
validateJiraCloudId,
validateNumericId,
validatePaginationCursor,
} from '@/lib/core/security/input-validation'
import { cleanHtmlContent, getConfluenceCloudId } from '@/tools/confluence/utils'
const logger = createLogger('ConfluencePageVersionsAPI')
@@ -55,42 +60,85 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
// If versionNumber is provided, get specific version
// If versionNumber is provided, get specific version with page content
if (versionNumber !== undefined && versionNumber !== null) {
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/versions/${versionNumber}`
const versionValidation = validateNumericId(versionNumber, 'versionNumber', { min: 1 })
if (!versionValidation.isValid) {
return NextResponse.json({ error: versionValidation.error }, { status: 400 })
}
const safeVersion = versionValidation.sanitized
const versionUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/versions/${safeVersion}`
const pageUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}?version=${safeVersion}&body-format=storage`
logger.info(`Fetching version ${versionNumber} for page ${pageId}`)
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
const [versionResponse, pageResponse] = await Promise.all([
fetch(versionUrl, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
}),
fetch(pageUrl, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
}),
])
if (!response.ok) {
const errorData = await response.json().catch(() => null)
if (!versionResponse.ok) {
const errorData = await versionResponse.json().catch(() => null)
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
status: versionResponse.status,
statusText: versionResponse.statusText,
error: JSON.stringify(errorData, null, 2),
})
const errorMessage = errorData?.message || `Failed to get page version (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
const errorMessage =
errorData?.message || `Failed to get page version (${versionResponse.status})`
return NextResponse.json({ error: errorMessage }, { status: versionResponse.status })
}
const data = await response.json()
const versionData = await versionResponse.json()
let title: string | null = null
let content: string | null = null
let body: Record<string, unknown> | null = null
if (pageResponse.ok) {
const pageData = await pageResponse.json()
title = pageData.title ?? null
body = pageData.body ?? null
const rawContent =
pageData.body?.storage?.value ||
pageData.body?.view?.value ||
pageData.body?.atlas_doc_format?.value ||
''
if (rawContent) {
content = cleanHtmlContent(rawContent)
}
} else {
logger.warn(
`Could not fetch page content for version ${versionNumber}: ${pageResponse.status}`
)
}
return NextResponse.json({
version: {
number: data.number,
message: data.message ?? null,
minorEdit: data.minorEdit ?? false,
authorId: data.authorId ?? null,
createdAt: data.createdAt ?? null,
number: versionData.number,
message: versionData.message ?? null,
minorEdit: versionData.minorEdit ?? false,
authorId: versionData.authorId ?? null,
createdAt: versionData.createdAt ?? null,
},
pageId,
title,
content,
body,
})
}
// List all versions
@@ -98,6 +146,10 @@ export async function POST(request: NextRequest) {
queryParams.append('limit', String(Math.min(limit, 250)))
if (cursor) {
const cursorValidation = validatePaginationCursor(cursor, 'cursor')
if (!cursorValidation.isValid) {
return NextResponse.json({ error: cursorValidation.error }, { status: 400 })
}
queryParams.append('cursor', cursor)
}

View File

@@ -185,7 +185,7 @@ export async function PUT(request: NextRequest) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const currentPageUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}`
const currentPageUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}?body-format=storage`
const currentPageResponse = await fetch(currentPageUrl, {
headers: {
Accept: 'application/json',

View File

@@ -0,0 +1,114 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import {
validateAlphanumericId,
validateJiraCloudId,
validatePaginationCursor,
} from '@/lib/core/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
const logger = createLogger('ConfluenceSpacePermissionsAPI')
export const dynamic = 'force-dynamic'
/**
* List permissions for a Confluence space.
* Uses GET /wiki/api/v2/spaces/{id}/permissions
*/
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, spaceId, cloudId: providedCloudId, limit = 50, cursor } = body
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!spaceId) {
return NextResponse.json({ error: 'Space ID is required' }, { status: 400 })
}
const spaceIdValidation = validateAlphanumericId(spaceId, 'spaceId', 255)
if (!spaceIdValidation.isValid) {
return NextResponse.json({ error: spaceIdValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const queryParams = new URLSearchParams()
queryParams.append('limit', String(Math.min(limit, 250)))
if (cursor) {
const cursorValidation = validatePaginationCursor(cursor, 'cursor')
if (!cursorValidation.isValid) {
return NextResponse.json({ error: cursorValidation.error }, { status: 400 })
}
queryParams.append('cursor', cursor)
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/spaces/${spaceId}/permissions?${queryParams.toString()}`
logger.info(`Fetching permissions for space ${spaceId}`)
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: JSON.stringify(errorData, null, 2),
})
const errorMessage =
errorData?.message || `Failed to list space permissions (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const data = await response.json()
const permissions = (data.results || []).map((perm: any) => ({
id: perm.id,
principalType: perm.principal?.type ?? null,
principalId: perm.principal?.id ?? null,
operationKey: perm.operation?.key ?? null,
operationTargetType: perm.operation?.targetType ?? null,
anonymousAccess: perm.anonymousAccess ?? false,
unlicensedAccess: perm.unlicensedAccess ?? false,
}))
return NextResponse.json({
permissions,
spaceId,
nextCursor: data._links?.next
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
: null,
})
} catch (error) {
logger.error('Error listing space permissions:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,209 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import {
validateAlphanumericId,
validateJiraCloudId,
validatePaginationCursor,
} from '@/lib/core/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
const logger = createLogger('ConfluenceSpacePropertiesAPI')
export const dynamic = 'force-dynamic'
/**
* List, create, or delete space properties.
* Uses GET/POST /wiki/api/v2/spaces/{id}/properties
* and DELETE /wiki/api/v2/spaces/{id}/properties/{propertyId}
*/
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,
spaceId,
cloudId: providedCloudId,
action,
key,
value,
propertyId,
limit = 50,
cursor,
} = body
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!spaceId) {
return NextResponse.json({ error: 'Space ID is required' }, { status: 400 })
}
const spaceIdValidation = validateAlphanumericId(spaceId, 'spaceId', 255)
if (!spaceIdValidation.isValid) {
return NextResponse.json({ error: spaceIdValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const baseUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/spaces/${spaceId}/properties`
// Validate required params for specific actions
if (action === 'delete' && !propertyId) {
return NextResponse.json(
{ error: 'Property ID is required for delete action' },
{ status: 400 }
)
}
if (action === 'create' && !key) {
return NextResponse.json(
{ error: 'Property key is required for create action' },
{ status: 400 }
)
}
// Delete a property
if (action === 'delete' && propertyId) {
const propertyIdValidation = validateAlphanumericId(propertyId, 'propertyId', 255)
if (!propertyIdValidation.isValid) {
return NextResponse.json({ error: propertyIdValidation.error }, { status: 400 })
}
const url = `${baseUrl}/${encodeURIComponent(propertyId)}`
logger.info(`Deleting space property ${propertyId} from space ${spaceId}`)
const response = await fetch(url, {
method: 'DELETE',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: JSON.stringify(errorData, null, 2),
})
const errorMessage =
errorData?.message || `Failed to delete space property (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
return NextResponse.json({ spaceId, propertyId, deleted: true })
}
// Create a property
if (action === 'create' && key) {
logger.info(`Creating space property '${key}' on space ${spaceId}`)
const response = await fetch(baseUrl, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ key, value: value ?? {} }),
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: JSON.stringify(errorData, null, 2),
})
const errorMessage =
errorData?.message || `Failed to create space property (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const data = await response.json()
return NextResponse.json({
propertyId: data.id,
key: data.key,
value: data.value ?? null,
spaceId,
})
}
// List properties
const queryParams = new URLSearchParams()
queryParams.append('limit', String(Math.min(limit, 250)))
if (cursor) {
const cursorValidation = validatePaginationCursor(cursor, 'cursor')
if (!cursorValidation.isValid) {
return NextResponse.json({ error: cursorValidation.error }, { status: 400 })
}
queryParams.append('cursor', cursor)
}
const url = `${baseUrl}?${queryParams.toString()}`
logger.info(`Fetching properties for space ${spaceId}`)
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: JSON.stringify(errorData, null, 2),
})
const errorMessage =
errorData?.message || `Failed to list space properties (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const data = await response.json()
const properties = (data.results || []).map((prop: any) => ({
id: prop.id,
key: prop.key,
value: prop.value ?? null,
}))
return NextResponse.json({
properties,
spaceId,
nextCursor: data._links?.next
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
: null,
})
} catch (error) {
logger.error('Error with space properties:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -78,3 +78,258 @@ export async function GET(request: NextRequest) {
)
}
}
/**
* Create a new Confluence space.
* Uses POST /wiki/api/v2/spaces
*/
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, name, key, description, cloudId: providedCloudId } = body
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!name) {
return NextResponse.json({ error: 'Space name is required' }, { status: 400 })
}
if (!key) {
return NextResponse.json({ error: 'Space key is required' }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/spaces`
const createBody: Record<string, unknown> = { name, key }
if (description) {
createBody.description = { value: description, representation: 'plain' }
}
logger.info(`Creating space with key ${key}`)
const response = await fetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify(createBody),
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: JSON.stringify(errorData, null, 2),
})
const errorMessage = errorData?.message || `Failed to create space (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
logger.error('Error creating Confluence space:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
}
/**
* Update a Confluence space.
* Uses PUT /wiki/api/v2/spaces/{id}
*/
export async function PUT(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, spaceId, name, description, cloudId: providedCloudId } = body
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!spaceId) {
return NextResponse.json({ error: 'Space ID is required' }, { status: 400 })
}
const spaceIdValidation = validateAlphanumericId(spaceId, 'spaceId', 255)
if (!spaceIdValidation.isValid) {
return NextResponse.json({ error: spaceIdValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/spaces/${spaceId}`
if (!name && description === undefined) {
return NextResponse.json(
{ error: 'At least one of name or description is required for update' },
{ status: 400 }
)
}
const updateBody: Record<string, unknown> = {}
if (name) {
updateBody.name = name
} else {
const currentResponse = await fetch(url, {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!currentResponse.ok) {
return NextResponse.json(
{ error: `Failed to fetch current space: ${currentResponse.status}` },
{ status: currentResponse.status }
)
}
const currentSpace = await currentResponse.json()
updateBody.name = currentSpace.name
}
if (description !== undefined) {
updateBody.description = { value: description, representation: 'plain' }
}
logger.info(`Updating space ${spaceId}`)
const response = await fetch(url, {
method: 'PUT',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify(updateBody),
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: JSON.stringify(errorData, null, 2),
})
const errorMessage = errorData?.message || `Failed to update space (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
logger.error('Error updating Confluence space:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
}
/**
* Delete a Confluence space.
* Uses DELETE /wiki/api/v2/spaces/{id}
*/
export async function DELETE(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, spaceId, cloudId: providedCloudId } = body
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!spaceId) {
return NextResponse.json({ error: 'Space ID is required' }, { status: 400 })
}
const spaceIdValidation = validateAlphanumericId(spaceId, 'spaceId', 255)
if (!spaceIdValidation.isValid) {
return NextResponse.json({ error: spaceIdValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/spaces/${spaceId}`
logger.info(`Deleting space ${spaceId}`)
const response = await fetch(url, {
method: 'DELETE',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: JSON.stringify(errorData, null, 2),
})
const errorMessage = errorData?.message || `Failed to delete space (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
return NextResponse.json({ spaceId, deleted: true })
} catch (error) {
logger.error('Error deleting Confluence space:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,278 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import {
validateAlphanumericId,
validateJiraCloudId,
validatePaginationCursor,
validatePathSegment,
} from '@/lib/core/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
const logger = createLogger('ConfluenceTasksAPI')
export const dynamic = 'force-dynamic'
/**
* List, get, or update Confluence inline tasks.
* Uses GET /wiki/api/v2/tasks, GET /wiki/api/v2/tasks/{id}, PUT /wiki/api/v2/tasks/{id}
*/
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,
action,
taskId,
status: taskStatus,
pageId,
spaceId,
assignedTo,
limit = 50,
cursor,
} = body
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
// Update a task
if (action === 'update' && taskId) {
const taskIdValidation = validateAlphanumericId(taskId, 'taskId', 255)
if (!taskIdValidation.isValid) {
return NextResponse.json({ error: taskIdValidation.error }, { status: 400 })
}
// First fetch the current task to get required fields
const getUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/tasks/${taskId}`
const getResponse = await fetch(getUrl, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!getResponse.ok) {
const errorData = await getResponse.json().catch(() => null)
const errorMessage = errorData?.message || `Failed to fetch task (${getResponse.status})`
return NextResponse.json({ error: errorMessage }, { status: getResponse.status })
}
const currentTask = await getResponse.json()
const updateBody: Record<string, unknown> = {
id: taskId,
status: taskStatus || currentTask.status,
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/tasks/${taskId}`
logger.info(`Updating task ${taskId}`)
const response = await fetch(url, {
method: 'PUT',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify(updateBody),
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: JSON.stringify(errorData, null, 2),
})
const errorMessage = errorData?.message || `Failed to update task (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const data = await response.json()
return NextResponse.json({
task: {
id: data.id,
localId: data.localId ?? null,
spaceId: data.spaceId ?? null,
pageId: data.pageId ?? null,
blogPostId: data.blogPostId ?? null,
status: data.status,
body: data.body?.storage?.value ?? null,
createdBy: data.createdBy ?? null,
assignedTo: data.assignedTo ?? null,
completedBy: data.completedBy ?? null,
createdAt: data.createdAt ?? null,
updatedAt: data.updatedAt ?? null,
dueAt: data.dueAt ?? null,
completedAt: data.completedAt ?? null,
},
})
}
// Get a specific task
if (taskId) {
const taskIdValidation = validateAlphanumericId(taskId, 'taskId', 255)
if (!taskIdValidation.isValid) {
return NextResponse.json({ error: taskIdValidation.error }, { status: 400 })
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/tasks/${taskId}`
logger.info(`Fetching task ${taskId}`)
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: JSON.stringify(errorData, null, 2),
})
const errorMessage = errorData?.message || `Failed to get task (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const data = await response.json()
return NextResponse.json({
task: {
id: data.id,
localId: data.localId ?? null,
spaceId: data.spaceId ?? null,
pageId: data.pageId ?? null,
blogPostId: data.blogPostId ?? null,
status: data.status,
body: data.body?.storage?.value ?? null,
createdBy: data.createdBy ?? null,
assignedTo: data.assignedTo ?? null,
completedBy: data.completedBy ?? null,
createdAt: data.createdAt ?? null,
updatedAt: data.updatedAt ?? null,
dueAt: data.dueAt ?? null,
completedAt: data.completedAt ?? null,
},
})
}
// List tasks
const queryParams = new URLSearchParams()
queryParams.append('limit', String(Math.min(limit, 250)))
if (cursor) {
const cursorValidation = validatePaginationCursor(cursor, 'cursor')
if (!cursorValidation.isValid) {
return NextResponse.json({ error: cursorValidation.error }, { status: 400 })
}
queryParams.append('cursor', cursor)
}
if (taskStatus) queryParams.append('status', taskStatus)
if (pageId) {
const pageIdValidation = validateAlphanumericId(pageId, 'pageId', 255)
if (!pageIdValidation.isValid) {
return NextResponse.json({ error: pageIdValidation.error }, { status: 400 })
}
queryParams.append('page-id', pageId)
}
if (spaceId) {
const spaceIdValidation = validateAlphanumericId(spaceId, 'spaceId', 255)
if (!spaceIdValidation.isValid) {
return NextResponse.json({ error: spaceIdValidation.error }, { status: 400 })
}
queryParams.append('space-id', spaceId)
}
if (assignedTo) {
// Atlassian account IDs: 5d5bd05c3aee0123abc or 557058:6b9c9931-4693-49c1-8b3a-931f1af98134
const assignedToValidation = validatePathSegment(assignedTo, {
paramName: 'assignedTo',
maxLength: 128,
customPattern: /^[a-zA-Z0-9_|:-]+$/,
})
if (!assignedToValidation.isValid) {
return NextResponse.json({ error: assignedToValidation.error }, { status: 400 })
}
queryParams.append('assigned-to', assignedTo)
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/tasks?${queryParams.toString()}`
logger.info('Fetching tasks')
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: JSON.stringify(errorData, null, 2),
})
const errorMessage = errorData?.message || `Failed to list tasks (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const data = await response.json()
const tasks = (data.results || []).map((task: any) => ({
id: task.id,
localId: task.localId ?? null,
spaceId: task.spaceId ?? null,
pageId: task.pageId ?? null,
blogPostId: task.blogPostId ?? null,
status: task.status,
body: task.body?.storage?.value ?? null,
createdBy: task.createdBy ?? null,
assignedTo: task.assignedTo ?? null,
completedBy: task.completedBy ?? null,
createdAt: task.createdAt ?? null,
updatedAt: task.updatedAt ?? null,
dueAt: task.dueAt ?? null,
completedAt: task.completedAt ?? null,
}))
return NextResponse.json({
tasks,
nextCursor: data._links?.next
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
: null,
})
} catch (error) {
logger.error('Error with tasks:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,85 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateJiraCloudId, validatePathSegment } from '@/lib/core/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
const logger = createLogger('ConfluenceUserAPI')
export const dynamic = 'force-dynamic'
/**
* Get a Confluence user by account ID.
* Uses GET /wiki/rest/api/user?accountId={accountId}
*/
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, accountId, cloudId: providedCloudId } = body
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!accountId) {
return NextResponse.json({ error: 'Account ID is required' }, { status: 400 })
}
// Atlassian account IDs: 5d5bd05c3aee0123abc or 557058:6b9c9931-4693-49c1-8b3a-931f1af98134
const accountIdValidation = validatePathSegment(accountId, {
paramName: 'accountId',
maxLength: 128,
customPattern: /^[a-zA-Z0-9_|:-]+$/,
})
if (!accountIdValidation.isValid) {
return NextResponse.json({ error: accountIdValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/rest/api/user?accountId=${encodeURIComponent(accountId)}`
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: JSON.stringify(errorData, null, 2),
})
const errorMessage =
errorData?.message || `Failed to get Confluence user (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
logger.error('Error getting Confluence user:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -18,7 +18,6 @@ export async function DELETE(
const { id } = await params
try {
logger.debug(`[${requestId}] Deleting API key: ${id}`)
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

View File

@@ -0,0 +1,44 @@
/**
* GET /api/v1/admin/audit-logs/[id]
*
* Get a single audit log entry by ID.
*
* Response: AdminSingleResponse<AdminAuditLog>
*/
import { db } from '@sim/db'
import { auditLog } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import { toAdminAuditLog } from '@/app/api/v1/admin/types'
const logger = createLogger('AdminAuditLogDetailAPI')
interface RouteParams {
id: string
}
export const GET = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id } = await context.params
try {
const [log] = await db.select().from(auditLog).where(eq(auditLog.id, id)).limit(1)
if (!log) {
return notFoundResponse('AuditLog')
}
logger.info(`Admin API: Retrieved audit log ${id}`)
return singleResponse(toAdminAuditLog(log))
} catch (error) {
logger.error('Admin API: Failed to get audit log', { error, id })
return internalErrorResponse('Failed to get audit log')
}
})

View File

@@ -0,0 +1,96 @@
/**
* GET /api/v1/admin/audit-logs
*
* List all audit logs with pagination and filtering.
*
* Query Parameters:
* - limit: number (default: 50, max: 250)
* - offset: number (default: 0)
* - action: string (optional) - Filter by action (e.g., "workflow.created")
* - resourceType: string (optional) - Filter by resource type (e.g., "workflow")
* - resourceId: string (optional) - Filter by resource ID
* - workspaceId: string (optional) - Filter by workspace ID
* - actorId: string (optional) - Filter by actor user ID
* - actorEmail: string (optional) - Filter by actor email
* - startDate: string (optional) - ISO 8601 date, filter createdAt >= startDate
* - endDate: string (optional) - ISO 8601 date, filter createdAt <= endDate
*
* Response: AdminListResponse<AdminAuditLog>
*/
import { db } from '@sim/db'
import { auditLog } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, count, desc, eq, gte, lte, type SQL } from 'drizzle-orm'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
listResponse,
} from '@/app/api/v1/admin/responses'
import {
type AdminAuditLog,
createPaginationMeta,
parsePaginationParams,
toAdminAuditLog,
} from '@/app/api/v1/admin/types'
const logger = createLogger('AdminAuditLogsAPI')
export const GET = withAdminAuth(async (request) => {
const url = new URL(request.url)
const { limit, offset } = parsePaginationParams(url)
const actionFilter = url.searchParams.get('action')
const resourceTypeFilter = url.searchParams.get('resourceType')
const resourceIdFilter = url.searchParams.get('resourceId')
const workspaceIdFilter = url.searchParams.get('workspaceId')
const actorIdFilter = url.searchParams.get('actorId')
const actorEmailFilter = url.searchParams.get('actorEmail')
const startDateFilter = url.searchParams.get('startDate')
const endDateFilter = url.searchParams.get('endDate')
if (startDateFilter && Number.isNaN(Date.parse(startDateFilter))) {
return badRequestResponse('Invalid startDate format. Use ISO 8601.')
}
if (endDateFilter && Number.isNaN(Date.parse(endDateFilter))) {
return badRequestResponse('Invalid endDate format. Use ISO 8601.')
}
try {
const conditions: SQL<unknown>[] = []
if (actionFilter) conditions.push(eq(auditLog.action, actionFilter))
if (resourceTypeFilter) conditions.push(eq(auditLog.resourceType, resourceTypeFilter))
if (resourceIdFilter) conditions.push(eq(auditLog.resourceId, resourceIdFilter))
if (workspaceIdFilter) conditions.push(eq(auditLog.workspaceId, workspaceIdFilter))
if (actorIdFilter) conditions.push(eq(auditLog.actorId, actorIdFilter))
if (actorEmailFilter) conditions.push(eq(auditLog.actorEmail, actorEmailFilter))
if (startDateFilter) conditions.push(gte(auditLog.createdAt, new Date(startDateFilter)))
if (endDateFilter) conditions.push(lte(auditLog.createdAt, new Date(endDateFilter)))
const whereClause = conditions.length > 0 ? and(...conditions) : undefined
const [countResult, logs] = await Promise.all([
db.select({ total: count() }).from(auditLog).where(whereClause),
db
.select()
.from(auditLog)
.where(whereClause)
.orderBy(desc(auditLog.createdAt))
.limit(limit)
.offset(offset),
])
const total = countResult[0].total
const data: AdminAuditLog[] = logs.map(toAdminAuditLog)
const pagination = createPaginationMeta(total, limit, offset)
logger.info(`Admin API: Listed ${data.length} audit logs (total: ${total})`)
return listResponse(data, pagination)
} catch (error) {
logger.error('Admin API: Failed to list audit logs', { error })
return internalErrorResponse('Failed to list audit logs')
}
})

View File

@@ -6,6 +6,7 @@
*/
import type {
auditLog,
member,
organization,
referralCampaigns,
@@ -694,3 +695,45 @@ export function toAdminReferralCampaign(
updatedAt: dbCampaign.updatedAt.toISOString(),
}
}
// =============================================================================
// Audit Log Types
// =============================================================================
export type DbAuditLog = InferSelectModel<typeof auditLog>
export interface AdminAuditLog {
id: string
workspaceId: string | null
actorId: string | null
actorName: string | null
actorEmail: string | null
action: string
resourceType: string
resourceId: string | null
resourceName: string | null
description: string | null
metadata: unknown
ipAddress: string | null
userAgent: string | null
createdAt: string
}
export function toAdminAuditLog(dbLog: DbAuditLog): AdminAuditLog {
return {
id: dbLog.id,
workspaceId: dbLog.workspaceId,
actorId: dbLog.actorId,
actorName: dbLog.actorName,
actorEmail: dbLog.actorEmail,
action: dbLog.action,
resourceType: dbLog.resourceType,
resourceId: dbLog.resourceId,
resourceName: dbLog.resourceName,
description: dbLog.description,
metadata: dbLog.metadata,
ipAddress: dbLog.ipAddress,
userAgent: dbLog.userAgent,
createdAt: dbLog.createdAt.toISOString(),
}
}

View File

@@ -0,0 +1,78 @@
/**
* GET /api/v1/audit-logs/[id]
*
* Get a single audit log entry by ID, scoped to the authenticated user's organization.
* Requires enterprise subscription and org admin/owner role.
*
* Scope includes logs from current org members AND logs within org workspaces
* (including those from departed members or system actions with null actorId).
*
* Response: { data: AuditLogEntry, limits: UserLimits }
*/
import { db } from '@sim/db'
import { auditLog, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth'
import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format'
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware'
const logger = createLogger('V1AuditLogDetailAPI')
export const revalidate = 0
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
const rateLimit = await checkRateLimit(request, 'audit-logs')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const { id } = await params
const authResult = await validateEnterpriseAuditAccess(userId)
if (!authResult.success) {
return authResult.response
}
const { orgMemberIds } = authResult.context
const orgWorkspaceIds = db
.select({ id: workspace.id })
.from(workspace)
.where(inArray(workspace.ownerId, orgMemberIds))
const [log] = await db
.select()
.from(auditLog)
.where(
and(
eq(auditLog.id, id),
or(
inArray(auditLog.actorId, orgMemberIds),
inArray(auditLog.workspaceId, orgWorkspaceIds)
)
)
)
.limit(1)
if (!log) {
return NextResponse.json({ error: 'Audit log not found' }, { status: 404 })
}
const limits = await getUserLimits(userId)
const response = createApiResponse({ data: formatAuditLogEntry(log) }, limits, rateLimit)
return NextResponse.json(response.body, { headers: response.headers })
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error'
logger.error(`[${requestId}] Audit log detail fetch error`, { error: message })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,103 @@
/**
* Enterprise audit log authorization.
*
* Validates that the authenticated user is an admin/owner of an enterprise organization
* and returns the organization context needed for scoped queries.
*/
import { db } from '@sim/db'
import { member, subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
const logger = createLogger('V1AuditLogsAuth')
export interface EnterpriseAuditContext {
organizationId: string
orgMemberIds: string[]
}
type AuthResult =
| { success: true; context: EnterpriseAuditContext }
| { success: false; response: NextResponse }
/**
* Validates enterprise audit log access for the given user.
*
* Checks:
* 1. User belongs to an organization
* 2. User has admin or owner role
* 3. Organization has an active enterprise subscription
*
* Returns the organization ID and all member user IDs on success,
* or an error response on failure.
*/
export async function validateEnterpriseAuditAccess(userId: string): Promise<AuthResult> {
const [membership] = await db
.select({ organizationId: member.organizationId, role: member.role })
.from(member)
.where(eq(member.userId, userId))
.limit(1)
if (!membership) {
return {
success: false,
response: NextResponse.json({ error: 'Not a member of any organization' }, { status: 403 }),
}
}
if (membership.role !== 'admin' && membership.role !== 'owner') {
return {
success: false,
response: NextResponse.json(
{ error: 'Organization admin or owner role required' },
{ status: 403 }
),
}
}
const [orgSub, orgMembers] = await Promise.all([
db
.select({ id: subscription.id })
.from(subscription)
.where(
and(
eq(subscription.referenceId, membership.organizationId),
eq(subscription.plan, 'enterprise'),
eq(subscription.status, 'active')
)
)
.limit(1),
db
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, membership.organizationId)),
])
if (orgSub.length === 0) {
return {
success: false,
response: NextResponse.json(
{ error: 'Active enterprise subscription required' },
{ status: 403 }
),
}
}
const orgMemberIds = orgMembers.map((m) => m.userId)
logger.info('Enterprise audit access validated', {
userId,
organizationId: membership.organizationId,
memberCount: orgMemberIds.length,
})
return {
success: true,
context: {
organizationId: membership.organizationId,
orgMemberIds,
},
}
}

View File

@@ -0,0 +1,43 @@
/**
* Enterprise audit log response formatting.
*
* Defines the shape returned by the enterprise audit log API.
* Excludes `ipAddress` and `userAgent` for privacy.
*/
import type { auditLog } from '@sim/db/schema'
import type { InferSelectModel } from 'drizzle-orm'
type DbAuditLog = InferSelectModel<typeof auditLog>
export interface EnterpriseAuditLogEntry {
id: string
workspaceId: string | null
actorId: string | null
actorName: string | null
actorEmail: string | null
action: string
resourceType: string
resourceId: string | null
resourceName: string | null
description: string | null
metadata: unknown
createdAt: string
}
export function formatAuditLogEntry(log: DbAuditLog): EnterpriseAuditLogEntry {
return {
id: log.id,
workspaceId: log.workspaceId,
actorId: log.actorId,
actorName: log.actorName,
actorEmail: log.actorEmail,
action: log.action,
resourceType: log.resourceType,
resourceId: log.resourceId,
resourceName: log.resourceName,
description: log.description,
metadata: log.metadata,
createdAt: log.createdAt.toISOString(),
}
}

View File

@@ -0,0 +1,191 @@
/**
* GET /api/v1/audit-logs
*
* List audit logs scoped to the authenticated user's organization.
* Requires enterprise subscription and org admin/owner role.
*
* Query Parameters:
* - action: string (optional) - Filter by action (e.g., "workflow.created")
* - resourceType: string (optional) - Filter by resource type (e.g., "workflow")
* - resourceId: string (optional) - Filter by resource ID
* - workspaceId: string (optional) - Filter by workspace ID
* - actorId: string (optional) - Filter by actor user ID (must be an org member)
* - startDate: string (optional) - ISO 8601 date, filter createdAt >= startDate
* - endDate: string (optional) - ISO 8601 date, filter createdAt <= endDate
* - includeDeparted: boolean (optional, default: false) - Include logs from departed members
* - limit: number (optional, default: 50, max: 100)
* - cursor: string (optional) - Opaque cursor for pagination
*
* Response: { data: AuditLogEntry[], nextCursor?: string, limits: UserLimits }
*/
import { db } from '@sim/db'
import { auditLog, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, desc, eq, gte, inArray, lt, lte, or, type SQL } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth'
import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format'
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware'
const logger = createLogger('V1AuditLogsAPI')
export const dynamic = 'force-dynamic'
export const revalidate = 0
const isoDateString = z.string().refine((val) => !Number.isNaN(Date.parse(val)), {
message: 'Invalid date format. Use ISO 8601.',
})
const QueryParamsSchema = z.object({
action: z.string().optional(),
resourceType: z.string().optional(),
resourceId: z.string().optional(),
workspaceId: z.string().optional(),
actorId: z.string().optional(),
startDate: isoDateString.optional(),
endDate: isoDateString.optional(),
includeDeparted: z
.enum(['true', 'false'])
.transform((val) => val === 'true')
.optional()
.default('false'),
limit: z.coerce.number().min(1).max(100).optional().default(50),
cursor: z.string().optional(),
})
interface CursorData {
createdAt: string
id: string
}
function encodeCursor(data: CursorData): string {
return Buffer.from(JSON.stringify(data)).toString('base64')
}
function decodeCursor(cursor: string): CursorData | null {
try {
return JSON.parse(Buffer.from(cursor, 'base64').toString())
} catch {
return null
}
}
export async function GET(request: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
const rateLimit = await checkRateLimit(request, 'audit-logs')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const authResult = await validateEnterpriseAuditAccess(userId)
if (!authResult.success) {
return authResult.response
}
const { orgMemberIds } = authResult.context
const { searchParams } = new URL(request.url)
const rawParams = Object.fromEntries(searchParams.entries())
const validationResult = QueryParamsSchema.safeParse(rawParams)
if (!validationResult.success) {
return NextResponse.json(
{ error: 'Invalid parameters', details: validationResult.error.errors },
{ status: 400 }
)
}
const params = validationResult.data
if (params.actorId && !orgMemberIds.includes(params.actorId)) {
return NextResponse.json(
{ error: 'actorId is not a member of your organization' },
{ status: 400 }
)
}
let scopeCondition: SQL<unknown>
if (params.includeDeparted) {
const orgWorkspaces = await db
.select({ id: workspace.id })
.from(workspace)
.where(inArray(workspace.ownerId, orgMemberIds))
const orgWorkspaceIds = orgWorkspaces.map((w) => w.id)
if (orgWorkspaceIds.length > 0) {
scopeCondition = or(
inArray(auditLog.actorId, orgMemberIds),
inArray(auditLog.workspaceId, orgWorkspaceIds)
)!
} else {
scopeCondition = inArray(auditLog.actorId, orgMemberIds)
}
} else {
scopeCondition = inArray(auditLog.actorId, orgMemberIds)
}
const conditions: SQL<unknown>[] = [scopeCondition]
if (params.action) conditions.push(eq(auditLog.action, params.action))
if (params.resourceType) conditions.push(eq(auditLog.resourceType, params.resourceType))
if (params.resourceId) conditions.push(eq(auditLog.resourceId, params.resourceId))
if (params.workspaceId) conditions.push(eq(auditLog.workspaceId, params.workspaceId))
if (params.actorId) conditions.push(eq(auditLog.actorId, params.actorId))
if (params.startDate) conditions.push(gte(auditLog.createdAt, new Date(params.startDate)))
if (params.endDate) conditions.push(lte(auditLog.createdAt, new Date(params.endDate)))
if (params.cursor) {
const cursorData = decodeCursor(params.cursor)
if (cursorData?.createdAt && cursorData.id) {
const cursorDate = new Date(cursorData.createdAt)
if (!Number.isNaN(cursorDate.getTime())) {
conditions.push(
or(
lt(auditLog.createdAt, cursorDate),
and(eq(auditLog.createdAt, cursorDate), lt(auditLog.id, cursorData.id))
)!
)
}
}
}
const rows = await db
.select()
.from(auditLog)
.where(and(...conditions))
.orderBy(desc(auditLog.createdAt), desc(auditLog.id))
.limit(params.limit + 1)
const hasMore = rows.length > params.limit
const data = rows.slice(0, params.limit)
let nextCursor: string | undefined
if (hasMore && data.length > 0) {
const last = data[data.length - 1]
nextCursor = encodeCursor({
createdAt: last.createdAt.toISOString(),
id: last.id,
})
}
const formattedLogs = data.map(formatAuditLogEntry)
const limits = await getUserLimits(userId)
const response = createApiResponse({ data: formattedLogs, nextCursor }, limits, rateLimit)
return NextResponse.json(response.body, { headers: response.headers })
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error'
logger.error(`[${requestId}] Audit logs fetch error`, { error: message })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -19,7 +19,7 @@ export interface RateLimitResult {
export async function checkRateLimit(
request: NextRequest,
endpoint: 'logs' | 'logs-detail' | 'workflows' | 'workflow-detail' = 'logs'
endpoint: 'logs' | 'logs-detail' | 'workflows' | 'workflow-detail' | 'audit-logs' = 'logs'
): Promise<RateLimitResult> {
try {
const auth = await authenticateV1Request(request)

View File

@@ -103,12 +103,10 @@ async function updateUserStatsForWand(
isBYOK = false
): Promise<void> {
if (!isBillingEnabled) {
logger.debug(`[${requestId}] Billing is disabled, skipping wand usage cost update`)
return
}
if (!usage.total_tokens || usage.total_tokens <= 0) {
logger.debug(`[${requestId}] No tokens to update in user stats`)
return
}
@@ -146,13 +144,6 @@ async function updateUserStatsForWand(
})
.where(eq(userStats.userId, userId))
logger.debug(`[${requestId}] Updated user stats for wand usage`, {
userId,
tokensUsed: totalTokens,
costAdded: costToStore,
isBYOK,
})
await logModelUsage({
userId,
source: 'wand',
@@ -291,23 +282,8 @@ export async function POST(req: NextRequest) {
messages.push({ role: 'user', content: prompt })
logger.debug(
`[${requestId}] Calling ${useWandAzure ? 'Azure OpenAI' : 'OpenAI'} API for wand generation`,
{
stream,
historyLength: history.length,
endpoint: useWandAzure ? azureEndpoint : 'api.openai.com',
model: useWandAzure ? wandModelName : 'gpt-4o',
apiVersion: useWandAzure ? azureApiVersion : 'N/A',
}
)
if (stream) {
try {
logger.debug(
`[${requestId}] Starting streaming request to ${useWandAzure ? 'Azure OpenAI' : 'OpenAI'}`
)
logger.info(
`[${requestId}] About to create stream with model: ${useWandAzure ? wandModelName : 'gpt-4o'}`
)
@@ -327,8 +303,6 @@ export async function POST(req: NextRequest) {
headers.Authorization = `Bearer ${activeOpenAIKey}`
}
logger.debug(`[${requestId}] Making streaming request to: ${apiUrl}`)
const response = await fetch(apiUrl, {
method: 'POST',
headers,
@@ -429,7 +403,6 @@ export async function POST(req: NextRequest) {
try {
parsed = JSON.parse(data)
} catch (parseError) {
logger.debug(`[${requestId}] Skipped non-JSON line: ${data.substring(0, 100)}`)
continue
}

View File

@@ -21,7 +21,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
try {
const { id } = await params
logger.debug(`[${requestId}] Fetching webhook with ID: ${id}`)
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
@@ -77,7 +76,6 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
try {
const { id } = await params
logger.debug(`[${requestId}] Updating webhook with ID: ${id}`)
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
@@ -129,11 +127,6 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
logger.debug(`[${requestId}] Updating webhook properties`, {
hasActiveUpdate: isActive !== undefined,
hasFailedCountUpdate: failedCount !== undefined,
})
const updatedWebhook = await db
.update(webhook)
.set({
@@ -161,7 +154,6 @@ export async function DELETE(
try {
const { id } = await params
logger.debug(`[${requestId}] Deleting webhook with ID: ${id}`)
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {

View File

@@ -112,7 +112,6 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ webhooks: [] }, { status: 200 })
}
logger.debug(`[${requestId}] Fetching workspace-accessible webhooks for ${session.user.id}`)
const workspacePermissionRows = await db
.select({ workspaceId: permissions.entityId })
.from(permissions)

View File

@@ -35,8 +35,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
)
}
logger.debug(`[${requestId}] Checking chat deployment status for workflow: ${id}`)
// Find any active chat deployments for this workflow
const deploymentResults = await db
.select({

View File

@@ -22,6 +22,7 @@ import {
} from '@/lib/workflows/schedules'
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('WorkflowDeployAPI')
@@ -33,8 +34,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const { id } = await params
try {
logger.debug(`[${requestId}] Fetching deployment info for workflow: ${id}`)
const { error, workflow: workflowData } = await validateWorkflowPermissions(
id,
requestId,
@@ -51,6 +50,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
deployedAt: null,
apiKey: null,
needsRedeployment: false,
isPublicApi: workflowData.isPublicApi ?? false,
})
}
@@ -85,7 +85,10 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
variables: workflowRecord?.variables || {},
}
const { hasWorkflowChanged } = await import('@/lib/workflows/comparison')
needsRedeployment = hasWorkflowChanged(currentState as any, active.state as any)
needsRedeployment = hasWorkflowChanged(
currentState as WorkflowState,
active.state as WorkflowState
)
}
}
@@ -98,6 +101,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
isDeployed: workflowData.isDeployed,
deployedAt: workflowData.deployedAt,
needsRedeployment,
isPublicApi: workflowData.isPublicApi ?? false,
})
} catch (error: any) {
logger.error(`[${requestId}] Error fetching deployment info: ${id}`, error)
@@ -110,8 +114,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const { id } = await params
try {
logger.debug(`[${requestId}] Deploying workflow: ${id}`)
const {
error,
session,
@@ -269,6 +271,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
resourceId: id,
resourceName: workflowData?.name,
description: `Deployed workflow "${workflowData?.name || id}"`,
metadata: { version: deploymentVersionId },
request,
})
@@ -301,6 +304,49 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
}
}
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = generateRequestId()
const { id } = await params
try {
const { error, session } = await validateWorkflowPermissions(id, requestId, 'admin')
if (error) {
return createErrorResponse(error.message, error.status)
}
const body = await request.json()
const { isPublicApi } = body
if (typeof isPublicApi !== 'boolean') {
return createErrorResponse('Invalid request body: isPublicApi must be a boolean', 400)
}
if (isPublicApi) {
const { validatePublicApiAllowed, PublicApiNotAllowedError } = await import(
'@/ee/access-control/utils/permission-check'
)
try {
await validatePublicApiAllowed(session?.user?.id)
} catch (err) {
if (err instanceof PublicApiNotAllowedError) {
return createErrorResponse('Public API access is disabled', 403)
}
throw err
}
}
await db.update(workflow).set({ isPublicApi }).where(eq(workflow.id, id))
logger.info(`[${requestId}] Updated isPublicApi for workflow ${id} to ${isPublicApi}`)
return createSuccessResponse({ isPublicApi })
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Failed to update deployment settings'
logger.error(`[${requestId}] Error updating deployment settings: ${id}`, { error })
return createErrorResponse(message, 500)
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
@@ -309,8 +355,6 @@ export async function DELETE(
const { id } = await params
try {
logger.debug(`[${requestId}] Undeploying workflow: ${id}`)
const {
error,
session,

View File

@@ -21,8 +21,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const { id } = await params
try {
logger.debug(`[${requestId}] Fetching deployed state for workflow: ${id}`)
const authHeader = request.headers.get('authorization')
let isInternalCall = false
@@ -38,8 +36,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const response = createErrorResponse(error.message, error.status)
return addNoCacheHeaders(response)
}
} else {
logger.debug(`[${requestId}] Internal API call for deployed workflow: ${id}`)
}
let deployedState = null
@@ -52,7 +48,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
parallels: data.parallels,
variables: data.variables,
}
} catch {
} catch (error) {
logger.warn(`[${requestId}] Failed to load deployed state for workflow ${id}`, { error })
deployedState = null
}

View File

@@ -12,11 +12,16 @@ import {
import { generateRequestId } from '@/lib/core/utils/request'
import { SSE_HEADERS } from '@/lib/core/utils/sse'
import { getBaseUrl } from '@/lib/core/utils/urls'
import {
buildNextCallChain,
parseCallChain,
SIM_VIA_HEADER,
validateCallChain,
} from '@/lib/execution/call-chain'
import { createExecutionEventWriter, setExecutionMeta } from '@/lib/execution/event-buffer'
import { processInputFileFields } from '@/lib/execution/files'
import { preprocessExecution } from '@/lib/execution/preprocessing'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
import {
cleanupExecutionBase64Cache,
hydrateUserFilesWithBase64,
@@ -155,10 +160,11 @@ type AsyncExecutionParams = {
input: any
triggerType: CoreTriggerType
executionId: string
callChain?: string[]
}
async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextResponse> {
const { requestId, workflowId, userId, input, triggerType, executionId } = params
const { requestId, workflowId, userId, input, triggerType, executionId, callChain } = params
const payload: WorkflowExecutionPayload = {
workflowId,
@@ -166,6 +172,7 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextR
input,
triggerType,
executionId,
callChain,
}
try {
@@ -236,12 +243,59 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
const requestId = generateRequestId()
const { id: workflowId } = await params
const incomingCallChain = parseCallChain(req.headers.get(SIM_VIA_HEADER))
const callChainError = validateCallChain(incomingCallChain)
if (callChainError) {
logger.warn(`[${requestId}] Call chain rejected for workflow ${workflowId}: ${callChainError}`)
return NextResponse.json({ error: callChainError }, { status: 409 })
}
const callChain = buildNextCallChain(incomingCallChain, workflowId)
try {
const auth = await checkHybridAuth(req, { requireWorkflowId: false })
let userId: string
let isPublicApiAccess = false
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
const hasExplicitCredentials =
req.headers.has('x-api-key') || req.headers.get('authorization')?.startsWith('Bearer ')
if (hasExplicitCredentials) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { db: dbClient, workflow: workflowTable } = await import('@sim/db')
const { eq } = await import('drizzle-orm')
const [wf] = await dbClient
.select({
isPublicApi: workflowTable.isPublicApi,
isDeployed: workflowTable.isDeployed,
userId: workflowTable.userId,
})
.from(workflowTable)
.where(eq(workflowTable.id, workflowId))
.limit(1)
if (!wf?.isPublicApi || !wf.isDeployed) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { isPublicApiDisabled } = await import('@/lib/core/config/feature-flags')
if (isPublicApiDisabled) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { getUserPermissionConfig } = await import('@/ee/access-control/utils/permission-check')
const ownerConfig = await getUserPermissionConfig(wf.userId)
if (ownerConfig?.disablePublicApi) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
userId = wf.userId
isPublicApiAccess = true
} else {
userId = auth.userId
}
const userId = auth.userId
let body: any = {}
try {
@@ -268,7 +322,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
)
}
const defaultTriggerType = auth.authType === 'api_key' ? 'api' : 'manual'
const defaultTriggerType = isPublicApiAccess || auth.authType === 'api_key' ? 'api' : 'manual'
const {
selectedOutputs,
@@ -289,7 +343,9 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
| { startBlockId: string; sourceSnapshot: SerializableExecutionState }
| undefined
if (rawRunFromBlock) {
if (rawRunFromBlock.sourceSnapshot) {
if (rawRunFromBlock.sourceSnapshot && !isPublicApiAccess) {
// Public API callers cannot inject arbitrary block state via sourceSnapshot.
// They must use executionId to resume from a server-stored execution state.
resolvedRunFromBlock = {
startBlockId: rawRunFromBlock.startBlockId,
sourceSnapshot: rawRunFromBlock.sourceSnapshot as SerializableExecutionState,
@@ -325,7 +381,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
// For API key and internal JWT auth, the entire body is the input (except for our control fields)
// For session auth, the input is explicitly provided in the input field
const input =
auth.authType === 'api_key' || auth.authType === 'internal_jwt'
isPublicApiAccess || auth.authType === 'api_key' || auth.authType === 'internal_jwt'
? (() => {
const {
selectedOutputs,
@@ -344,19 +400,14 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
})()
: validatedInput
const shouldUseDraftState = useDraftState ?? auth.authType === 'session'
const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId,
action: shouldUseDraftState ? 'write' : 'read',
})
if (!workflowAuthorization.allowed) {
return NextResponse.json(
{ error: workflowAuthorization.message || 'Access denied' },
{ status: workflowAuthorization.status }
)
}
// Public API callers must not inject arbitrary workflow state overrides (code injection risk).
// stopAfterBlockId and runFromBlock are safe — they control execution flow within the deployed state.
const sanitizedWorkflowStateOverride = isPublicApiAccess ? undefined : workflowStateOverride
// Public API callers always execute the deployed state, never the draft.
const shouldUseDraftState = isPublicApiAccess
? false
: (useDraftState ?? auth.authType === 'session')
const streamHeader = req.headers.get('X-Stream-Response') === 'true'
const enableSSE = streamHeader || streamParam === true
const executionModeHeader = req.headers.get('X-Execution-Mode')
@@ -391,6 +442,21 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
const useAuthenticatedUserAsActor =
isClientSession || (auth.authType === 'api_key' && auth.apiKeyType === 'personal')
// Authorization fetches the full workflow record and checks workspace permissions.
// Run it first so we can pass the record to preprocessing (eliminates a duplicate DB query).
const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId,
action: shouldUseDraftState ? 'write' : 'read',
})
if (!workflowAuthorization.allowed) {
return NextResponse.json(
{ error: workflowAuthorization.message || 'Access denied' },
{ status: workflowAuthorization.status }
)
}
// Pass the pre-fetched workflow record to skip the redundant Step 1 DB query in preprocessing.
const preprocessResult = await preprocessExecution({
workflowId,
userId,
@@ -401,6 +467,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
loggingSession,
useDraftState: shouldUseDraftState,
useAuthenticatedUserAsActor,
workflowRecord: workflowAuthorization.workflow ?? undefined,
})
if (!preprocessResult.success) {
@@ -433,6 +500,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
input,
triggerType: loggingTriggerType,
executionId,
callChain,
})
}
@@ -449,7 +517,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
try {
const workflowData = shouldUseDraftState
? await loadWorkflowFromNormalizedTables(workflowId)
: await loadDeployedWorkflowState(workflowId)
: await loadDeployedWorkflowState(workflowId, workspaceId)
if (workflowData) {
const deployedVariables =
@@ -516,7 +584,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
)
}
const effectiveWorkflowStateOverride = workflowStateOverride || cachedWorkflowData || undefined
const effectiveWorkflowStateOverride =
sanitizedWorkflowStateOverride || cachedWorkflowData || undefined
if (!enableSSE) {
logger.info(`[${requestId}] Using non-SSE execution (direct JSON response)`)
@@ -539,6 +608,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
isClientSession,
enforceCredentialAccess: useAuthenticatedUserAsActor,
workflowStateOverride: effectiveWorkflowStateOverride,
callChain,
}
const executionVariables = cachedWorkflowData?.variables ?? workflow.variables ?? {}
@@ -627,12 +697,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
const executionResult = hasExecutionResult(error) ? error.executionResult : undefined
await loggingSession.safeCompleteWithError({
totalDurationMs: executionResult?.metadata?.duration,
error: { message: errorMessage },
traceSpans: executionResult?.logs as any,
})
return NextResponse.json(
{
success: false,
@@ -651,11 +715,9 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
} finally {
timeoutController.cleanup()
if (executionId) {
try {
await cleanupExecutionBase64Cache(executionId)
} catch (error) {
void cleanupExecutionBase64Cache(executionId).catch((error) => {
logger.error(`[${requestId}] Failed to cleanup base64 cache`, { error })
}
})
}
}
}
@@ -909,6 +971,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
isClientSession,
enforceCredentialAccess: useAuthenticatedUserAsActor,
workflowStateOverride: effectiveWorkflowStateOverride,
callChain,
}
const sseExecutionVariables = cachedWorkflowData?.variables ?? workflow.variables ?? {}
@@ -924,7 +987,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
const onChildWorkflowInstanceReady = (
blockId: string,
childWorkflowInstanceId: string,
iterationContext?: IterationContext
iterationContext?: IterationContext,
executionOrder?: number
) => {
sendEvent({
type: 'block:childWorkflowStarted',
@@ -938,6 +1002,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
iterationCurrent: iterationContext.iterationCurrent,
iterationContainerId: iterationContext.iterationContainerId,
}),
...(executionOrder !== undefined && { executionOrder }),
},
})
}
@@ -1055,15 +1120,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
logger.error(`[${requestId}] SSE execution failed: ${errorMessage}`, { isTimeout })
const executionResult = hasExecutionResult(error) ? error.executionResult : undefined
const { traceSpans, totalDuration } = executionResult
? buildTraceSpans(executionResult)
: { traceSpans: [], totalDuration: 0 }
await loggingSession.safeCompleteWithError({
totalDurationMs: totalDuration || executionResult?.metadata?.duration,
error: { message: errorMessage },
traceSpans,
})
sendEvent({
type: 'execution:error',

View File

@@ -77,18 +77,9 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
}
}
logger.debug(`[${requestId}] Attempting to load workflow ${workflowId} from normalized tables`)
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
if (normalizedData) {
logger.debug(`[${requestId}] Found normalized data for workflow ${workflowId}:`, {
blocksCount: Object.keys(normalizedData.blocks).length,
edgesCount: normalizedData.edges.length,
loopsCount: Object.keys(normalizedData.loops).length,
parallelsCount: Object.keys(normalizedData.parallels).length,
loops: normalizedData.loops,
})
const finalWorkflowData = {
...workflowData,
state: {
@@ -347,6 +338,9 @@ export async function DELETE(
resourceId: workflowId,
resourceName: workflowData.name,
description: `Deleted workflow "${workflowData.name}"`,
metadata: {
deleteTemplates: deleteTemplatesParam === 'delete',
},
request,
})

View File

@@ -11,7 +11,7 @@ import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/sanitization/validation'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
import type { BlockState } from '@/stores/workflows/workflow/types'
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
const logger = createLogger('WorkflowStateAPI')
@@ -153,13 +153,15 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
}
// Sanitize custom tools in agent blocks before saving
const { blocks: sanitizedBlocks, warnings } = sanitizeAgentToolsInBlocks(state.blocks as any)
const { blocks: sanitizedBlocks, warnings } = sanitizeAgentToolsInBlocks(
state.blocks as Record<string, BlockState>
)
// Save to normalized tables
// Ensure all required fields are present for WorkflowState type
// Filter out blocks without type or name before saving
const filteredBlocks = Object.entries(sanitizedBlocks).reduce(
(acc, [blockId, block]: [string, any]) => {
(acc, [blockId, block]: [string, BlockState]) => {
if (block.type && block.name) {
// Ensure all required fields are present
acc[blockId] = {
@@ -191,7 +193,10 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
deployedAt: state.deployedAt,
}
const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowState as any)
const saveResult = await saveWorkflowToNormalizedTables(
workflowId,
workflowState as WorkflowState
)
if (!saveResult.success) {
logger.error(`[${requestId}] Failed to save workflow ${workflowId} state:`, saveResult.error)

View File

@@ -7,6 +7,7 @@ import { hasWorkflowChanged } from '@/lib/workflows/comparison'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('WorkflowStatusAPI')
@@ -64,7 +65,10 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
.limit(1)
if (active?.state) {
needsRedeployment = hasWorkflowChanged(currentState as any, active.state as any)
needsRedeployment = hasWorkflowChanged(
currentState as WorkflowState,
active.state as WorkflowState
)
}
}

View File

@@ -90,6 +90,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
resourceId: workflowId,
resourceName: workflowData.name ?? undefined,
description: `Updated workflow variables`,
metadata: { variableCount: Object.keys(variables).length },
request: req,
})

View File

@@ -137,7 +137,7 @@ export async function DELETE(
.where(
and(eq(apiKey.workspaceId, workspaceId), eq(apiKey.id, keyId), eq(apiKey.type, 'workspace'))
)
.returning({ id: apiKey.id, name: apiKey.name })
.returning({ id: apiKey.id, name: apiKey.name, lastUsed: apiKey.lastUsed })
if (deletedRows.length === 0) {
return NextResponse.json({ error: 'API key not found' }, { status: 404 })
@@ -155,6 +155,7 @@ export async function DELETE(
actorEmail: session.user.email ?? undefined,
resourceName: deletedKey.name,
description: `Revoked workspace API key: ${deletedKey.name}`,
metadata: { lastUsed: deletedKey.lastUsed?.toISOString() ?? null },
request,
})

View File

@@ -56,6 +56,10 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
resourceId: result.id,
resourceName: name,
description: `Duplicated workspace to "${name}"`,
metadata: {
sourceWorkspaceId,
affected: { workflows: result.workflowsCount, folders: result.foldersCount },
},
request: req,
})

View File

@@ -140,7 +140,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
resourceType: AuditResourceType.ENVIRONMENT,
resourceId: workspaceId,
description: `Updated environment variables`,
metadata: { keysUpdated: Object.keys(variables) },
metadata: { variableCount: Object.keys(variables).length },
request,
})

View File

@@ -1,6 +1,6 @@
import crypto from 'crypto'
import { db } from '@sim/db'
import { permissions, workspace, workspaceEnvironment } from '@sim/db/schema'
import { permissions, user, workspace, workspaceEnvironment } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
@@ -132,6 +132,21 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
)
}
// Capture existing permissions and user info for audit metadata
const existingPerms = await db
.select({
userId: permissions.userId,
permissionType: permissions.permissionType,
email: user.email,
})
.from(permissions)
.innerJoin(user, eq(permissions.userId, user.id))
.where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId)))
const permLookup = new Map(
existingPerms.map((p) => [p.userId, { permission: p.permissionType, email: p.email }])
)
await db.transaction(async (tx) => {
for (const update of body.updates) {
await tx
@@ -182,7 +197,17 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
description: `Changed permissions for user ${update.userId} to ${update.permissions}`,
metadata: { targetUserId: update.userId, newPermissions: update.permissions },
metadata: {
targetUserId: update.userId,
targetEmail: permLookup.get(update.userId)?.email ?? undefined,
changes: [
{
field: 'permissions',
from: permLookup.get(update.userId)?.permission ?? null,
to: update.permissions,
},
],
},
request,
})
}

View File

@@ -237,6 +237,7 @@ export async function DELETE(
.limit(1)
// Delete workspace and all related data in a transaction
let workspaceWorkflowCount = 0
await db.transaction(async (tx) => {
// Get all workflows in this workspace before deletion
const workspaceWorkflows = await tx
@@ -244,6 +245,8 @@ export async function DELETE(
.from(workflow)
.where(eq(workflow.workspaceId, workspaceId))
workspaceWorkflowCount = workspaceWorkflows.length
if (workspaceWorkflows.length > 0) {
const workflowIds = workspaceWorkflows.map((w) => w.id)
@@ -299,6 +302,12 @@ export async function DELETE(
resourceId: workspaceId,
resourceName: workspaceRecord?.name,
description: `Deleted workspace "${workspaceRecord?.name || workspaceId}"`,
metadata: {
affected: {
workflows: workspaceWorkflowCount,
},
deleteTemplates,
},
request,
})

View File

@@ -189,6 +189,7 @@ export async function GET(
actorEmail: session.user.email ?? undefined,
resourceName: workspaceDetails.name,
description: `Accepted workspace invitation to "${workspaceDetails.name}"`,
metadata: { targetEmail: invitation.email },
request: req,
})
@@ -255,7 +256,7 @@ export async function DELETE(
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
description: `Revoked workspace invitation for ${invitation.email}`,
metadata: { invitationId, email: invitation.email },
metadata: { invitationId, targetEmail: invitation.email },
request: _request,
})

View File

@@ -225,7 +225,7 @@ export async function POST(req: NextRequest) {
resourceId: workspaceId,
resourceName: email,
description: `Invited ${email} as ${permission}`,
metadata: { email, role: permission },
metadata: { targetEmail: email, targetRole: permission },
request: req,
})

View File

@@ -3,8 +3,8 @@
import type React from 'react'
import { useEffect, useRef, useState } from 'react'
import { motion } from 'framer-motion'
import { AlertCircle, Paperclip, Send, Square, X } from 'lucide-react'
import { Tooltip } from '@/components/emcn'
import { Paperclip, Send, Square, X } from 'lucide-react'
import { Badge, Tooltip } from '@/components/emcn'
import { VoiceInput } from '@/app/chat/components/input/voice-input'
const logger = createLogger('ChatInput')
@@ -218,24 +218,12 @@ export const ChatInput: React.FC<{
<div ref={wrapperRef} className='w-full max-w-3xl md:max-w-[748px]'>
{/* Error Messages */}
{uploadErrors.length > 0 && (
<div className='mb-3'>
<div className='rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-800/50 dark:bg-red-950/20'>
<div className='flex items-start gap-2'>
<AlertCircle className='mt-0.5 h-4 w-4 shrink-0 text-red-600 dark:text-red-400' />
<div className='flex-1'>
<div className='mb-1 font-medium text-red-800 text-sm dark:text-red-300'>
File upload error
</div>
<div className='space-y-1'>
{uploadErrors.map((error, idx) => (
<div key={idx} className='text-red-700 text-sm dark:text-red-400'>
{error}
</div>
))}
</div>
</div>
</div>
</div>
<div className='mb-3 flex flex-col gap-2'>
{uploadErrors.map((error, idx) => (
<Badge key={idx} variant='red' size='lg' dot className='max-w-full'>
{error}
</Badge>
))}
</div>
)}

View File

@@ -21,6 +21,7 @@ interface TemplateCardProps {
blocks?: string[]
className?: string
state?: WorkflowState
description?: string | null
isStarred?: boolean
isVerified?: boolean
}
@@ -124,6 +125,7 @@ function TemplateCardInner({
blocks = [],
className,
state,
description,
isStarred = false,
isVerified = false,
}: TemplateCardProps) {
@@ -270,6 +272,12 @@ function TemplateCardInner({
</div>
</div>
{description && (
<p className='mt-[4px] truncate pl-[2px] text-[12px] text-[var(--text-tertiary)]'>
{description}
</p>
)}
<div className='mt-[10px] flex items-center justify-between'>
<div className='flex min-w-0 items-center gap-[8px]'>
{authorImageUrl ? (

View File

@@ -196,6 +196,7 @@ export default function Templates({
key={template.id}
id={template.id}
title={template.name}
description={template.details?.tagline}
author={template.creator?.name || 'Unknown'}
authorImageUrl={template.creator?.profileImageUrl || null}
usageCount={template.views.toString()}

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