Compare commits

...

17 Commits

Author SHA1 Message Date
Waleed Latif
315e4509a3 fix(attio): use code subblock type for JSON input fields 2026-02-24 15:25:47 -08:00
Waleed Latif
5e3c43ff83 fix(attio): use timestamp generationType for date wandConfig fields 2026-02-24 13:52:40 -08:00
waleed
8db43b775c update docs 2026-02-24 13:50:36 -08:00
Waleed Latif
edf3c0dc06 feat(attio): add Attio CRM integration with 40 tools and 18 webhook triggers 2026-02-24 13:43:48 -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
Vikhyath Mondreti
2ae814549a improvement(migration): move credential selector automigration logic to server side (#3310)
* improvement(credentials): move client side automigration to server side

* fix migration func

* fix tests

* address bugbot
2026-02-23 06:33:54 -08:00
Vikhyath Mondreti
e55d41f2ef fix(credentials): credential dependent endpoints (#3309)
* fix(dependent): credential dependent endpoints

* fix tests

* fix route to not block ws creds"

* remove faulty auth checks:

* prevent unintended cascade by depends on during migration

* address bugbot comments
2026-02-23 04:38:03 -08:00
Vikhyath Mondreti
364bb196ea feat(credentials): multiple credentials per provider (#3211)
* feat(mult-credentials): progress

* checkpoint

* make it autoselect personal secret when create secret is clicked

* improve collaborative UX

* remove add member ui for workspace secrets

* bulk entry of .env

* promote to workspace secret

* more ux improvmeent

* share with workspace for oauth

* remove new badge

* share button

* copilot + oauth name comflict

* reconnect option to connect diff account

* remove credential no access marker

* canonical credential id entry

* remove migration to prep stagin migration

* migration readded

* backfill improvements

* run lint

* fix tests

* remove unused code

* autoselect provider when connecting from block

* address bugbot comments

* remove some dead code

* more permissions stuff

* remove more unused code

* address bugbot

* add filter

* remove migration to prep migration

* fix migration

* fix migration issues

* remove migration prep merge

* readd migration

* include user tables triggers

* extract shared code

* fix

* fix tx issue

* remove migration to prep merge

* readd migration

* fix agent tool input

* agent with tool input deletion case

* fix credential subblock saving

* remove dead code

* fix tests

* address bugbot comments
2026-02-23 02:26:16 -08:00
357 changed files with 48585 additions and 3487 deletions

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
@@ -3541,6 +3552,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'>
@@ -5824,7 +5844,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,
@@ -40,6 +41,7 @@ import {
GithubIcon,
GitLabIcon,
GmailIcon,
GongIcon,
GoogleBooksIcon,
GoogleCalendarIcon,
GoogleDocsIcon,
@@ -158,6 +160,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
apollo: ApolloIcon,
arxiv: ArxivIcon,
asana: AsanaIcon,
attio: AttioIcon,
browser_use: BrowserUseIcon,
calcom: CalComIcon,
calendly: CalendlyIcon,
@@ -183,6 +186,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
github_v2: GithubIcon,
gitlab: GitLabIcon,
gmail_v2: GmailIcon,
gong: GongIcon,
google_books: GoogleBooksIcon,
google_calendar_v2: GoogleCalendarIcon,
google_docs: GoogleDocsIcon,

File diff suppressed because it is too large Load Diff

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

@@ -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",
@@ -35,6 +36,7 @@
"github",
"gitlab",
"gmail",
"gong",
"google_books",
"google_calendar",
"google_docs",

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

@@ -1,7 +1,7 @@
import { db } from '@sim/db'
import { account } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { and, desc, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
@@ -31,15 +31,13 @@ export async function GET(request: NextRequest) {
})
.from(account)
.where(and(...whereConditions))
// Use the user's email as the display name (consistent with credential selector)
const userEmail = session.user.email
.orderBy(desc(account.updatedAt))
const accountsWithDisplayName = accounts.map((acc) => ({
id: acc.id,
accountId: acc.accountId,
providerId: acc.providerId,
displayName: userEmail || acc.providerId,
displayName: acc.accountId || acc.providerId,
}))
return NextResponse.json({ accounts: accountsWithDisplayName })

View File

@@ -57,10 +57,6 @@ describe('OAuth Credentials API Route', () => {
eq: vi.fn((field, value) => ({ field, value, type: 'eq' })),
}))
vi.doMock('jwt-decode', () => ({
jwtDecode: vi.fn(),
}))
vi.doMock('@sim/logger', () => ({
createLogger: vi.fn().mockReturnValue(mockLogger),
}))
@@ -84,64 +80,6 @@ describe('OAuth Credentials API Route', () => {
vi.clearAllMocks()
})
it('should return credentials successfully', async () => {
mockGetSession.mockResolvedValueOnce({
user: { id: 'user-123' },
})
mockParseProvider.mockReturnValueOnce({
baseProvider: 'google',
})
const mockAccounts = [
{
id: 'credential-1',
userId: 'user-123',
providerId: 'google-email',
accountId: 'test@example.com',
updatedAt: new Date('2024-01-01'),
idToken: null,
},
{
id: 'credential-2',
userId: 'user-123',
providerId: 'google-default',
accountId: 'user-id',
updatedAt: new Date('2024-01-02'),
idToken: null,
},
]
mockDb.select.mockReturnValueOnce(mockDb)
mockDb.from.mockReturnValueOnce(mockDb)
mockDb.where.mockResolvedValueOnce(mockAccounts)
mockDb.select.mockReturnValueOnce(mockDb)
mockDb.from.mockReturnValueOnce(mockDb)
mockDb.where.mockReturnValueOnce(mockDb)
mockDb.limit.mockResolvedValueOnce([{ email: 'user@example.com' }])
const req = createMockRequestWithQuery('GET', '?provider=google-email')
const { GET } = await import('@/app/api/auth/oauth/credentials/route')
const response = await GET(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.credentials).toHaveLength(2)
expect(data.credentials[0]).toMatchObject({
id: 'credential-1',
provider: 'google-email',
isDefault: false,
})
expect(data.credentials[1]).toMatchObject({
id: 'credential-2',
provider: 'google-default',
isDefault: true,
})
})
it('should handle unauthenticated user', async () => {
mockGetSession.mockResolvedValueOnce(null)
@@ -198,39 +136,12 @@ describe('OAuth Credentials API Route', () => {
expect(data.credentials).toHaveLength(0)
})
it('should decode ID token for display name', async () => {
const { jwtDecode } = await import('jwt-decode')
const mockJwtDecode = jwtDecode as any
it('should return empty credentials when no workspace context', async () => {
mockGetSession.mockResolvedValueOnce({
user: { id: 'user-123' },
})
mockParseProvider.mockReturnValueOnce({
baseProvider: 'google',
})
const mockAccounts = [
{
id: 'credential-1',
userId: 'user-123',
providerId: 'google-default',
accountId: 'google-user-id',
updatedAt: new Date('2024-01-01'),
idToken: 'mock-jwt-token',
},
]
mockJwtDecode.mockReturnValueOnce({
email: 'decoded@example.com',
name: 'Decoded User',
})
mockDb.select.mockReturnValueOnce(mockDb)
mockDb.from.mockReturnValueOnce(mockDb)
mockDb.where.mockResolvedValueOnce(mockAccounts)
const req = createMockRequestWithQuery('GET', '?provider=google')
const req = createMockRequestWithQuery('GET', '?provider=google-email')
const { GET } = await import('@/app/api/auth/oauth/credentials/route')
@@ -238,31 +149,6 @@ describe('OAuth Credentials API Route', () => {
const data = await response.json()
expect(response.status).toBe(200)
expect(data.credentials[0].name).toBe('decoded@example.com')
})
it('should handle database error', async () => {
mockGetSession.mockResolvedValueOnce({
user: { id: 'user-123' },
})
mockParseProvider.mockReturnValueOnce({
baseProvider: 'google',
})
mockDb.select.mockReturnValueOnce(mockDb)
mockDb.from.mockReturnValueOnce(mockDb)
mockDb.where.mockRejectedValueOnce(new Error('Database error'))
const req = createMockRequestWithQuery('GET', '?provider=google')
const { GET } = await import('@/app/api/auth/oauth/credentials/route')
const response = await GET(req)
const data = await response.json()
expect(response.status).toBe(500)
expect(data.error).toBe('Internal server error')
expect(mockLogger.error).toHaveBeenCalled()
expect(data.credentials).toHaveLength(0)
})
})

View File

@@ -1,14 +1,15 @@
import { db } from '@sim/db'
import { account, user } from '@sim/db/schema'
import { account, credential, credentialMember } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { jwtDecode } from 'jwt-decode'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { evaluateScopeCoverage, type OAuthProvider, parseProvider } from '@/lib/oauth'
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
import { evaluateScopeCoverage } from '@/lib/oauth'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
export const dynamic = 'force-dynamic'
@@ -18,6 +19,7 @@ const credentialsQuerySchema = z
.object({
provider: z.string().nullish(),
workflowId: z.string().uuid('Workflow ID must be a valid UUID').nullish(),
workspaceId: z.string().uuid('Workspace ID must be a valid UUID').nullish(),
credentialId: z
.string()
.min(1, 'Credential ID must not be empty')
@@ -29,10 +31,30 @@ const credentialsQuerySchema = z
path: ['provider'],
})
interface GoogleIdToken {
email?: string
sub?: string
name?: string
function toCredentialResponse(
id: string,
displayName: string,
providerId: string,
updatedAt: Date,
scope: string | null
) {
const storedScope = scope?.trim()
const grantedScopes = storedScope ? storedScope.split(/[\s,]+/).filter(Boolean) : []
const scopeEvaluation = evaluateScopeCoverage(providerId, grantedScopes)
const [_, featureType = 'default'] = providerId.split('-')
return {
id,
name: displayName,
provider: providerId,
lastUsed: updatedAt.toISOString(),
isDefault: featureType === 'default',
scopes: scopeEvaluation.grantedScopes,
canonicalScopes: scopeEvaluation.canonicalScopes,
missingScopes: scopeEvaluation.missingScopes,
extraScopes: scopeEvaluation.extraScopes,
requiresReauthorization: scopeEvaluation.requiresReauthorization,
}
}
/**
@@ -46,6 +68,7 @@ export async function GET(request: NextRequest) {
const rawQuery = {
provider: searchParams.get('provider'),
workflowId: searchParams.get('workflowId'),
workspaceId: searchParams.get('workspaceId'),
credentialId: searchParams.get('credentialId'),
}
@@ -78,7 +101,7 @@ export async function GET(request: NextRequest) {
)
}
const { provider: providerParam, workflowId, credentialId } = parseResult.data
const { provider: providerParam, workflowId, workspaceId, credentialId } = parseResult.data
// Authenticate requester (supports session and internal JWT)
const authResult = await checkSessionOrInternalAuth(request)
@@ -88,7 +111,7 @@ export async function GET(request: NextRequest) {
}
const requesterUserId = authResult.userId
const effectiveUserId = requesterUserId
let effectiveWorkspaceId = workspaceId ?? undefined
if (workflowId) {
const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
@@ -106,105 +129,125 @@ export async function GET(request: NextRequest) {
{ status: workflowAuthorization.status }
)
}
effectiveWorkspaceId = workflowAuthorization.workflow?.workspaceId || undefined
}
// Parse the provider to get base provider and feature type (if provider is present)
const { baseProvider } = parseProvider((providerParam || 'google') as OAuthProvider)
let accountsData
if (credentialId && workflowId) {
// When both workflowId and credentialId are provided, fetch by ID only.
// Workspace authorization above already proves access; the credential
// may belong to another workspace member (e.g. for display name resolution).
accountsData = await db.select().from(account).where(eq(account.id, credentialId))
} else if (credentialId) {
accountsData = await db
.select()
.from(account)
.where(and(eq(account.userId, effectiveUserId), eq(account.id, credentialId)))
} else {
// Fetch all credentials for provider and effective user
accountsData = await db
.select()
.from(account)
.where(and(eq(account.userId, effectiveUserId), eq(account.providerId, providerParam!)))
if (effectiveWorkspaceId) {
const workspaceAccess = await checkWorkspaceAccess(effectiveWorkspaceId, requesterUserId)
if (!workspaceAccess.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
// Transform accounts into credentials
const credentials = await Promise.all(
accountsData.map(async (acc) => {
// Extract the feature type from providerId (e.g., 'google-default' -> 'default')
const [_, featureType = 'default'] = acc.providerId.split('-')
if (credentialId) {
const [platformCredential] = await db
.select({
id: credential.id,
workspaceId: credential.workspaceId,
type: credential.type,
displayName: credential.displayName,
providerId: credential.providerId,
accountId: credential.accountId,
accountProviderId: account.providerId,
accountScope: account.scope,
accountUpdatedAt: account.updatedAt,
})
.from(credential)
.leftJoin(account, eq(credential.accountId, account.id))
.where(eq(credential.id, credentialId))
.limit(1)
// Try multiple methods to get a user-friendly display name
let displayName = ''
if (platformCredential) {
if (platformCredential.type !== 'oauth' || !platformCredential.accountId) {
return NextResponse.json({ credentials: [] }, { status: 200 })
}
// Method 1: Try to extract email from ID token (works for Google, etc.)
if (acc.idToken) {
try {
const decoded = jwtDecode<GoogleIdToken>(acc.idToken)
if (decoded.email) {
displayName = decoded.email
} else if (decoded.name) {
displayName = decoded.name
}
} catch (_error) {
logger.warn(`[${requestId}] Error decoding ID token`, {
accountId: acc.id,
})
if (workflowId) {
if (!effectiveWorkspaceId || platformCredential.workspaceId !== effectiveWorkspaceId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
} else {
const [membership] = await db
.select({ id: credentialMember.id })
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, platformCredential.id),
eq(credentialMember.userId, requesterUserId),
eq(credentialMember.status, 'active')
)
)
.limit(1)
if (!membership) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
// Method 2: For GitHub, the accountId might be the username
if (!displayName && baseProvider === 'github') {
displayName = `${acc.accountId} (GitHub)`
if (!platformCredential.accountProviderId || !platformCredential.accountUpdatedAt) {
return NextResponse.json({ credentials: [] }, { status: 200 })
}
// Method 3: Try to get the user's email from our database
if (!displayName) {
try {
const userRecord = await db
.select({ email: user.email })
.from(user)
.where(eq(user.id, acc.userId))
.limit(1)
return NextResponse.json(
{
credentials: [
toCredentialResponse(
platformCredential.id,
platformCredential.displayName,
platformCredential.accountProviderId,
platformCredential.accountUpdatedAt,
platformCredential.accountScope
),
],
},
{ status: 200 }
)
}
}
if (userRecord.length > 0) {
displayName = userRecord[0].email
}
} catch (_error) {
logger.warn(`[${requestId}] Error fetching user email`, {
userId: acc.userId,
})
}
}
// Fallback: Use accountId with provider type as context
if (!displayName) {
displayName = `${acc.accountId} (${baseProvider})`
}
const storedScope = acc.scope?.trim()
const grantedScopes = storedScope ? storedScope.split(/[\s,]+/).filter(Boolean) : []
const scopeEvaluation = evaluateScopeCoverage(acc.providerId, grantedScopes)
return {
id: acc.id,
name: displayName,
provider: acc.providerId,
lastUsed: acc.updatedAt.toISOString(),
isDefault: featureType === 'default',
scopes: scopeEvaluation.grantedScopes,
canonicalScopes: scopeEvaluation.canonicalScopes,
missingScopes: scopeEvaluation.missingScopes,
extraScopes: scopeEvaluation.extraScopes,
requiresReauthorization: scopeEvaluation.requiresReauthorization,
}
if (effectiveWorkspaceId && providerParam) {
await syncWorkspaceOAuthCredentialsForUser({
workspaceId: effectiveWorkspaceId,
userId: requesterUserId,
})
)
return NextResponse.json({ credentials }, { status: 200 })
const credentialsData = await db
.select({
id: credential.id,
displayName: credential.displayName,
providerId: account.providerId,
scope: account.scope,
updatedAt: account.updatedAt,
})
.from(credential)
.innerJoin(account, eq(credential.accountId, account.id))
.innerJoin(
credentialMember,
and(
eq(credentialMember.credentialId, credential.id),
eq(credentialMember.userId, requesterUserId),
eq(credentialMember.status, 'active')
)
)
.where(
and(
eq(credential.workspaceId, effectiveWorkspaceId),
eq(credential.type, 'oauth'),
eq(account.providerId, providerParam)
)
)
return NextResponse.json(
{
credentials: credentialsData.map((row) =>
toCredentialResponse(row.id, row.displayName, row.providerId, row.updatedAt, row.scope)
),
},
{ status: 200 }
)
}
return NextResponse.json({ credentials: [] }, { status: 200 })
} catch (error) {
logger.error(`[${requestId}] Error fetching OAuth credentials`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })

View File

@@ -16,6 +16,7 @@ const logger = createLogger('OAuthDisconnectAPI')
const disconnectSchema = z.object({
provider: z.string({ required_error: 'Provider is required' }).min(1, 'Provider is required'),
providerId: z.string().optional(),
accountId: z.string().optional(),
})
/**
@@ -51,15 +52,20 @@ export async function POST(request: NextRequest) {
)
}
const { provider, providerId } = parseResult.data
const { provider, providerId, accountId } = parseResult.data
logger.info(`[${requestId}] Processing OAuth disconnect request`, {
provider,
hasProviderId: !!providerId,
})
// If a specific providerId is provided, delete only that account
if (providerId) {
// If a specific account row ID is provided, delete that exact account
if (accountId) {
await db
.delete(account)
.where(and(eq(account.userId, session.user.id), eq(account.id, accountId)))
} else if (providerId) {
// If a specific providerId is provided, delete accounts for that provider ID
await db
.delete(account)
.where(and(eq(account.userId, session.user.id), eq(account.providerId, providerId)))

View File

@@ -38,13 +38,18 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status })
}
const credential = await getCredential(requestId, credentialId, authz.credentialOwnerUserId)
const resolvedCredentialId = authz.resolvedCredentialId || credentialId
const credential = await getCredential(
requestId,
resolvedCredentialId,
authz.credentialOwnerUserId
)
if (!credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const accessToken = await refreshAccessTokenIfNeeded(
credentialId,
resolvedCredentialId,
authz.credentialOwnerUserId,
requestId
)

View File

@@ -37,14 +37,19 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status })
}
const credential = await getCredential(requestId, credentialId, authz.credentialOwnerUserId)
const resolvedCredentialId = authz.resolvedCredentialId || credentialId
const credential = await getCredential(
requestId,
resolvedCredentialId,
authz.credentialOwnerUserId
)
if (!credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
// Refresh access token if needed using the utility function
const accessToken = await refreshAccessTokenIfNeeded(
credentialId,
resolvedCredentialId,
authz.credentialOwnerUserId,
requestId
)

View File

@@ -344,10 +344,11 @@ describe('OAuth Token API Routes', () => {
*/
describe('GET handler', () => {
it('should return access token successfully', async () => {
mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: true,
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
authType: 'session',
userId: 'test-user-id',
requesterUserId: 'test-user-id',
credentialOwnerUserId: 'test-user-id',
})
mockGetCredential.mockResolvedValueOnce({
id: 'credential-id',
@@ -373,8 +374,8 @@ describe('OAuth Token API Routes', () => {
expect(response.status).toBe(200)
expect(data).toHaveProperty('accessToken', 'fresh-token')
expect(mockCheckSessionOrInternalAuth).toHaveBeenCalled()
expect(mockGetCredential).toHaveBeenCalledWith(mockRequestId, 'credential-id', 'test-user-id')
expect(mockAuthorizeCredentialUse).toHaveBeenCalled()
expect(mockGetCredential).toHaveBeenCalled()
expect(mockRefreshTokenIfNeeded).toHaveBeenCalled()
})
@@ -392,8 +393,8 @@ describe('OAuth Token API Routes', () => {
})
it('should handle authentication failure', async () => {
mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: false,
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: false,
error: 'Authentication required',
})
@@ -406,15 +407,16 @@ describe('OAuth Token API Routes', () => {
const response = await GET(req as any)
const data = await response.json()
expect(response.status).toBe(401)
expect(response.status).toBe(403)
expect(data).toHaveProperty('error')
})
it('should handle credential not found', async () => {
mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: true,
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
authType: 'session',
userId: 'test-user-id',
requesterUserId: 'test-user-id',
credentialOwnerUserId: 'test-user-id',
})
mockGetCredential.mockResolvedValueOnce(undefined)
@@ -432,10 +434,11 @@ describe('OAuth Token API Routes', () => {
})
it('should handle missing access token', async () => {
mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: true,
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
authType: 'session',
userId: 'test-user-id',
requesterUserId: 'test-user-id',
credentialOwnerUserId: 'test-user-id',
})
mockGetCredential.mockResolvedValueOnce({
id: 'credential-id',
@@ -458,10 +461,11 @@ describe('OAuth Token API Routes', () => {
})
it('should handle token refresh failure', async () => {
mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: true,
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
authType: 'session',
userId: 'test-user-id',
requesterUserId: 'test-user-id',
credentialOwnerUserId: 'test-user-id',
})
mockGetCredential.mockResolvedValueOnce({
id: 'credential-id',

View File

@@ -110,23 +110,35 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
}
const callerUserId = new URL(request.url).searchParams.get('userId') || undefined
const authz = await authorizeCredentialUse(request, {
credentialId,
workflowId: workflowId ?? undefined,
requireWorkflowIdForInternal: false,
callerUserId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const credential = await getCredential(requestId, credentialId, authz.credentialOwnerUserId)
const resolvedCredentialId = authz.resolvedCredentialId || credentialId
const credential = await getCredential(
requestId,
resolvedCredentialId,
authz.credentialOwnerUserId
)
if (!credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
try {
const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId)
const { accessToken } = await refreshTokenIfNeeded(
requestId,
credential,
resolvedCredentialId
)
let instanceUrl: string | undefined
if (credential.providerId === 'salesforce' && credential.scope) {
@@ -186,13 +198,20 @@ export async function GET(request: NextRequest) {
const { credentialId } = parseResult.data
// For GET requests, we only support session-based authentication
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || auth.authType !== 'session' || !auth.userId) {
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
const authz = await authorizeCredentialUse(request, {
credentialId,
requireWorkflowIdForInternal: false,
})
if (!authz.ok || authz.authType !== 'session' || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const credential = await getCredential(requestId, credentialId, auth.userId)
const resolvedCredentialId = authz.resolvedCredentialId || credentialId
const credential = await getCredential(
requestId,
resolvedCredentialId,
authz.credentialOwnerUserId
)
if (!credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
@@ -204,7 +223,11 @@ export async function GET(request: NextRequest) {
}
try {
const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId)
const { accessToken } = await refreshTokenIfNeeded(
requestId,
credential,
resolvedCredentialId
)
// For Salesforce, extract instanceUrl from the scope field
let instanceUrl: string | undefined

View File

@@ -62,21 +62,23 @@ describe('OAuth Utils', () => {
describe('getCredential', () => {
it('should return credential when found', async () => {
const mockCredential = { id: 'credential-id', userId: 'test-user-id' }
const { mockFrom, mockWhere, mockLimit } = mockSelectChain([mockCredential])
const mockCredentialRow = { type: 'oauth', accountId: 'resolved-account-id' }
const mockAccountRow = { id: 'resolved-account-id', userId: 'test-user-id' }
mockSelectChain([mockCredentialRow])
mockSelectChain([mockAccountRow])
const credential = await getCredential('request-id', 'credential-id', 'test-user-id')
expect(mockDb.select).toHaveBeenCalled()
expect(mockFrom).toHaveBeenCalled()
expect(mockWhere).toHaveBeenCalled()
expect(mockLimit).toHaveBeenCalledWith(1)
expect(mockDb.select).toHaveBeenCalledTimes(2)
expect(credential).toEqual(mockCredential)
expect(credential).toMatchObject(mockAccountRow)
expect(credential).toMatchObject({ resolvedCredentialId: 'resolved-account-id' })
})
it('should return undefined when credential is not found', async () => {
mockSelectChain([])
mockSelectChain([])
const credential = await getCredential('request-id', 'nonexistent-id', 'test-user-id')
@@ -158,15 +160,17 @@ describe('OAuth Utils', () => {
describe('refreshAccessTokenIfNeeded', () => {
it('should return valid access token without refresh if not expired', async () => {
const mockCredential = {
id: 'credential-id',
const mockCredentialRow = { type: 'oauth', accountId: 'account-id' }
const mockAccountRow = {
id: 'account-id',
accessToken: 'valid-token',
refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000),
providerId: 'google',
userId: 'test-user-id',
}
mockSelectChain([mockCredential])
mockSelectChain([mockCredentialRow])
mockSelectChain([mockAccountRow])
const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id')
@@ -175,15 +179,17 @@ describe('OAuth Utils', () => {
})
it('should refresh token when expired', async () => {
const mockCredential = {
id: 'credential-id',
const mockCredentialRow = { type: 'oauth', accountId: 'account-id' }
const mockAccountRow = {
id: 'account-id',
accessToken: 'expired-token',
refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000),
providerId: 'google',
userId: 'test-user-id',
}
mockSelectChain([mockCredential])
mockSelectChain([mockCredentialRow])
mockSelectChain([mockAccountRow])
mockUpdateChain()
mockRefreshOAuthToken.mockResolvedValueOnce({
@@ -201,6 +207,7 @@ describe('OAuth Utils', () => {
it('should return null if credential not found', async () => {
mockSelectChain([])
mockSelectChain([])
const token = await refreshAccessTokenIfNeeded('nonexistent-id', 'test-user-id', 'request-id')
@@ -208,15 +215,17 @@ describe('OAuth Utils', () => {
})
it('should return null if refresh fails', async () => {
const mockCredential = {
id: 'credential-id',
const mockCredentialRow = { type: 'oauth', accountId: 'account-id' }
const mockAccountRow = {
id: 'account-id',
accessToken: 'expired-token',
refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000),
providerId: 'google',
userId: 'test-user-id',
}
mockSelectChain([mockCredential])
mockSelectChain([mockCredentialRow])
mockSelectChain([mockAccountRow])
mockRefreshOAuthToken.mockResolvedValueOnce(null)

View File

@@ -1,5 +1,5 @@
import { db } from '@sim/db'
import { account, credentialSetMember } from '@sim/db/schema'
import { account, credential, credentialSetMember } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, desc, eq, inArray } from 'drizzle-orm'
import { refreshOAuthToken } from '@/lib/oauth'
@@ -25,6 +25,38 @@ interface AccountInsertData {
accessTokenExpiresAt?: Date
}
/**
* Resolves a credential ID to its underlying account ID.
* If `credentialId` matches a `credential` row, returns its `accountId` and `workspaceId`.
* Otherwise assumes `credentialId` is already a raw `account.id` (legacy).
*/
export async function resolveOAuthAccountId(
credentialId: string
): Promise<{ accountId: string; workspaceId?: string; usedCredentialTable: boolean } | null> {
const [credentialRow] = await db
.select({
type: credential.type,
accountId: credential.accountId,
workspaceId: credential.workspaceId,
})
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
if (credentialRow) {
if (credentialRow.type !== 'oauth' || !credentialRow.accountId) {
return null
}
return {
accountId: credentialRow.accountId,
workspaceId: credentialRow.workspaceId,
usedCredentialTable: true,
}
}
return { accountId: credentialId, usedCredentialTable: false }
}
/**
* Safely inserts an account record, handling duplicate constraint violations gracefully.
* If a duplicate is detected (unique constraint violation), logs a warning and returns success.
@@ -52,10 +84,16 @@ export async function safeAccountInsert(
* Get a credential by ID and verify it belongs to the user
*/
export async function getCredential(requestId: string, credentialId: string, userId: string) {
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
logger.warn(`[${requestId}] Credential is not an OAuth credential`)
return undefined
}
const credentials = await db
.select()
.from(account)
.where(and(eq(account.id, credentialId), eq(account.userId, userId)))
.where(and(eq(account.id, resolved.accountId), eq(account.userId, userId)))
.limit(1)
if (!credentials.length) {
@@ -63,7 +101,10 @@ export async function getCredential(requestId: string, credentialId: string, use
return undefined
}
return credentials[0]
return {
...credentials[0],
resolvedCredentialId: resolved.accountId,
}
}
export async function getOAuthToken(userId: string, providerId: string): Promise<string | null> {
@@ -238,7 +279,9 @@ export async function refreshAccessTokenIfNeeded(
}
// Update the token in the database
await db.update(account).set(updateData).where(eq(account.id, credentialId))
const resolvedCredentialId =
(credential as { resolvedCredentialId?: string }).resolvedCredentialId ?? credentialId
await db.update(account).set(updateData).where(eq(account.id, resolvedCredentialId))
logger.info(`[${requestId}] Successfully refreshed access token for credential`)
return refreshedToken.accessToken
@@ -274,6 +317,8 @@ export async function refreshTokenIfNeeded(
credential: any,
credentialId: string
): Promise<{ accessToken: string; refreshed: boolean }> {
const resolvedCredentialId = credential.resolvedCredentialId ?? credentialId
// Decide if we should refresh: token missing OR expired
const accessTokenExpiresAt = credential.accessTokenExpiresAt
const refreshTokenExpiresAt = credential.refreshTokenExpiresAt
@@ -334,7 +379,7 @@ export async function refreshTokenIfNeeded(
updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry()
}
await db.update(account).set(updateData).where(eq(account.id, credentialId))
await db.update(account).set(updateData).where(eq(account.id, resolvedCredentialId))
logger.info(`[${requestId}] Successfully refreshed access token`)
return { accessToken: refreshedToken, refreshed: true }
@@ -343,7 +388,7 @@ export async function refreshTokenIfNeeded(
`[${requestId}] Refresh attempt failed, checking if another concurrent request succeeded`
)
const freshCredential = await getCredential(requestId, credentialId, credential.userId)
const freshCredential = await getCredential(requestId, resolvedCredentialId, credential.userId)
if (freshCredential?.accessToken) {
const freshExpiresAt = freshCredential.accessTokenExpiresAt
const stillValid = !freshExpiresAt || freshExpiresAt > new Date()

View File

@@ -6,7 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { validateEnum, validatePathSegment } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
@@ -57,24 +57,41 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: itemIdValidation.error }, { status: 400 })
}
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
if (resolved.workspaceId) {
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
const perm = await getUserEntityPermissions(
session.user.id,
'workspace',
resolved.workspaceId
)
if (perm === null) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
const credentials = await db
.select()
.from(account)
.where(eq(account.id, resolved.accountId))
.limit(1)
if (!credentials.length) {
logger.warn(`[${requestId}] Credential not found`, { credentialId })
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const credential = credentials[0]
const accountRow = credentials[0]
if (credential.userId !== session.user.id) {
logger.warn(`[${requestId}] Unauthorized credential access attempt`, {
credentialUserId: credential.userId,
requestUserId: session.user.id,
})
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
const accessToken = await refreshAccessTokenIfNeeded(
resolved.accountId,
accountRow.userId,
requestId
)
if (!accessToken) {
logger.error(`[${requestId}] Failed to obtain valid access token`)

View File

@@ -5,7 +5,7 @@ import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
@@ -47,27 +47,41 @@ export async function GET(request: NextRequest) {
)
}
// Get the credential from the database
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
if (resolved.workspaceId) {
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
const perm = await getUserEntityPermissions(
session.user.id,
'workspace',
resolved.workspaceId
)
if (perm === null) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
const credentials = await db
.select()
.from(account)
.where(eq(account.id, resolved.accountId))
.limit(1)
if (!credentials.length) {
logger.warn(`[${requestId}] Credential not found`, { credentialId })
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const credential = credentials[0]
const accountRow = credentials[0]
// Check if the credential belongs to the user
if (credential.userId !== session.user.id) {
logger.warn(`[${requestId}] Unauthorized credential access attempt`, {
credentialUserId: credential.userId,
requestUserId: session.user.id,
})
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
// Refresh access token if needed
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
const accessToken = await refreshAccessTokenIfNeeded(
resolved.accountId,
accountRow.userId,
requestId
)
if (!accessToken) {
logger.error(`[${requestId}] Failed to obtain valid access token`)

View File

@@ -48,16 +48,21 @@ export async function GET(request: NextRequest) {
const shopData = await shopResponse.json()
const shopInfo = shopData.shop
const stableAccountId = shopInfo.id?.toString() || shopDomain
const existing = await db.query.account.findFirst({
where: and(eq(account.userId, session.user.id), eq(account.providerId, 'shopify')),
where: and(
eq(account.userId, session.user.id),
eq(account.providerId, 'shopify'),
eq(account.accountId, stableAccountId)
),
})
const now = new Date()
const accountData = {
accessToken: accessToken,
accountId: shopInfo.id?.toString() || shopDomain,
accountId: stableAccountId,
scope: scope || '',
updatedAt: now,
idToken: shopDomain,

View File

@@ -52,7 +52,11 @@ export async function POST(request: NextRequest) {
const trelloUser = await userResponse.json()
const existing = await db.query.account.findFirst({
where: and(eq(account.userId, session.user.id), eq(account.providerId, 'trello')),
where: and(
eq(account.userId, session.user.id),
eq(account.providerId, 'trello'),
eq(account.accountId, trelloUser.id)
),
})
const now = new Date()

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

@@ -0,0 +1,226 @@
import { db } from '@sim/db'
import { credential, credentialMember, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('CredentialMembersAPI')
interface RouteContext {
params: Promise<{ id: string }>
}
async function requireWorkspaceAdminMembership(credentialId: string, userId: string) {
const [cred] = await db
.select({ id: credential.id, workspaceId: credential.workspaceId })
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
if (!cred) return null
const perm = await getUserEntityPermissions(userId, 'workspace', cred.workspaceId)
if (perm === null) return null
const [membership] = await db
.select({ role: credentialMember.role, status: credentialMember.status })
.from(credentialMember)
.where(
and(eq(credentialMember.credentialId, credentialId), eq(credentialMember.userId, userId))
)
.limit(1)
if (!membership || membership.status !== 'active' || membership.role !== 'admin') {
return null
}
return membership
}
export async function GET(_request: NextRequest, context: RouteContext) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: credentialId } = await context.params
const [cred] = await db
.select({ id: credential.id, workspaceId: credential.workspaceId })
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
if (!cred) {
return NextResponse.json({ members: [] }, { status: 200 })
}
const callerPerm = await getUserEntityPermissions(
session.user.id,
'workspace',
cred.workspaceId
)
if (callerPerm === null) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const members = await db
.select({
id: credentialMember.id,
userId: credentialMember.userId,
role: credentialMember.role,
status: credentialMember.status,
joinedAt: credentialMember.joinedAt,
userName: user.name,
userEmail: user.email,
})
.from(credentialMember)
.innerJoin(user, eq(credentialMember.userId, user.id))
.where(eq(credentialMember.credentialId, credentialId))
return NextResponse.json({ members })
} catch (error) {
logger.error('Failed to fetch credential members', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
const addMemberSchema = z.object({
userId: z.string().min(1),
role: z.enum(['admin', 'member']).default('member'),
})
export async function POST(request: NextRequest, context: RouteContext) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: credentialId } = await context.params
const admin = await requireWorkspaceAdminMembership(credentialId, session.user.id)
if (!admin) {
return NextResponse.json({ error: 'Admin access required' }, { status: 403 })
}
const body = await request.json()
const parsed = addMemberSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
}
const { userId, role } = parsed.data
const now = new Date()
const [existing] = await db
.select({ id: credentialMember.id, status: credentialMember.status })
.from(credentialMember)
.where(
and(eq(credentialMember.credentialId, credentialId), eq(credentialMember.userId, userId))
)
.limit(1)
if (existing) {
await db
.update(credentialMember)
.set({ role, status: 'active', updatedAt: now })
.where(eq(credentialMember.id, existing.id))
return NextResponse.json({ success: true })
}
await db.insert(credentialMember).values({
id: crypto.randomUUID(),
credentialId,
userId,
role,
status: 'active',
joinedAt: now,
invitedBy: session.user.id,
createdAt: now,
updatedAt: now,
})
return NextResponse.json({ success: true }, { status: 201 })
} catch (error) {
logger.error('Failed to add credential member', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function DELETE(request: NextRequest, context: RouteContext) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: credentialId } = await context.params
const targetUserId = new URL(request.url).searchParams.get('userId')
if (!targetUserId) {
return NextResponse.json({ error: 'userId query parameter required' }, { status: 400 })
}
const admin = await requireWorkspaceAdminMembership(credentialId, session.user.id)
if (!admin) {
return NextResponse.json({ error: 'Admin access required' }, { status: 403 })
}
const [target] = await db
.select({
id: credentialMember.id,
role: credentialMember.role,
})
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, credentialId),
eq(credentialMember.userId, targetUserId),
eq(credentialMember.status, 'active')
)
)
.limit(1)
if (!target) {
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
}
const revoked = await db.transaction(async (tx) => {
if (target.role === 'admin') {
const activeAdmins = await tx
.select({ id: credentialMember.id })
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, credentialId),
eq(credentialMember.role, 'admin'),
eq(credentialMember.status, 'active')
)
)
if (activeAdmins.length <= 1) {
return false
}
}
await tx
.update(credentialMember)
.set({ status: 'revoked', updatedAt: new Date() })
.where(eq(credentialMember.id, target.id))
return true
})
if (!revoked) {
return NextResponse.json({ error: 'Cannot remove the last admin' }, { status: 400 })
}
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Failed to remove credential member', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,251 @@
import { db } from '@sim/db'
import { credential, credentialMember, environment, workspaceEnvironment } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { getCredentialActorContext } from '@/lib/credentials/access'
import {
syncPersonalEnvCredentialsForUser,
syncWorkspaceEnvCredentials,
} from '@/lib/credentials/environment'
const logger = createLogger('CredentialByIdAPI')
const updateCredentialSchema = z
.object({
displayName: z.string().trim().min(1).max(255).optional(),
description: z.string().trim().max(500).nullish(),
})
.strict()
.refine((data) => data.displayName !== undefined || data.description !== undefined, {
message: 'At least one field must be provided',
path: ['displayName'],
})
async function getCredentialResponse(credentialId: string, userId: string) {
const [row] = await db
.select({
id: credential.id,
workspaceId: credential.workspaceId,
type: credential.type,
displayName: credential.displayName,
description: credential.description,
providerId: credential.providerId,
accountId: credential.accountId,
envKey: credential.envKey,
envOwnerUserId: credential.envOwnerUserId,
createdBy: credential.createdBy,
createdAt: credential.createdAt,
updatedAt: credential.updatedAt,
role: credentialMember.role,
status: credentialMember.status,
})
.from(credential)
.innerJoin(
credentialMember,
and(eq(credentialMember.credentialId, credential.id), eq(credentialMember.userId, userId))
)
.where(eq(credential.id, credentialId))
.limit(1)
return row ?? null
}
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
try {
const access = await getCredentialActorContext(id, session.user.id)
if (!access.credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
if (!access.hasWorkspaceAccess || !access.member) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const row = await getCredentialResponse(id, session.user.id)
return NextResponse.json({ credential: row }, { status: 200 })
} catch (error) {
logger.error('Failed to fetch credential', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
try {
const parseResult = updateCredentialSchema.safeParse(await request.json())
if (!parseResult.success) {
return NextResponse.json({ error: parseResult.error.errors[0]?.message }, { status: 400 })
}
const access = await getCredentialActorContext(id, session.user.id)
if (!access.credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
if (!access.hasWorkspaceAccess || !access.isAdmin) {
return NextResponse.json({ error: 'Credential admin permission required' }, { status: 403 })
}
const updates: Record<string, unknown> = {}
if (parseResult.data.description !== undefined) {
updates.description = parseResult.data.description ?? null
}
if (parseResult.data.displayName !== undefined && access.credential.type === 'oauth') {
updates.displayName = parseResult.data.displayName
}
if (Object.keys(updates).length === 0) {
if (access.credential.type === 'oauth') {
return NextResponse.json(
{
error: 'No updatable fields provided.',
},
{ status: 400 }
)
}
return NextResponse.json(
{
error:
'Environment credentials cannot be updated via this endpoint. Use the environment value editor in credentials settings.',
},
{ status: 400 }
)
}
updates.updatedAt = new Date()
await db.update(credential).set(updates).where(eq(credential.id, id))
const row = await getCredentialResponse(id, session.user.id)
return NextResponse.json({ credential: row }, { status: 200 })
} catch (error) {
logger.error('Failed to update credential', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
try {
const access = await getCredentialActorContext(id, session.user.id)
if (!access.credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
if (!access.hasWorkspaceAccess || !access.isAdmin) {
return NextResponse.json({ error: 'Credential admin permission required' }, { status: 403 })
}
if (access.credential.type === 'env_personal' && access.credential.envKey) {
const ownerUserId = access.credential.envOwnerUserId
if (!ownerUserId) {
return NextResponse.json({ error: 'Invalid personal secret owner' }, { status: 400 })
}
const [personalRow] = await db
.select({ variables: environment.variables })
.from(environment)
.where(eq(environment.userId, ownerUserId))
.limit(1)
const current = ((personalRow?.variables as Record<string, string> | null) ?? {}) as Record<
string,
string
>
if (access.credential.envKey in current) {
delete current[access.credential.envKey]
}
await db
.insert(environment)
.values({
id: ownerUserId,
userId: ownerUserId,
variables: current,
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: [environment.userId],
set: { variables: current, updatedAt: new Date() },
})
await syncPersonalEnvCredentialsForUser({
userId: ownerUserId,
envKeys: Object.keys(current),
})
return NextResponse.json({ success: true }, { status: 200 })
}
if (access.credential.type === 'env_workspace' && access.credential.envKey) {
const [workspaceRow] = await db
.select({
id: workspaceEnvironment.id,
createdAt: workspaceEnvironment.createdAt,
variables: workspaceEnvironment.variables,
})
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, access.credential.workspaceId))
.limit(1)
const current = ((workspaceRow?.variables as Record<string, string> | null) ?? {}) as Record<
string,
string
>
if (access.credential.envKey in current) {
delete current[access.credential.envKey]
}
await db
.insert(workspaceEnvironment)
.values({
id: workspaceRow?.id || crypto.randomUUID(),
workspaceId: access.credential.workspaceId,
variables: current,
createdAt: workspaceRow?.createdAt || new Date(),
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: [workspaceEnvironment.workspaceId],
set: { variables: current, updatedAt: new Date() },
})
await syncWorkspaceEnvCredentials({
workspaceId: access.credential.workspaceId,
envKeys: Object.keys(current),
actingUserId: session.user.id,
})
return NextResponse.json({ success: true }, { status: 200 })
}
await db.delete(credential).where(eq(credential.id, id))
return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
logger.error('Failed to delete credential', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,116 @@
import { db } from '@sim/db'
import { credential, credentialMember, pendingCredentialDraft } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, lt } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('CredentialDraftAPI')
const DRAFT_TTL_MS = 15 * 60 * 1000
const createDraftSchema = z.object({
workspaceId: z.string().min(1),
providerId: z.string().min(1),
displayName: z.string().min(1),
description: z.string().trim().max(500).optional(),
credentialId: z.string().min(1).optional(),
})
export async function POST(request: Request) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const parsed = createDraftSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
}
const { workspaceId, providerId, displayName, description, credentialId } = parsed.data
const userId = session.user.id
const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId)
if (!workspaceAccess.canWrite) {
return NextResponse.json({ error: 'Write permission required' }, { status: 403 })
}
if (credentialId) {
const [membership] = await db
.select({ role: credentialMember.role, status: credentialMember.status })
.from(credentialMember)
.innerJoin(credential, eq(credential.id, credentialMember.credentialId))
.where(
and(
eq(credentialMember.credentialId, credentialId),
eq(credentialMember.userId, userId),
eq(credentialMember.status, 'active'),
eq(credentialMember.role, 'admin'),
eq(credential.workspaceId, workspaceId)
)
)
.limit(1)
if (!membership) {
return NextResponse.json(
{ error: 'Admin access required on the target credential' },
{ status: 403 }
)
}
}
const now = new Date()
await db
.delete(pendingCredentialDraft)
.where(
and(eq(pendingCredentialDraft.userId, userId), lt(pendingCredentialDraft.expiresAt, now))
)
await db
.insert(pendingCredentialDraft)
.values({
id: crypto.randomUUID(),
userId,
workspaceId,
providerId,
displayName,
description: description || null,
credentialId: credentialId || null,
expiresAt: new Date(now.getTime() + DRAFT_TTL_MS),
createdAt: now,
})
.onConflictDoUpdate({
target: [
pendingCredentialDraft.userId,
pendingCredentialDraft.providerId,
pendingCredentialDraft.workspaceId,
],
set: {
displayName,
description: description || null,
credentialId: credentialId || null,
expiresAt: new Date(now.getTime() + DRAFT_TTL_MS),
createdAt: now,
},
})
logger.info('Credential draft saved', {
userId,
workspaceId,
providerId,
displayName,
credentialId: credentialId || null,
})
return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
logger.error('Failed to save credential draft', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,120 @@
import { db } from '@sim/db'
import { credential, credentialMember } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
const logger = createLogger('CredentialMembershipsAPI')
const leaveCredentialSchema = z.object({
credentialId: z.string().min(1),
})
export async function GET() {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const memberships = await db
.select({
membershipId: credentialMember.id,
credentialId: credential.id,
workspaceId: credential.workspaceId,
type: credential.type,
displayName: credential.displayName,
providerId: credential.providerId,
role: credentialMember.role,
status: credentialMember.status,
joinedAt: credentialMember.joinedAt,
})
.from(credentialMember)
.innerJoin(credential, eq(credentialMember.credentialId, credential.id))
.where(eq(credentialMember.userId, session.user.id))
return NextResponse.json({ memberships }, { status: 200 })
} catch (error) {
logger.error('Failed to list credential memberships', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function DELETE(request: NextRequest) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const parseResult = leaveCredentialSchema.safeParse({
credentialId: new URL(request.url).searchParams.get('credentialId'),
})
if (!parseResult.success) {
return NextResponse.json({ error: parseResult.error.errors[0]?.message }, { status: 400 })
}
const { credentialId } = parseResult.data
const [membership] = await db
.select()
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, credentialId),
eq(credentialMember.userId, session.user.id)
)
)
.limit(1)
if (!membership) {
return NextResponse.json({ error: 'Membership not found' }, { status: 404 })
}
if (membership.status !== 'active') {
return NextResponse.json({ success: true }, { status: 200 })
}
const revoked = await db.transaction(async (tx) => {
if (membership.role === 'admin') {
const activeAdmins = await tx
.select({ id: credentialMember.id })
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, credentialId),
eq(credentialMember.role, 'admin'),
eq(credentialMember.status, 'active')
)
)
if (activeAdmins.length <= 1) {
return false
}
}
await tx
.update(credentialMember)
.set({
status: 'revoked',
updatedAt: new Date(),
})
.where(eq(credentialMember.id, membership.id))
return true
})
if (!revoked) {
return NextResponse.json(
{ error: 'Cannot leave credential as the last active admin' },
{ status: 400 }
)
}
return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
logger.error('Failed to leave credential', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,520 @@
import { db } from '@sim/db'
import { account, credential, credentialMember, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { getWorkspaceMemberUserIds } from '@/lib/credentials/environment'
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
import { getServiceConfigByProviderId } from '@/lib/oauth'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
import { isValidEnvVarName } from '@/executor/constants'
const logger = createLogger('CredentialsAPI')
const credentialTypeSchema = z.enum(['oauth', 'env_workspace', 'env_personal'])
function normalizeEnvKeyInput(raw: string): string {
const trimmed = raw.trim()
const wrappedMatch = /^\{\{\s*([A-Za-z0-9_]+)\s*\}\}$/.exec(trimmed)
return wrappedMatch ? wrappedMatch[1] : trimmed
}
const listCredentialsSchema = z.object({
workspaceId: z.string().uuid('Workspace ID must be a valid UUID'),
type: credentialTypeSchema.optional(),
providerId: z.string().optional(),
credentialId: z.string().optional(),
})
const createCredentialSchema = z
.object({
workspaceId: z.string().uuid('Workspace ID must be a valid UUID'),
type: credentialTypeSchema,
displayName: z.string().trim().min(1).max(255).optional(),
description: z.string().trim().max(500).optional(),
providerId: z.string().trim().min(1).optional(),
accountId: z.string().trim().min(1).optional(),
envKey: z.string().trim().min(1).optional(),
envOwnerUserId: z.string().trim().min(1).optional(),
})
.superRefine((data, ctx) => {
if (data.type === 'oauth') {
if (!data.accountId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'accountId is required for oauth credentials',
path: ['accountId'],
})
}
if (!data.providerId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'providerId is required for oauth credentials',
path: ['providerId'],
})
}
if (!data.displayName) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'displayName is required for oauth credentials',
path: ['displayName'],
})
}
return
}
const normalizedEnvKey = data.envKey ? normalizeEnvKeyInput(data.envKey) : ''
if (!normalizedEnvKey) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'envKey is required for env credentials',
path: ['envKey'],
})
return
}
if (!isValidEnvVarName(normalizedEnvKey)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'envKey must contain only letters, numbers, and underscores',
path: ['envKey'],
})
}
})
interface ExistingCredentialSourceParams {
workspaceId: string
type: 'oauth' | 'env_workspace' | 'env_personal'
accountId?: string | null
envKey?: string | null
envOwnerUserId?: string | null
}
async function findExistingCredentialBySource(params: ExistingCredentialSourceParams) {
const { workspaceId, type, accountId, envKey, envOwnerUserId } = params
if (type === 'oauth' && accountId) {
const [row] = await db
.select()
.from(credential)
.where(
and(
eq(credential.workspaceId, workspaceId),
eq(credential.type, 'oauth'),
eq(credential.accountId, accountId)
)
)
.limit(1)
return row ?? null
}
if (type === 'env_workspace' && envKey) {
const [row] = await db
.select()
.from(credential)
.where(
and(
eq(credential.workspaceId, workspaceId),
eq(credential.type, 'env_workspace'),
eq(credential.envKey, envKey)
)
)
.limit(1)
return row ?? null
}
if (type === 'env_personal' && envKey && envOwnerUserId) {
const [row] = await db
.select()
.from(credential)
.where(
and(
eq(credential.workspaceId, workspaceId),
eq(credential.type, 'env_personal'),
eq(credential.envKey, envKey),
eq(credential.envOwnerUserId, envOwnerUserId)
)
)
.limit(1)
return row ?? null
}
return null
}
export async function GET(request: NextRequest) {
const requestId = generateRequestId()
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const { searchParams } = new URL(request.url)
const rawWorkspaceId = searchParams.get('workspaceId')
const rawType = searchParams.get('type')
const rawProviderId = searchParams.get('providerId')
const rawCredentialId = searchParams.get('credentialId')
const parseResult = listCredentialsSchema.safeParse({
workspaceId: rawWorkspaceId?.trim(),
type: rawType?.trim() || undefined,
providerId: rawProviderId?.trim() || undefined,
credentialId: rawCredentialId?.trim() || undefined,
})
if (!parseResult.success) {
logger.warn(`[${requestId}] Invalid credential list request`, {
workspaceId: rawWorkspaceId,
type: rawType,
providerId: rawProviderId,
errors: parseResult.error.errors,
})
return NextResponse.json({ error: parseResult.error.errors[0]?.message }, { status: 400 })
}
const { workspaceId, type, providerId, credentialId: lookupCredentialId } = parseResult.data
const workspaceAccess = await checkWorkspaceAccess(workspaceId, session.user.id)
if (!workspaceAccess.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
if (lookupCredentialId) {
let [row] = await db
.select({
id: credential.id,
displayName: credential.displayName,
type: credential.type,
providerId: credential.providerId,
})
.from(credential)
.where(and(eq(credential.id, lookupCredentialId), eq(credential.workspaceId, workspaceId)))
.limit(1)
if (!row) {
;[row] = await db
.select({
id: credential.id,
displayName: credential.displayName,
type: credential.type,
providerId: credential.providerId,
})
.from(credential)
.where(
and(
eq(credential.accountId, lookupCredentialId),
eq(credential.workspaceId, workspaceId)
)
)
.limit(1)
}
return NextResponse.json({ credential: row ?? null })
}
if (!type || type === 'oauth') {
await syncWorkspaceOAuthCredentialsForUser({ workspaceId, userId: session.user.id })
}
const whereClauses = [eq(credential.workspaceId, workspaceId)]
if (type) {
whereClauses.push(eq(credential.type, type))
}
if (providerId) {
whereClauses.push(eq(credential.providerId, providerId))
}
const credentials = await db
.select({
id: credential.id,
workspaceId: credential.workspaceId,
type: credential.type,
displayName: credential.displayName,
description: credential.description,
providerId: credential.providerId,
accountId: credential.accountId,
envKey: credential.envKey,
envOwnerUserId: credential.envOwnerUserId,
createdBy: credential.createdBy,
createdAt: credential.createdAt,
updatedAt: credential.updatedAt,
role: credentialMember.role,
})
.from(credential)
.innerJoin(
credentialMember,
and(
eq(credentialMember.credentialId, credential.id),
eq(credentialMember.userId, session.user.id),
eq(credentialMember.status, 'active')
)
)
.where(and(...whereClauses))
return NextResponse.json({ credentials })
} catch (error) {
logger.error(`[${requestId}] Failed to list credentials`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const parseResult = createCredentialSchema.safeParse(body)
if (!parseResult.success) {
return NextResponse.json({ error: parseResult.error.errors[0]?.message }, { status: 400 })
}
const {
workspaceId,
type,
displayName,
description,
providerId,
accountId,
envKey,
envOwnerUserId,
} = parseResult.data
const workspaceAccess = await checkWorkspaceAccess(workspaceId, session.user.id)
if (!workspaceAccess.canWrite) {
return NextResponse.json({ error: 'Write permission required' }, { status: 403 })
}
let resolvedDisplayName = displayName?.trim() ?? ''
const resolvedDescription = description?.trim() || null
let resolvedProviderId: string | null = providerId ?? null
let resolvedAccountId: string | null = accountId ?? null
const resolvedEnvKey: string | null = envKey ? normalizeEnvKeyInput(envKey) : null
let resolvedEnvOwnerUserId: string | null = null
if (type === 'oauth') {
const [accountRow] = await db
.select({
id: account.id,
userId: account.userId,
providerId: account.providerId,
accountId: account.accountId,
})
.from(account)
.where(eq(account.id, accountId!))
.limit(1)
if (!accountRow) {
return NextResponse.json({ error: 'OAuth account not found' }, { status: 404 })
}
if (accountRow.userId !== session.user.id) {
return NextResponse.json(
{ error: 'Only account owners can create oauth credentials for an account' },
{ status: 403 }
)
}
if (providerId !== accountRow.providerId) {
return NextResponse.json(
{ error: 'providerId does not match the selected OAuth account' },
{ status: 400 }
)
}
if (!resolvedDisplayName) {
resolvedDisplayName =
getServiceConfigByProviderId(accountRow.providerId)?.name || accountRow.providerId
}
} else if (type === 'env_personal') {
resolvedEnvOwnerUserId = envOwnerUserId ?? session.user.id
if (resolvedEnvOwnerUserId !== session.user.id) {
return NextResponse.json(
{ error: 'Only the current user can create personal env credentials for themselves' },
{ status: 403 }
)
}
resolvedProviderId = null
resolvedAccountId = null
resolvedDisplayName = resolvedEnvKey || ''
} else {
resolvedProviderId = null
resolvedAccountId = null
resolvedEnvOwnerUserId = null
resolvedDisplayName = resolvedEnvKey || ''
}
if (!resolvedDisplayName) {
return NextResponse.json({ error: 'Display name is required' }, { status: 400 })
}
const existingCredential = await findExistingCredentialBySource({
workspaceId,
type,
accountId: resolvedAccountId,
envKey: resolvedEnvKey,
envOwnerUserId: resolvedEnvOwnerUserId,
})
if (existingCredential) {
const [membership] = await db
.select({
id: credentialMember.id,
status: credentialMember.status,
role: credentialMember.role,
})
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, existingCredential.id),
eq(credentialMember.userId, session.user.id)
)
)
.limit(1)
if (!membership || membership.status !== 'active') {
return NextResponse.json(
{ error: 'A credential with this source already exists in this workspace' },
{ status: 409 }
)
}
const canUpdateExistingCredential = membership.role === 'admin'
const shouldUpdateDisplayName =
type === 'oauth' &&
resolvedDisplayName &&
resolvedDisplayName !== existingCredential.displayName
const shouldUpdateDescription =
typeof description !== 'undefined' &&
(existingCredential.description ?? null) !== resolvedDescription
if (canUpdateExistingCredential && (shouldUpdateDisplayName || shouldUpdateDescription)) {
await db
.update(credential)
.set({
...(shouldUpdateDisplayName ? { displayName: resolvedDisplayName } : {}),
...(shouldUpdateDescription ? { description: resolvedDescription } : {}),
updatedAt: new Date(),
})
.where(eq(credential.id, existingCredential.id))
const [updatedCredential] = await db
.select()
.from(credential)
.where(eq(credential.id, existingCredential.id))
.limit(1)
return NextResponse.json(
{ credential: updatedCredential ?? existingCredential },
{ status: 200 }
)
}
return NextResponse.json({ credential: existingCredential }, { status: 200 })
}
const now = new Date()
const credentialId = crypto.randomUUID()
const [workspaceRow] = await db
.select({ ownerId: workspace.ownerId })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
await db.transaction(async (tx) => {
await tx.insert(credential).values({
id: credentialId,
workspaceId,
type,
displayName: resolvedDisplayName,
description: resolvedDescription,
providerId: resolvedProviderId,
accountId: resolvedAccountId,
envKey: resolvedEnvKey,
envOwnerUserId: resolvedEnvOwnerUserId,
createdBy: session.user.id,
createdAt: now,
updatedAt: now,
})
if (type === 'env_workspace' && workspaceRow?.ownerId) {
const workspaceUserIds = await getWorkspaceMemberUserIds(workspaceId)
if (workspaceUserIds.length > 0) {
for (const memberUserId of workspaceUserIds) {
await tx.insert(credentialMember).values({
id: crypto.randomUUID(),
credentialId,
userId: memberUserId,
role:
memberUserId === workspaceRow.ownerId || memberUserId === session.user.id
? 'admin'
: 'member',
status: 'active',
joinedAt: now,
invitedBy: session.user.id,
createdAt: now,
updatedAt: now,
})
}
}
} else {
await tx.insert(credentialMember).values({
id: crypto.randomUUID(),
credentialId,
userId: session.user.id,
role: 'admin',
status: 'active',
joinedAt: now,
invitedBy: session.user.id,
createdAt: now,
updatedAt: now,
})
}
})
const [created] = await db
.select()
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
return NextResponse.json({ credential: created }, { status: 201 })
} catch (error: any) {
if (error?.code === '23505') {
return NextResponse.json(
{ error: 'A credential with this source already exists' },
{ status: 409 }
)
}
if (error?.code === '23503') {
return NextResponse.json(
{ error: 'Invalid credential reference or membership target' },
{ status: 400 }
)
}
if (error?.code === '23514') {
return NextResponse.json(
{ error: 'Credential source data failed validation checks' },
{ status: 400 }
)
}
logger.error(`[${requestId}] Credential create failure details`, {
code: error?.code,
detail: error?.detail,
constraint: error?.constraint,
table: error?.table,
message: error?.message,
})
logger.error(`[${requestId}] Failed to create credential`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -4,18 +4,27 @@ import { createLogger } from '@sim/logger'
import { and, eq, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { verifyCronAuth } from '@/lib/auth/internal'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
const logger = createLogger('TeamsSubscriptionRenewal')
async function getCredentialOwnerUserId(credentialId: string): Promise<string | null> {
async function getCredentialOwner(
credentialId: string
): Promise<{ userId: string; accountId: string } | null> {
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
logger.error(`Failed to resolve OAuth account for credential ${credentialId}`)
return null
}
const [credentialRecord] = await db
.select({ userId: account.userId })
.from(account)
.where(eq(account.id, credentialId))
.where(eq(account.id, resolved.accountId))
.limit(1)
return credentialRecord?.userId ?? null
return credentialRecord
? { userId: credentialRecord.userId, accountId: resolved.accountId }
: null
}
/**
@@ -88,8 +97,8 @@ export async function GET(request: NextRequest) {
continue
}
const credentialOwnerUserId = await getCredentialOwnerUserId(credentialId)
if (!credentialOwnerUserId) {
const credentialOwner = await getCredentialOwner(credentialId)
if (!credentialOwner) {
logger.error(`Credential owner not found for credential ${credentialId}`)
totalFailed++
continue
@@ -97,8 +106,8 @@ export async function GET(request: NextRequest) {
// Get fresh access token
const accessToken = await refreshAccessTokenIfNeeded(
credentialId,
credentialOwnerUserId,
credentialOwner.accountId,
credentialOwner.userId,
`renewal-${webhook.id}`
)

View File

@@ -8,6 +8,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
import { generateRequestId } from '@/lib/core/utils/request'
import { syncPersonalEnvCredentialsForUser } from '@/lib/credentials/environment'
import type { EnvironmentVariable } from '@/stores/settings/environment'
const logger = createLogger('EnvironmentAPI')
@@ -54,6 +55,11 @@ export async function POST(req: NextRequest) {
},
})
await syncPersonalEnvCredentialsForUser({
userId: session.user.id,
envKeys: Object.keys(variables),
})
recordAudit({
actorId: session.user.id,
actorName: session.user.name,

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

@@ -83,7 +83,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

View File

@@ -11,6 +11,7 @@ import {
user,
userStats,
type WorkspaceInvitationStatus,
workspaceEnvironment,
workspaceInvitation,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
@@ -24,6 +25,7 @@ import { hasAccessControlAccess } from '@/lib/billing'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
import { sendEmail } from '@/lib/messaging/email/mailer'
const logger = createLogger('OrganizationInvitation')
@@ -496,6 +498,34 @@ export async function PUT(
}
})
if (status === 'accepted') {
const acceptedWsInvitations = await db
.select({ workspaceId: workspaceInvitation.workspaceId })
.from(workspaceInvitation)
.where(
and(
eq(workspaceInvitation.orgInvitationId, invitationId),
eq(workspaceInvitation.status, 'accepted' as WorkspaceInvitationStatus)
)
)
for (const wsInv of acceptedWsInvitations) {
const [wsEnvRow] = await db
.select({ variables: workspaceEnvironment.variables })
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, wsInv.workspaceId))
.limit(1)
const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record<string, string>) || {})
if (wsEnvKeys.length > 0) {
await syncWorkspaceEnvCredentials({
workspaceId: wsInv.workspaceId,
envKeys: wsEnvKeys,
actingUserId: session.user.id,
})
}
}
}
// Handle Pro subscription cancellation after transaction commits
if (personalProToCancel) {
try {
@@ -568,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

@@ -6,7 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { refreshTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
import type { StreamingExecution } from '@/executor/types'
import { executeProviderRequest } from '@/providers'
@@ -360,15 +360,20 @@ function sanitizeObject(obj: any): any {
async function resolveVertexCredential(requestId: string, credentialId: string): Promise<string> {
logger.info(`[${requestId}] Resolving Vertex AI credential: ${credentialId}`)
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
throw new Error(`Vertex AI credential not found: ${credentialId}`)
}
const credential = await db.query.account.findFirst({
where: eq(account.id, credentialId),
where: eq(account.id, resolved.accountId),
})
if (!credential) {
throw new Error(`Vertex AI credential not found: ${credentialId}`)
}
const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId)
const { accessToken } = await refreshTokenIfNeeded(requestId, credential, resolved.accountId)
if (!accessToken) {
throw new Error('Failed to get Vertex AI access token')

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

@@ -1,12 +1,12 @@
import { db } from '@sim/db'
import { account } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
@@ -41,10 +41,27 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: labelIdValidation.error }, { status: 400 })
}
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
if (resolved.workspaceId) {
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
const perm = await getUserEntityPermissions(
session.user.id,
'workspace',
resolved.workspaceId
)
if (perm === null) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
const credentials = await db
.select()
.from(account)
.where(and(eq(account.id, credentialId), eq(account.userId, session.user.id)))
.where(eq(account.id, resolved.accountId))
.limit(1)
if (!credentials.length) {
@@ -52,13 +69,17 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const credential = credentials[0]
const accountRow = credentials[0]
logger.info(
`[${requestId}] Using credential: ${credential.id}, provider: ${credential.providerId}`
`[${requestId}] Using credential: ${accountRow.id}, provider: ${accountRow.providerId}`
)
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
const accessToken = await refreshAccessTokenIfNeeded(
resolved.accountId,
accountRow.userId,
requestId
)
if (!accessToken) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })

View File

@@ -1,12 +1,12 @@
import { db } from '@sim/db'
import { account } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('GmailLabelsAPI')
@@ -45,27 +45,45 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
}
let credentials = await db
.select()
.from(account)
.where(and(eq(account.id, credentialId), eq(account.userId, session.user.id)))
.limit(1)
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
if (!credentials.length) {
credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (!credentials.length) {
logger.warn(`[${requestId}] Credential not found`)
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
if (resolved.workspaceId) {
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
const perm = await getUserEntityPermissions(
session.user.id,
'workspace',
resolved.workspaceId
)
if (perm === null) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
const credential = credentials[0]
const credentials = await db
.select()
.from(account)
.where(eq(account.id, resolved.accountId))
.limit(1)
if (!credentials.length) {
logger.warn(`[${requestId}] Credential not found`)
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const accountRow = credentials[0]
logger.info(
`[${requestId}] Using credential: ${credential.id}, provider: ${credential.providerId}`
`[${requestId}] Using credential: ${accountRow.id}, provider: ${accountRow.providerId}`
)
const accessToken = await refreshAccessTokenIfNeeded(credentialId, credential.userId, requestId)
const accessToken = await refreshAccessTokenIfNeeded(
resolved.accountId,
accountRow.userId,
requestId
)
if (!accessToken) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })

View File

@@ -6,7 +6,7 @@ import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
import type { PlannerTask } from '@/tools/microsoft_planner/types'
const logger = createLogger('MicrosoftPlannerTasksAPI')
@@ -42,24 +42,41 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: planIdValidation.error }, { status: 400 })
}
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
if (resolved.workspaceId) {
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
const perm = await getUserEntityPermissions(
session.user.id,
'workspace',
resolved.workspaceId
)
if (perm === null) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
const credentials = await db
.select()
.from(account)
.where(eq(account.id, resolved.accountId))
.limit(1)
if (!credentials.length) {
logger.warn(`[${requestId}] Credential not found`, { credentialId })
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const credential = credentials[0]
const accountRow = credentials[0]
if (credential.userId !== session.user.id) {
logger.warn(`[${requestId}] Unauthorized credential access attempt`, {
credentialUserId: credential.userId,
requestUserId: session.user.id,
})
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
const accessToken = await refreshAccessTokenIfNeeded(
resolved.accountId,
accountRow.userId,
requestId
)
if (!accessToken) {
logger.error(`[${requestId}] Failed to obtain valid access token`)

View File

@@ -6,7 +6,7 @@ import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
@@ -45,22 +45,40 @@ export async function GET(request: NextRequest) {
logger.info(`[${requestId}] Fetching credential`, { credentialId })
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
if (resolved.workspaceId) {
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
const perm = await getUserEntityPermissions(
session.user.id,
'workspace',
resolved.workspaceId
)
if (perm === null) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
const credentials = await db
.select()
.from(account)
.where(eq(account.id, resolved.accountId))
.limit(1)
if (!credentials.length) {
logger.warn(`[${requestId}] Credential not found`, { credentialId })
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const credential = credentials[0]
if (credential.userId !== session.user.id) {
logger.warn(`[${requestId}] Unauthorized credential access attempt`, {
credentialUserId: credential.userId,
requestUserId: session.user.id,
})
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
const accountRow = credentials[0]
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
const accessToken = await refreshAccessTokenIfNeeded(
resolved.accountId,
accountRow.userId,
requestId
)
if (!accessToken) {
logger.error(`[${requestId}] Failed to obtain valid access token`)
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })

View File

@@ -6,7 +6,7 @@ import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
@@ -34,17 +34,39 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: fileIdValidation.error }, { status: 400 })
}
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
if (resolved.workspaceId) {
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
const perm = await getUserEntityPermissions(
session.user.id,
'workspace',
resolved.workspaceId
)
if (perm === null) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
const credentials = await db
.select()
.from(account)
.where(eq(account.id, resolved.accountId))
.limit(1)
if (!credentials.length) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const credential = credentials[0]
if (credential.userId !== session.user.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
const accountRow = credentials[0]
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
const accessToken = await refreshAccessTokenIfNeeded(
resolved.accountId,
accountRow.userId,
requestId
)
if (!accessToken) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
}

View File

@@ -6,7 +6,7 @@ import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
@@ -40,17 +40,39 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
}
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
if (resolved.workspaceId) {
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
const perm = await getUserEntityPermissions(
session.user.id,
'workspace',
resolved.workspaceId
)
if (perm === null) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
const credentials = await db
.select()
.from(account)
.where(eq(account.id, resolved.accountId))
.limit(1)
if (!credentials.length) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const credential = credentials[0]
if (credential.userId !== session.user.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
const accountRow = credentials[0]
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
const accessToken = await refreshAccessTokenIfNeeded(
resolved.accountId,
accountRow.userId,
requestId
)
if (!accessToken) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
}

View File

@@ -6,7 +6,7 @@ import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
@@ -44,7 +44,28 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const creds = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
if (resolved.workspaceId) {
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
const perm = await getUserEntityPermissions(
session!.user!.id,
'workspace',
resolved.workspaceId
)
if (perm === null) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
const creds = await db
.select()
.from(account)
.where(eq(account.id, resolved.accountId))
.limit(1)
if (!creds.length) {
logger.warn('Credential not found', { credentialId })
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
@@ -52,7 +73,7 @@ export async function GET(request: Request) {
const credentialOwnerUserId = creds[0].userId
const accessToken = await refreshAccessTokenIfNeeded(
credentialId,
resolved.accountId,
credentialOwnerUserId,
generateRequestId()
)

View File

@@ -6,7 +6,7 @@ import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
@@ -34,17 +34,39 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: siteIdValidation.error }, { status: 400 })
}
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
if (resolved.workspaceId) {
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
const perm = await getUserEntityPermissions(
session.user.id,
'workspace',
resolved.workspaceId
)
if (perm === null) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
const credentials = await db
.select()
.from(account)
.where(eq(account.id, resolved.accountId))
.limit(1)
if (!credentials.length) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const credential = credentials[0]
if (credential.userId !== session.user.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
const accountRow = credentials[0]
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
const accessToken = await refreshAccessTokenIfNeeded(
resolved.accountId,
accountRow.userId,
requestId
)
if (!accessToken) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
}

View File

@@ -6,7 +6,7 @@ import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
import type { SharepointSite } from '@/tools/sharepoint/types'
export const dynamic = 'force-dynamic'
@@ -39,17 +39,39 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
}
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
if (resolved.workspaceId) {
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
const perm = await getUserEntityPermissions(
session.user.id,
'workspace',
resolved.workspaceId
)
if (perm === null) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
const credentials = await db
.select()
.from(account)
.where(eq(account.id, resolved.accountId))
.limit(1)
if (!credentials.length) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const credential = credentials[0]
if (credential.userId !== session.user.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
const accountRow = credentials[0]
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
const accessToken = await refreshAccessTokenIfNeeded(
resolved.accountId,
accountRow.userId,
requestId
)
if (!accessToken) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
}

View File

@@ -6,7 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { validateEnum, validatePathSegment } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
@@ -64,24 +64,41 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
}
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
if (resolved.workspaceId) {
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
const perm = await getUserEntityPermissions(
session.user.id,
'workspace',
resolved.workspaceId
)
if (perm === null) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
const credentials = await db
.select()
.from(account)
.where(eq(account.id, resolved.accountId))
.limit(1)
if (!credentials.length) {
logger.warn(`[${requestId}] Credential not found`, { credentialId })
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const credential = credentials[0]
const accountRow = credentials[0]
if (credential.userId !== session.user.id) {
logger.warn(`[${requestId}] Unauthorized credential access attempt`, {
credentialUserId: credential.userId,
requestUserId: session.user.id,
})
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
const accessToken = await refreshAccessTokenIfNeeded(
resolved.accountId,
accountRow.userId,
requestId
)
if (!accessToken) {
logger.error(`[${requestId}] Failed to obtain valid access token`)

View File

@@ -6,7 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { validateEnum, validatePathSegment } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
@@ -64,24 +64,41 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: typeValidation.error }, { status: 400 })
}
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
if (resolved.workspaceId) {
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
const perm = await getUserEntityPermissions(
session.user.id,
'workspace',
resolved.workspaceId
)
if (perm === null) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
const credentials = await db
.select()
.from(account)
.where(eq(account.id, resolved.accountId))
.limit(1)
if (!credentials.length) {
logger.warn(`[${requestId}] Credential not found`, { credentialId })
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const credential = credentials[0]
const accountRow = credentials[0]
if (credential.userId !== session.user.id) {
logger.warn(`[${requestId}] Unauthorized credential access attempt`, {
credentialUserId: credential.userId,
requestUserId: session.user.id,
})
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
const accessToken = await refreshAccessTokenIfNeeded(
resolved.accountId,
accountRow.userId,
requestId
)
if (!accessToken) {
logger.error(`[${requestId}] Failed to obtain valid access token`)

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

@@ -25,6 +25,7 @@ import { db } from '@sim/db'
import { permissions, user, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { revokeWorkspaceCredentialMemberships } from '@/lib/credentials/access'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
@@ -215,6 +216,8 @@ export const DELETE = withAdminAuthParams<RouteParams>(async (_, context) => {
await db.delete(permissions).where(eq(permissions.id, memberId))
await revokeWorkspaceCredentialMemberships(workspaceId, existingMember.userId)
logger.info(`Admin API: Removed member ${memberId} from workspace ${workspaceId}`, {
userId: existingMember.userId,
})

View File

@@ -32,9 +32,10 @@
import crypto from 'crypto'
import { db } from '@sim/db'
import { permissions, user, workspace } from '@sim/db/schema'
import { permissions, user, workspace, workspaceEnvironment } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, count, eq } from 'drizzle-orm'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
@@ -232,6 +233,20 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
permissionId,
})
const [wsEnvRow] = await db
.select({ variables: workspaceEnvironment.variables })
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
.limit(1)
const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record<string, string>) || {})
if (wsEnvKeys.length > 0) {
await syncWorkspaceEnvCredentials({
workspaceId,
envKeys: wsEnvKeys,
actingUserId: body.userId,
})
}
return singleResponse({
id: permissionId,
workspaceId,

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

@@ -1,9 +1,8 @@
import { db, workflowDeploymentVersion } from '@sim/db'
import { createLogger } from '@sim/logger'
import { and, desc, eq } from 'drizzle-orm'
import type { NextRequest, NextResponse } from 'next/server'
import { verifyInternalToken } from '@/lib/auth/internal'
import { generateRequestId } from '@/lib/core/utils/request'
import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils'
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
@@ -22,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
@@ -39,25 +36,24 @@ 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}`)
}
const [active] = await db
.select({ state: workflowDeploymentVersion.state })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, id),
eq(workflowDeploymentVersion.isActive, true)
)
)
.orderBy(desc(workflowDeploymentVersion.createdAt))
.limit(1)
let deployedState = null
try {
const data = await loadDeployedWorkflowState(id)
deployedState = {
blocks: data.blocks,
edges: data.edges,
loops: data.loops,
parallels: data.parallels,
variables: data.variables,
}
} catch (error) {
logger.warn(`[${requestId}] Failed to load deployed state for workflow ${id}`, { error })
deployedState = null
}
const response = createSuccessResponse({
deployedState: active?.state || null,
})
const response = createSuccessResponse({ deployedState })
return addNoCacheHeaders(response)
} catch (error: any) {
logger.error(`[${requestId}] Error fetching deployed state: ${id}`, error)

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)`)
@@ -537,7 +606,9 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
useDraftState: shouldUseDraftState,
startTime: new Date().toISOString(),
isClientSession,
enforceCredentialAccess: useAuthenticatedUserAsActor,
workflowStateOverride: effectiveWorkflowStateOverride,
callChain,
}
const executionVariables = cachedWorkflowData?.variables ?? workflow.variables ?? {}
@@ -626,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,
@@ -650,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 })
}
})
}
}
}
@@ -906,7 +969,9 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
useDraftState: shouldUseDraftState,
startTime: new Date().toISOString(),
isClientSession,
enforceCredentialAccess: useAuthenticatedUserAsActor,
workflowStateOverride: effectiveWorkflowStateOverride,
callChain,
}
const sseExecutionVariables = cachedWorkflowData?.variables ?? workflow.variables ?? {}
@@ -1053,15 +1118,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

@@ -1,13 +1,15 @@
import { db } from '@sim/db'
import { environment, workspaceEnvironment } from '@sim/db/schema'
import { workspaceEnvironment } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
import { encryptSecret } from '@/lib/core/security/encryption'
import { generateRequestId } from '@/lib/core/utils/request'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkspaceEnvironmentAPI')
@@ -45,44 +47,10 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Workspace env (encrypted)
const wsEnvRow = await db
.select()
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
.limit(1)
const wsEncrypted: Record<string, string> = (wsEnvRow[0]?.variables as any) || {}
// Personal env (encrypted)
const personalRow = await db
.select()
.from(environment)
.where(eq(environment.userId, userId))
.limit(1)
const personalEncrypted: Record<string, string> = (personalRow[0]?.variables as any) || {}
// Decrypt both for UI
const decryptAll = async (src: Record<string, string>) => {
const out: Record<string, string> = {}
for (const [k, v] of Object.entries(src)) {
try {
const { decrypted } = await decryptSecret(v)
out[k] = decrypted
} catch {
out[k] = ''
}
}
return out
}
const [workspaceDecrypted, personalDecrypted] = await Promise.all([
decryptAll(wsEncrypted),
decryptAll(personalEncrypted),
])
const conflicts = Object.keys(personalDecrypted).filter((k) => k in workspaceDecrypted)
const { workspaceDecrypted, personalDecrypted, conflicts } = await getPersonalAndWorkspaceEnv(
userId,
workspaceId
)
return NextResponse.json(
{
@@ -157,6 +125,12 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
set: { variables: merged, updatedAt: new Date() },
})
await syncWorkspaceEnvCredentials({
workspaceId,
envKeys: Object.keys(merged),
actingUserId: userId,
})
recordAudit({
workspaceId,
actorId: userId,
@@ -166,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,
})
@@ -236,6 +210,12 @@ export async function DELETE(
set: { variables: current, updatedAt: new Date() },
})
await syncWorkspaceEnvCredentials({
workspaceId,
envKeys: Object.keys(current),
actingUserId: userId,
})
return NextResponse.json({ success: true })
} catch (error: any) {
logger.error(`[${requestId}] Workspace env DELETE error`, error)

View File

@@ -1,12 +1,13 @@
import crypto from 'crypto'
import { db } from '@sim/db'
import { permissions, workspace } 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'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
import {
getUsersWithPermissions,
hasWorkspaceAdminAccess,
@@ -131,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
@@ -155,6 +171,20 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
}
})
const [wsEnvRow] = await db
.select({ variables: workspaceEnvironment.variables })
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
.limit(1)
const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record<string, string>) || {})
if (wsEnvKeys.length > 0) {
await syncWorkspaceEnvCredentials({
workspaceId,
envKeys: wsEnvKeys,
actingUserId: session.user.id,
})
}
const updatedUsers = await getUsersWithPermissions(workspaceId)
for (const update of body.updates) {
@@ -167,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

@@ -8,15 +8,27 @@ const mockHasWorkspaceAdminAccess = vi.fn()
let dbSelectResults: any[] = []
let dbSelectCallIndex = 0
const mockDbSelect = vi.fn().mockImplementation(() => ({
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
then: vi.fn().mockImplementation((callback: (rows: any[]) => any) => {
const result = dbSelectResults[dbSelectCallIndex] || []
dbSelectCallIndex++
return Promise.resolve(callback ? callback(result) : result)
}),
}))
const mockDbSelect = vi.fn().mockImplementation(() => {
const makeThen = () =>
vi.fn().mockImplementation((callback: (rows: any[]) => any) => {
const result = dbSelectResults[dbSelectCallIndex] || []
dbSelectCallIndex++
return Promise.resolve(callback ? callback(result) : result)
})
const makeLimit = () =>
vi.fn().mockImplementation(() => {
const result = dbSelectResults[dbSelectCallIndex] || []
dbSelectCallIndex++
return Promise.resolve(result)
})
const chain: any = {}
chain.from = vi.fn().mockReturnValue(chain)
chain.where = vi.fn().mockReturnValue(chain)
chain.limit = makeLimit()
chain.then = makeThen()
return chain
})
const mockDbInsert = vi.fn().mockImplementation(() => ({
values: vi.fn().mockResolvedValue(undefined),
@@ -53,6 +65,10 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({
mockHasWorkspaceAdminAccess(userId, workspaceId),
}))
vi.mock('@/lib/credentials/environment', () => ({
syncWorkspaceEnvCredentials: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@sim/logger', () => loggerMock)
vi.mock('@/lib/audit/log', () => auditMock)
@@ -97,6 +113,10 @@ vi.mock('@sim/db/schema', () => ({
userId: 'userId',
permissionType: 'permissionType',
},
workspaceEnvironment: {
workspaceId: 'workspaceId',
variables: 'variables',
},
}))
vi.mock('drizzle-orm', () => ({
@@ -209,6 +229,7 @@ describe('Workspace Invitation [invitationId] API Route', () => {
[mockWorkspace],
[{ ...mockUser, email: 'invited@example.com' }],
[],
[],
]
const request = new NextRequest(
@@ -462,6 +483,7 @@ describe('Workspace Invitation [invitationId] API Route', () => {
[mockWorkspace],
[{ ...mockUser, email: 'invited@example.com' }],
[],
[],
]
const request2 = new NextRequest(

View File

@@ -6,6 +6,7 @@ import {
user,
type WorkspaceInvitationStatus,
workspace,
workspaceEnvironment,
workspaceInvitation,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
@@ -15,6 +16,7 @@ import { WorkspaceInvitationEmail } from '@/components/emails'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
import { sendEmail } from '@/lib/messaging/email/mailer'
import { getFromEmailAddress } from '@/lib/messaging/email/utils'
import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
@@ -163,6 +165,20 @@ export async function GET(
.where(eq(workspaceInvitation.id, invitation.id))
})
const [wsEnvRow] = await db
.select({ variables: workspaceEnvironment.variables })
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, invitation.workspaceId))
.limit(1)
const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record<string, string>) || {})
if (wsEnvKeys.length > 0) {
await syncWorkspaceEnvCredentials({
workspaceId: invitation.workspaceId,
envKeys: wsEnvKeys,
actingUserId: session.user.id,
})
}
recordAudit({
workspaceId: invitation.workspaceId,
actorId: session.user.id,
@@ -173,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,
})
@@ -239,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

@@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { revokeWorkspaceCredentialMemberships } from '@/lib/credentials/access'
import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkspaceMemberAPI')
@@ -102,6 +103,8 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
)
)
await revokeWorkspaceCredentialMemberships(workspaceId, userId)
recordAudit({
workspaceId,
actorId: session.user.id,

View File

@@ -21,6 +21,7 @@ interface WorkflowDeploymentInfo {
endpoint: string
exampleCommand: string
needsRedeployment: boolean
isPublicApi?: boolean
}
interface ApiDeployProps {
@@ -107,12 +108,12 @@ export function ApiDeploy({
if (!info) return ''
const endpoint = getBaseEndpoint()
const payload = getPayloadObject()
const isPublic = info.isPublicApi
switch (language) {
case 'curl':
return `curl -X POST \\
-H "X-API-Key: $SIM_API_KEY" \\
-H "Content-Type: application/json" \\
${isPublic ? '' : ' -H "X-API-Key: $SIM_API_KEY" \\\n'} -H "Content-Type: application/json" \\
-d '${JSON.stringify(payload)}' \\
${endpoint}`
@@ -123,8 +124,7 @@ import requests
response = requests.post(
"${endpoint}",
headers={
"X-API-Key": os.environ.get("SIM_API_KEY"),
"Content-Type": "application/json"
${isPublic ? '' : ' "X-API-Key": os.environ.get("SIM_API_KEY"),\n'} "Content-Type": "application/json"
},
json=${JSON.stringify(payload, null, 4).replace(/\n/g, '\n ')}
)
@@ -135,8 +135,7 @@ print(response.json())`
return `const response = await fetch("${endpoint}", {
method: "POST",
headers: {
"X-API-Key": process.env.SIM_API_KEY,
"Content-Type": "application/json"
${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json"
},
body: JSON.stringify(${JSON.stringify(payload)})
});
@@ -148,8 +147,7 @@ console.log(data);`
return `const response = await fetch("${endpoint}", {
method: "POST",
headers: {
"X-API-Key": process.env.SIM_API_KEY,
"Content-Type": "application/json"
${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json"
},
body: JSON.stringify(${JSON.stringify(payload)})
});
@@ -166,12 +164,12 @@ console.log(data);`
if (!info) return ''
const endpoint = getBaseEndpoint()
const payload = getStreamPayloadObject()
const isPublic = info.isPublicApi
switch (language) {
case 'curl':
return `curl -X POST \\
-H "X-API-Key: $SIM_API_KEY" \\
-H "Content-Type: application/json" \\
${isPublic ? '' : ' -H "X-API-Key: $SIM_API_KEY" \\\n'} -H "Content-Type: application/json" \\
-d '${JSON.stringify(payload)}' \\
${endpoint}`
@@ -182,8 +180,7 @@ import requests
response = requests.post(
"${endpoint}",
headers={
"X-API-Key": os.environ.get("SIM_API_KEY"),
"Content-Type": "application/json"
${isPublic ? '' : ' "X-API-Key": os.environ.get("SIM_API_KEY"),\n'} "Content-Type": "application/json"
},
json=${JSON.stringify(payload, null, 4).replace(/\n/g, '\n ')},
stream=True
@@ -197,8 +194,7 @@ for line in response.iter_lines():
return `const response = await fetch("${endpoint}", {
method: "POST",
headers: {
"X-API-Key": process.env.SIM_API_KEY,
"Content-Type": "application/json"
${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json"
},
body: JSON.stringify(${JSON.stringify(payload)})
});
@@ -216,8 +212,7 @@ while (true) {
return `const response = await fetch("${endpoint}", {
method: "POST",
headers: {
"X-API-Key": process.env.SIM_API_KEY,
"Content-Type": "application/json"
${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json"
},
body: JSON.stringify(${JSON.stringify(payload)})
});
@@ -241,14 +236,14 @@ while (true) {
const endpoint = getBaseEndpoint()
const baseUrl = endpoint.split('/api/workflows/')[0]
const payload = getPayloadObject()
const isPublic = info.isPublicApi
switch (asyncExampleType) {
case 'execute':
switch (language) {
case 'curl':
return `curl -X POST \\
-H "X-API-Key: $SIM_API_KEY" \\
-H "Content-Type: application/json" \\
${isPublic ? '' : ' -H "X-API-Key: $SIM_API_KEY" \\\n'} -H "Content-Type: application/json" \\
-H "X-Execution-Mode: async" \\
-d '${JSON.stringify(payload)}' \\
${endpoint}`
@@ -260,8 +255,7 @@ import requests
response = requests.post(
"${endpoint}",
headers={
"X-API-Key": os.environ.get("SIM_API_KEY"),
"Content-Type": "application/json",
${isPublic ? '' : ' "X-API-Key": os.environ.get("SIM_API_KEY"),\n'} "Content-Type": "application/json",
"X-Execution-Mode": "async"
},
json=${JSON.stringify(payload, null, 4).replace(/\n/g, '\n ')}
@@ -274,8 +268,7 @@ print(job) # Contains jobId and executionId`
return `const response = await fetch("${endpoint}", {
method: "POST",
headers: {
"X-API-Key": process.env.SIM_API_KEY,
"Content-Type": "application/json",
${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json",
"X-Execution-Mode": "async"
},
body: JSON.stringify(${JSON.stringify(payload)})
@@ -288,8 +281,7 @@ console.log(job); // Contains jobId and executionId`
return `const response = await fetch("${endpoint}", {
method: "POST",
headers: {
"X-API-Key": process.env.SIM_API_KEY,
"Content-Type": "application/json",
${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json",
"X-Execution-Mode": "async"
},
body: JSON.stringify(${JSON.stringify(payload)})

View File

@@ -4,6 +4,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Badge,
Button,
ButtonGroup,
ButtonGroupItem,
Input,
Label,
Modal,
@@ -16,6 +18,8 @@ import {
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import type { InputFormatField } from '@/lib/workflows/types'
import { useDeploymentInfo, useUpdatePublicApi } from '@/hooks/queries/deployments'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -40,13 +44,20 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
)
const updateWorkflow = useWorkflowRegistry((state) => state.updateWorkflow)
const { data: deploymentData } = useDeploymentInfo(workflowId, { enabled: open })
const updatePublicApiMutation = useUpdatePublicApi()
const { isPublicApiDisabled } = usePermissionConfig()
const [description, setDescription] = useState('')
const [paramDescriptions, setParamDescriptions] = useState<Record<string, string>>({})
const [accessMode, setAccessMode] = useState<'api_key' | 'public'>('api_key')
const [isSaving, setIsSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
const initialDescriptionRef = useRef('')
const initialParamDescriptionsRef = useRef<Record<string, string>>({})
const initialAccessModeRef = useRef<'api_key' | 'public'>('api_key')
const starterBlockId = useMemo(() => {
for (const [blockId, block] of Object.entries(blocks)) {
@@ -71,6 +82,8 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
return normalizeInputFormatValue(blockValue) as NormalizedField[]
}, [starterBlockId, subBlockValues, blocks])
const accessModeInitializedRef = useRef(false)
useEffect(() => {
if (open) {
const normalizedDesc = workflowMetadata?.description?.toLowerCase().trim()
@@ -92,11 +105,24 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
}
setParamDescriptions(descriptions)
initialParamDescriptionsRef.current = { ...descriptions }
setSaveError(null)
accessModeInitializedRef.current = false
}
}, [open, workflowMetadata, inputFormat])
useEffect(() => {
if (open && deploymentData && !accessModeInitializedRef.current) {
const initialAccess = deploymentData.isPublicApi ? 'public' : 'api_key'
setAccessMode(initialAccess)
initialAccessModeRef.current = initialAccess
accessModeInitializedRef.current = true
}
}, [open, deploymentData])
const hasChanges = useMemo(() => {
if (description.trim() !== initialDescriptionRef.current.trim()) return true
if (accessMode !== initialAccessModeRef.current) return true
for (const field of inputFormat) {
const currentValue = (paramDescriptions[field.name] || '').trim()
@@ -105,7 +131,7 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
}
return false
}, [description, paramDescriptions, inputFormat])
}, [description, paramDescriptions, inputFormat, accessMode])
const handleParamDescriptionChange = (fieldName: string, value: string) => {
setParamDescriptions((prev) => ({
@@ -126,6 +152,7 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
setShowUnsavedChangesAlert(false)
setDescription(initialDescriptionRef.current)
setParamDescriptions({ ...initialParamDescriptionsRef.current })
setAccessMode(initialAccessModeRef.current)
onOpenChange(false)
}, [onOpenChange])
@@ -138,7 +165,15 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
}
setIsSaving(true)
setSaveError(null)
try {
if (accessMode !== initialAccessModeRef.current) {
await updatePublicApiMutation.mutateAsync({
workflowId,
isPublicApi: accessMode === 'public',
})
}
if (description.trim() !== (workflowMetadata?.description || '')) {
updateWorkflow(workflowId, { description: description.trim() || 'New workflow' })
}
@@ -152,6 +187,9 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
}
onOpenChange(false)
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to update access settings'
setSaveError(message)
} finally {
setIsSaving(false)
}
@@ -165,6 +203,8 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
paramDescriptions,
setValue,
onOpenChange,
accessMode,
updatePublicApiMutation,
])
return (
@@ -187,6 +227,26 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
/>
</div>
{!isPublicApiDisabled && (
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Access
</Label>
<ButtonGroup
value={accessMode}
onValueChange={(val) => setAccessMode(val as 'api_key' | 'public')}
>
<ButtonGroupItem value='api_key'>API Key</ButtonGroupItem>
<ButtonGroupItem value='public'>Public</ButtonGroupItem>
</ButtonGroup>
<p className='mt-1 text-[12px] text-[var(--text-secondary)]'>
{accessMode === 'public'
? 'Anyone can call this API without authentication. You will be billed for all usage.'
: 'Requires a valid API key to call this endpoint.'}
</p>
</div>
)}
{inputFormat.length > 0 && (
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
@@ -227,6 +287,9 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
)}
</ModalBody>
<ModalFooter>
{saveError && (
<p className='mr-auto text-[12px] text-[var(--text-error)]'>{saveError}</p>
)}
<Button variant='default' onClick={handleCloseAttempt} disabled={isSaving}>
Cancel
</Button>

View File

@@ -70,6 +70,7 @@ interface WorkflowDeploymentInfoUI {
endpoint: string
exampleCommand: string
needsRedeployment: boolean
isPublicApi: boolean
}
type TabView = 'general' | 'api' | 'chat' | 'template' | 'mcp' | 'form' | 'a2a'
@@ -117,7 +118,7 @@ export function DeployModal({
const [isApiInfoModalOpen, setIsApiInfoModalOpen] = useState(false)
const userPermissions = useUserPermissionsContext()
const canManageWorkspaceKeys = userPermissions.canAdmin
const { config: permissionConfig } = usePermissionConfig()
const { config: permissionConfig, isPublicApiDisabled } = usePermissionConfig()
const { data: apiKeysData, isLoading: isLoadingKeys } = useApiKeys(workflowWorkspaceId || '')
const { data: workspaceSettingsData, isLoading: isLoadingSettings } = useWorkspaceSettings(
workflowWorkspaceId || ''
@@ -214,9 +215,11 @@ export function DeployModal({
endpoint,
exampleCommand: `curl -X POST -H "X-API-Key: ${placeholderKey}" -H "Content-Type: application/json"${inputFormatExample} ${endpoint}`,
needsRedeployment: deploymentInfoData.needsRedeployment,
isPublicApi: isPublicApiDisabled ? false : (deploymentInfoData.isPublicApi ?? false),
}
}, [
deploymentInfoData,
isPublicApiDisabled,
workflowId,
selectedStreamingOutputs,
getInputFormatExample,

View File

@@ -30,6 +30,7 @@ export interface OAuthRequiredModalProps {
requiredScopes?: string[]
serviceId: string
newScopes?: string[]
onConnect?: () => Promise<void> | void
}
const SCOPE_DESCRIPTIONS: Record<string, string> = {
@@ -300,6 +301,16 @@ const SCOPE_DESCRIPTIONS: Record<string, string> = {
'user-follow-modify': 'Follow and unfollow artists and users',
'user-read-playback-position': 'View playback position in podcasts',
'ugc-image-upload': 'Upload images to Spotify playlists',
// Attio
'record_permission:read-write': 'Read and write CRM records',
'object_configuration:read-write': 'Read and manage object schemas',
'list_configuration:read-write': 'Read and manage list configurations',
'list_entry:read-write': 'Read and write list entries',
'note:read-write': 'Read and write notes',
'task:read-write': 'Read and write tasks',
'comment:read-write': 'Read and write comments and threads',
'user_management:read': 'View workspace members',
'webhook:read-write': 'Manage webhooks',
}
function getScopeDescription(scope: string): string {
@@ -314,6 +325,7 @@ export function OAuthRequiredModal({
requiredScopes = [],
serviceId,
newScopes = [],
onConnect,
}: OAuthRequiredModalProps) {
const [error, setError] = useState<string | null>(null)
const { baseProvider } = parseProvider(provider)
@@ -359,6 +371,12 @@ export function OAuthRequiredModal({
setError(null)
try {
if (onConnect) {
await onConnect()
onClose()
return
}
const providerId = getProviderIdFromServiceId(serviceId)
logger.info('Linking OAuth2:', {

View File

@@ -1,12 +1,13 @@
'use client'
import { createElement, useCallback, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { ExternalLink, Users } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button, Combobox } from '@/components/emcn/components'
import { getSubscriptionStatus } from '@/lib/billing/client'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { getPollingProviderFromOAuth } from '@/lib/credential-sets/providers'
import { writePendingCredentialCreateRequest } from '@/lib/credentials/client-state'
import {
getCanonicalScopesForProvider,
getProviderIdFromServiceId,
@@ -18,15 +19,15 @@ import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types'
import { CREDENTIAL, CREDENTIAL_SET } from '@/executor/constants'
import { CREDENTIAL_SET } from '@/executor/constants'
import { useCredentialSets } from '@/hooks/queries/credential-sets'
import { useOAuthCredentialDetail, useOAuthCredentials } from '@/hooks/queries/oauth-credentials'
import { useOAuthCredentials } from '@/hooks/queries/oauth-credentials'
import { useOrganizations } from '@/hooks/queries/organization'
import { useSubscriptionData } from '@/hooks/queries/subscription'
import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers'
import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('CredentialSelector')
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
interface CredentialSelectorProps {
@@ -46,6 +47,8 @@ export function CredentialSelector({
previewValue,
previewContextValues,
}: CredentialSelectorProps) {
const params = useParams()
const workspaceId = (params?.workspaceId as string) || ''
const [showOAuthModal, setShowOAuthModal] = useState(false)
const [editingValue, setEditingValue] = useState('')
const [isEditing, setIsEditing] = useState(false)
@@ -96,64 +99,63 @@ export function CredentialSelector({
data: credentials = [],
isFetching: credentialsLoading,
refetch: refetchCredentials,
} = useOAuthCredentials(effectiveProviderId, Boolean(effectiveProviderId))
} = useOAuthCredentials(effectiveProviderId, {
enabled: Boolean(effectiveProviderId),
workspaceId,
workflowId: activeWorkflowId || undefined,
})
const selectedCredential = useMemo(
() => credentials.find((cred) => cred.id === selectedId),
[credentials, selectedId]
)
const shouldFetchForeignMeta =
Boolean(selectedId) &&
!selectedCredential &&
Boolean(activeWorkflowId) &&
Boolean(effectiveProviderId)
const { data: foreignCredentials = [], isFetching: foreignMetaLoading } =
useOAuthCredentialDetail(
shouldFetchForeignMeta ? selectedId : undefined,
activeWorkflowId || undefined,
shouldFetchForeignMeta
)
const hasForeignMeta = foreignCredentials.length > 0
const isForeign = Boolean(selectedId && !selectedCredential && hasForeignMeta)
const selectedCredentialSet = useMemo(
() => credentialSets.find((cs) => cs.id === selectedCredentialSetId),
[credentialSets, selectedCredentialSetId]
)
const isForeignCredentialSet = Boolean(isCredentialSetSelected && !selectedCredentialSet)
const [inaccessibleCredentialName, setInaccessibleCredentialName] = useState<string | null>(null)
useEffect(() => {
if (!selectedId || selectedCredential || credentialsLoading || !workspaceId) {
setInaccessibleCredentialName(null)
return
}
setInaccessibleCredentialName(null)
let cancelled = false
;(async () => {
try {
const response = await fetch(
`/api/credentials?workspaceId=${encodeURIComponent(workspaceId)}&credentialId=${encodeURIComponent(selectedId)}`
)
if (!response.ok || cancelled) return
const data = await response.json()
if (!cancelled && data.credential?.displayName) {
setInaccessibleCredentialName(data.credential.displayName)
}
} catch {
// Ignore fetch errors
}
})()
return () => {
cancelled = true
}
}, [selectedId, selectedCredential, credentialsLoading, workspaceId])
const resolvedLabel = useMemo(() => {
if (selectedCredentialSet) return selectedCredentialSet.name
if (isForeignCredentialSet) return CREDENTIAL.FOREIGN_LABEL
if (selectedCredential) return selectedCredential.name
if (isForeign) return CREDENTIAL.FOREIGN_LABEL
if (inaccessibleCredentialName) return inaccessibleCredentialName
return ''
}, [selectedCredentialSet, isForeignCredentialSet, selectedCredential, isForeign])
}, [selectedCredentialSet, selectedCredential, inaccessibleCredentialName])
const displayValue = isEditing ? editingValue : resolvedLabel
const invalidSelection =
!isPreview &&
Boolean(selectedId) &&
!selectedCredential &&
!hasForeignMeta &&
!credentialsLoading &&
!foreignMetaLoading
useEffect(() => {
if (!invalidSelection) return
logger.info('Clearing invalid credential selection - credential was disconnected', {
selectedId,
provider: effectiveProviderId,
})
setStoreValue('')
}, [invalidSelection, selectedId, effectiveProviderId, setStoreValue])
useCredentialRefreshTriggers(refetchCredentials)
useCredentialRefreshTriggers(refetchCredentials, effectiveProviderId, workspaceId)
const handleOpenChange = useCallback(
(isOpen: boolean) => {
@@ -195,8 +197,18 @@ export function CredentialSelector({
)
const handleAddCredential = useCallback(() => {
setShowOAuthModal(true)
}, [])
writePendingCredentialCreateRequest({
workspaceId,
type: 'oauth',
providerId: effectiveProviderId,
displayName: '',
serviceId,
requiredScopes: getCanonicalScopesForProvider(effectiveProviderId),
requestedAt: Date.now(),
})
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'credentials' } }))
}, [workspaceId, effectiveProviderId, serviceId])
const getProviderIcon = useCallback((providerName: OAuthProvider) => {
const { baseProvider } = parseProvider(providerName)
@@ -251,23 +263,18 @@ export function CredentialSelector({
label: cred.name,
value: cred.id,
}))
credentialItems.push({
label:
credentials.length > 0
? `Connect another ${getProviderName(provider)} account`
: `Connect ${getProviderName(provider)} account`,
value: '__connect_account__',
})
if (credentialItems.length > 0) {
groups.push({
section: 'Personal Credential',
items: credentialItems,
})
} else {
groups.push({
section: 'Personal Credential',
items: [
{
label: `Connect ${getProviderName(provider)} account`,
value: '__connect_account__',
},
],
})
}
groups.push({
section: 'Personal Credential',
items: credentialItems,
})
return { comboboxOptions: [], comboboxGroups: groups }
}
@@ -277,12 +284,13 @@ export function CredentialSelector({
value: cred.id,
}))
if (credentials.length === 0) {
options.push({
label: `Connect ${getProviderName(provider)} account`,
value: '__connect_account__',
})
}
options.push({
label:
credentials.length > 0
? `Connect another ${getProviderName(provider)} account`
: `Connect ${getProviderName(provider)} account`,
value: '__connect_account__',
})
return { comboboxOptions: options, comboboxGroups: undefined }
}, [
@@ -368,7 +376,7 @@ export function CredentialSelector({
}
disabled={effectiveDisabled}
editable={true}
filterOptions={!isForeign && !isForeignCredentialSet}
filterOptions={true}
isLoading={credentialsLoading}
overlayContent={overlayContent}
className={selectedId || isCredentialSetSelected ? 'pl-[28px]' : ''}
@@ -380,15 +388,13 @@ export function CredentialSelector({
<span className='mr-[6px] inline-block h-[6px] w-[6px] rounded-[2px] bg-amber-500' />
Additional permissions required
</div>
{!isForeign && (
<Button
variant='active'
onClick={() => setShowOAuthModal(true)}
className='w-full px-[8px] py-[4px] font-medium text-[12px]'
>
Update access
</Button>
)}
<Button
variant='active'
onClick={() => setShowOAuthModal(true)}
className='w-full px-[8px] py-[4px] font-medium text-[12px]'
>
Update access
</Button>
</div>
)}
@@ -406,31 +412,3 @@ export function CredentialSelector({
</div>
)
}
function useCredentialRefreshTriggers(refetchCredentials: () => Promise<unknown>) {
useEffect(() => {
const refresh = () => {
void refetchCredentials()
}
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
refresh()
}
}
const handlePageShow = (event: Event) => {
if ('persisted' in event && (event as PageTransitionEvent).persisted) {
refresh()
}
}
document.addEventListener('visibilitychange', handleVisibilityChange)
window.addEventListener('pageshow', handlePageShow)
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
window.removeEventListener('pageshow', handlePageShow)
}
}, [refetchCredentials])
}

View File

@@ -9,6 +9,7 @@ import {
PopoverSection,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { writePendingCredentialCreateRequest } from '@/lib/credentials/client-state'
import {
usePersonalEnvironment,
useWorkspaceEnvironment,
@@ -168,7 +169,15 @@ export const EnvVarDropdown: React.FC<EnvVarDropdownProps> = ({
}, [searchTerm])
const openEnvironmentSettings = () => {
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'environment' } }))
if (workspaceId) {
writePendingCredentialCreateRequest({
workspaceId,
type: 'env_personal',
envKey: searchTerm.trim(),
requestedAt: Date.now(),
})
}
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'credentials' } }))
onClose?.()
}
@@ -302,7 +311,7 @@ export const EnvVarDropdown: React.FC<EnvVarDropdownProps> = ({
}}
>
<Plus className='h-3 w-3' />
<span>Create environment variable</span>
<span>Create Secret</span>
</PopoverItem>
</PopoverScrollArea>
) : (

View File

@@ -7,7 +7,6 @@ import { getProviderIdFromServiceId } from '@/lib/oauth'
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import { getBlock } from '@/blocks/registry'
@@ -125,8 +124,6 @@ export function FileSelectorInput({
const serviceId = subBlock.serviceId || ''
const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId])
const { isForeignCredential } = useForeignCredential(effectiveProviderId, normalizedCredentialId)
const selectorResolution = useMemo<SelectorResolution | null>(() => {
return resolveSelectorForSubBlock(subBlock, {
workflowId: workflowIdFromUrl,
@@ -168,7 +165,6 @@ export function FileSelectorInput({
const disabledReason =
finalDisabled ||
isForeignCredential ||
missingCredential ||
missingDomain ||
missingProject ||

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