Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion apps/sim/app/api/billing/update-cost/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { recordUsage } from '@/lib/billing/core/usage-log'
import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
import { checkInternalApiKey } from '@/lib/copilot/request/http'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import { type AtomicClaimResult, billingIdempotency } from '@/lib/core/idempotency/service'
import { generateRequestId } from '@/lib/core/utils/request'

const logger = createLogger('BillingUpdateCostAPI')
Expand All @@ -19,6 +20,7 @@ const UpdateCostSchema = z.object({
source: z
.enum(['copilot', 'workspace-chat', 'mcp_copilot', 'mothership_block'])
.default('copilot'),
idempotencyKey: z.string().min(1).optional(),
})

/**
Expand All @@ -28,6 +30,8 @@ const UpdateCostSchema = z.object({
export async function POST(req: NextRequest) {
const requestId = generateRequestId()
const startTime = Date.now()
let claim: AtomicClaimResult | null = null
let usageCommitted = false

try {
logger.info(`[${requestId}] Update cost request started`)
Expand Down Expand Up @@ -75,9 +79,30 @@ export async function POST(req: NextRequest) {
)
}

const { userId, cost, model, inputTokens, outputTokens, source } = validation.data
const { userId, cost, model, inputTokens, outputTokens, source, idempotencyKey } =
validation.data
const isMcp = source === 'mcp_copilot'

claim = idempotencyKey
? await billingIdempotency.atomicallyClaim('update-cost', idempotencyKey)
: null

if (claim && !claim.claimed) {
logger.warn(`[${requestId}] Duplicate billing update rejected`, {
idempotencyKey,
userId,
source,
})
return NextResponse.json(
{
success: false,
error: 'Duplicate request: idempotency key already processed',
requestId,
},
{ status: 409 }
)
}

logger.info(`[${requestId}] Processing cost update`, {
userId,
cost,
Expand Down Expand Up @@ -113,6 +138,7 @@ export async function POST(req: NextRequest) {
],
additionalStats,
})
usageCommitted = true

logger.info(`[${requestId}] Recorded usage`, {
userId,
Expand Down Expand Up @@ -149,6 +175,22 @@ export async function POST(req: NextRequest) {
duration,
})

if (claim?.claimed && !usageCommitted) {
await billingIdempotency
.release(claim.normalizedKey, claim.storageMethod)
.catch((releaseErr) => {
logger.warn(`[${requestId}] Failed to release idempotency claim`, {
error: releaseErr instanceof Error ? releaseErr.message : String(releaseErr),
normalizedKey: claim?.normalizedKey,
})
})
} else if (claim?.claimed && usageCommitted) {
logger.warn(
`[${requestId}] Error occurred after usage committed; retaining idempotency claim to prevent double-billing`,
{ normalizedKey: claim.normalizedKey }
)
}

return NextResponse.json(
{
success: false,
Expand Down
23 changes: 22 additions & 1 deletion apps/sim/app/api/tools/google_drive/download/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
ALL_REVISION_FIELDS,
DEFAULT_EXPORT_FORMATS,
GOOGLE_WORKSPACE_MIME_TYPES,
VALID_EXPORT_FORMATS,
} from '@/tools/google_drive/utils'

export const dynamic = 'force-dynamic'
Expand Down Expand Up @@ -65,10 +66,12 @@ export async function POST(request: NextRequest) {
const {
accessToken,
fileId,
mimeType: exportMimeType,
mimeType: rawExportMimeType,
fileName,
includeRevisions,
} = validatedData
const exportMimeType =
rawExportMimeType && rawExportMimeType !== 'auto' ? rawExportMimeType : null
const authHeader = `Bearer ${accessToken}`

logger.info(`[${requestId}] Getting file metadata from Google Drive`, { fileId })
Expand Down Expand Up @@ -112,6 +115,24 @@ export async function POST(request: NextRequest) {

if (GOOGLE_WORKSPACE_MIME_TYPES.includes(fileMimeType)) {
const exportFormat = exportMimeType || DEFAULT_EXPORT_FORMATS[fileMimeType] || 'text/plain'

const validFormats = VALID_EXPORT_FORMATS[fileMimeType]
if (validFormats && !validFormats.includes(exportFormat)) {
logger.warn(`[${requestId}] Unsupported export format requested`, {
fileId,
fileMimeType,
requestedFormat: exportFormat,
validFormats,
})
return NextResponse.json(
{
success: false,
error: `Export format "${exportFormat}" is not supported for this file type. Supported formats: ${validFormats.join(', ')}`,
},
{ status: 400 }
)
}

finalMimeType = exportFormat

logger.info(`[${requestId}] Exporting Google Workspace file`, {
Expand Down
38 changes: 5 additions & 33 deletions apps/sim/app/api/tools/jira/update/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation'
import { getJiraCloudId, parseAtlassianErrorMessage } from '@/tools/jira/utils'
import { getJiraCloudId, parseAtlassianErrorMessage, toAdf } from '@/tools/jira/utils'

export const dynamic = 'force-dynamic'

Expand All @@ -15,14 +15,14 @@ const jiraUpdateSchema = z.object({
issueKey: z.string().min(1, 'Issue key is required'),
summary: z.string().optional(),
title: z.string().optional(),
description: z.string().optional(),
description: z.union([z.string(), z.record(z.unknown())]).optional(),
priority: z.string().optional(),
assignee: z.string().optional(),
labels: z.array(z.string()).optional(),
components: z.array(z.string()).optional(),
duedate: z.string().optional(),
fixVersions: z.array(z.string()).optional(),
environment: z.string().optional(),
environment: z.union([z.string(), z.record(z.unknown())]).optional(),
customFieldId: z.string().optional(),
customFieldValue: z.string().optional(),
notifyUsers: z.boolean().optional(),
Expand Down Expand Up @@ -91,21 +91,7 @@ export async function PUT(request: NextRequest) {
}

if (description !== undefined && description !== null && description !== '') {
fields.description = {
type: 'doc',
version: 1,
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: description,
},
],
},
],
}
fields.description = toAdf(description)
}

if (priority !== undefined && priority !== null && priority !== '') {
Expand Down Expand Up @@ -136,21 +122,7 @@ export async function PUT(request: NextRequest) {
}

if (environment !== undefined && environment !== null && environment !== '') {
fields.environment = {
type: 'doc',
version: 1,
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: environment,
},
],
},
],
}
fields.environment = toAdf(environment)
}

if (
Expand Down
34 changes: 3 additions & 31 deletions apps/sim/app/api/tools/jira/write/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getJiraCloudId, parseAtlassianErrorMessage } from '@/tools/jira/utils'
import { getJiraCloudId, parseAtlassianErrorMessage, toAdf } from '@/tools/jira/utils'

export const dynamic = 'force-dynamic'

Expand Down Expand Up @@ -85,21 +85,7 @@ export async function POST(request: NextRequest) {
}

if (description !== undefined && description !== null && description !== '') {
fields.description = {
type: 'doc',
version: 1,
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: description,
},
],
},
],
}
fields.description = toAdf(description)
}

if (parent !== undefined && parent !== null && parent !== '') {
Expand Down Expand Up @@ -144,21 +130,7 @@ export async function POST(request: NextRequest) {
}

if (environment !== undefined && environment !== null && environment !== '') {
fields.environment = {
type: 'doc',
version: 1,
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: environment,
},
],
},
],
}
fields.environment = toAdf(environment)
}

if (
Expand Down
68 changes: 68 additions & 0 deletions apps/sim/background/webhook-execution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* @vitest-environment node
*/

import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockResolveWebhookRecordProviderConfig } = vi.hoisted(() => ({
mockResolveWebhookRecordProviderConfig: vi.fn(),
}))

vi.mock('@/lib/webhooks/env-resolver', () => ({
resolveWebhookRecordProviderConfig: mockResolveWebhookRecordProviderConfig,
}))

import { resolveWebhookExecutionProviderConfig } from './webhook-execution'

describe('resolveWebhookExecutionProviderConfig', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('returns the resolved webhook record when provider config resolution succeeds', async () => {
const webhookRecord = {
id: 'webhook-1',
providerConfig: {
botToken: '{{SLACK_BOT_TOKEN}}',
},
}
const resolvedWebhookRecord = {
...webhookRecord,
providerConfig: {
botToken: 'xoxb-resolved',
},
}

mockResolveWebhookRecordProviderConfig.mockResolvedValue(resolvedWebhookRecord)

await expect(
resolveWebhookExecutionProviderConfig(webhookRecord, 'slack', 'user-1', 'workspace-1')
).resolves.toEqual(resolvedWebhookRecord)

expect(mockResolveWebhookRecordProviderConfig).toHaveBeenCalledWith(
webhookRecord,
'user-1',
'workspace-1'
)
})

it('throws a contextual error when provider config resolution fails', async () => {
mockResolveWebhookRecordProviderConfig.mockRejectedValue(new Error('env lookup failed'))

await expect(
resolveWebhookExecutionProviderConfig(
{
id: 'webhook-1',
providerConfig: {
botToken: '{{SLACK_BOT_TOKEN}}',
},
},
'slack',
'user-1',
'workspace-1'
)
).rejects.toThrow(
'Failed to resolve webhook provider config for slack webhook webhook-1: env lookup failed'
)
})
})
28 changes: 27 additions & 1 deletion apps/sim/background/webhook-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ 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 { WebhookAttachmentProcessor } from '@/lib/webhooks/attachment-processor'
import { resolveWebhookRecordProviderConfig } from '@/lib/webhooks/env-resolver'
import { getProviderHandler } from '@/lib/webhooks/providers'
import {
executeWorkflowCore,
Expand Down Expand Up @@ -168,6 +169,24 @@ export async function executeWebhookJob(payload: WebhookExecutionPayload) {
)
}

export async function resolveWebhookExecutionProviderConfig<
T extends { id: string; providerConfig?: unknown },
>(
webhookRecord: T,
provider: string,
userId: string,
workspaceId?: string
): Promise<T & { providerConfig: Record<string, unknown> }> {
try {
return await resolveWebhookRecordProviderConfig(webhookRecord, userId, workspaceId)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
throw new Error(
`Failed to resolve webhook provider config for ${provider} webhook ${webhookRecord.id}: ${errorMessage}`
)
}
}

async function resolveCredentialAccountUserId(credentialId: string): Promise<string | undefined> {
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
Expand Down Expand Up @@ -300,9 +319,16 @@ async function executeWebhookJobInternal(
throw new Error(`Webhook record not found: ${payload.webhookId}`)
}

const resolvedWebhookRecord = await resolveWebhookExecutionProviderConfig(
webhookRecord,
payload.provider,
workflowRecord.userId,
workspaceId
)

if (handler.formatInput) {
const result = await handler.formatInput({
webhook: webhookRecord,
webhook: resolvedWebhookRecord,
workflow: { id: payload.workflowId, userId: payload.userId },
body: payload.body,
headers: payload.headers,
Expand Down
Loading
Loading