From 9c4c342febac8aaf98ad2c94a568e57694f0057c Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 13:42:37 -0700 Subject: [PATCH 01/29] feat(observability): extend audit log, PostHog, and storage metering coverage Instruments previously-uncaptured resources and actions across the three observability layers (audit log, PostHog product analytics, usage metering): - Exfiltration audit: file downloads (workspace/public-share/v1), table, workflow, and workspace exports. - Credential access audited at the token-issuance boundary (success-only). - Full revenue trail: invoice paid/failed, overage billed, disputes, credit fulfillment, subscription lifecycle, plan/seat changes. - Session/login lifecycle audit via Better Auth hooks (login, blocked sign-in, logout, session revoke, account delete). - v1/admin programmatic surface and copilot tool handlers instrumented. - Dead audit/PostHog constants wired (lock/unlock, table, custom tool, etc.). - Storage metering extended to KB documents and copilot files. - PostHog person identify + workspace/organization group hygiene. Audit package hardened to null the actor FK for system actors (admin-api) and adds an awaitable recordAuditNow for pre-delete hooks. All instrumentation is fire-and-forget and never blocks or breaks the primary operation. --- apps/realtime/src/handlers/variables.ts | 27 +- apps/sim/app/api/auth/oauth/token/route.ts | 112 ++++ apps/sim/app/api/billing/switch-plan/route.ts | 24 + apps/sim/app/api/environment/route.ts | 6 + apps/sim/app/api/files/download/route.ts | 21 +- apps/sim/app/api/files/export/[id]/route.ts | 31 ++ .../api/files/public/[token]/content/route.ts | 25 + .../api/files/public/[token]/inline/route.ts | 25 +- .../[id]/members/[memberId]/route.ts | 23 + apps/sim/app/api/organizations/route.ts | 7 + .../[tableId]/export-async/route.test.ts | 1 + .../api/table/[tableId]/export-async/route.ts | 8 +- .../app/api/table/[tableId]/export/route.ts | 23 + apps/sim/app/api/table/[tableId]/route.ts | 4 +- .../app/api/users/me/api-keys/[id]/route.ts | 6 + apps/sim/app/api/users/me/api-keys/route.ts | 6 + .../me/subscription/[id]/transfer/route.ts | 22 + apps/sim/app/api/v1/admin/credits/route.ts | 19 + .../[id]/members/[memberId]/route.ts | 28 + .../admin/organizations/[id]/members/route.ts | 23 + .../api/v1/admin/organizations/[id]/route.ts | 13 + .../[id]/transfer-ownership/route.ts | 17 + .../app/api/v1/admin/organizations/route.ts | 13 + .../api/v1/admin/workflows/export/route.ts | 15 + .../v1/admin/workspaces/[id]/export/route.ts | 17 + .../[id]/members/[memberId]/route.ts | 28 + .../v1/admin/workspaces/[id]/members/route.ts | 38 ++ apps/sim/app/api/v1/files/[fileId]/route.ts | 25 + .../app/api/workflows/[id]/deploy/route.ts | 14 + apps/sim/app/api/workflows/[id]/route.ts | 1 + .../[id]/files/[fileId]/download/route.ts | 20 + .../workspaces/[id]/files/download/route.ts | 19 + .../app/api/workspaces/members/[id]/route.ts | 1 + .../providers/workspace-scope-sync.tsx | 13 +- apps/sim/lib/auth/auth.ts | 155 +++++- apps/sim/lib/billing/organizations/seats.ts | 34 ++ apps/sim/lib/billing/threshold-billing.ts | 526 ++++++++++-------- apps/sim/lib/billing/webhooks/disputes.ts | 79 ++- apps/sim/lib/billing/webhooks/enterprise.ts | 39 ++ apps/sim/lib/billing/webhooks/invoices.ts | 110 +++- apps/sim/lib/billing/webhooks/subscription.ts | 62 +++ apps/sim/lib/copilot/chat/post.ts | 14 + .../handlers/management/manage-custom-tool.ts | 68 +++ .../tools/handlers/management/manage-skill.ts | 65 +++ .../copilot/tools/server/table/user-table.ts | 48 +- apps/sim/lib/invitations/core.test.ts | 1 + apps/sim/lib/invitations/core.ts | 47 +- apps/sim/lib/knowledge/documents/service.ts | 76 ++- apps/sim/lib/posthog/events.ts | 134 ++++- apps/sim/lib/table/export-runner.ts | 26 +- apps/sim/lib/table/import-data.ts | 4 +- apps/sim/lib/table/service.ts | 77 ++- .../contexts/copilot/copilot-file-manager.ts | 33 ++ .../orchestration/workflow-lifecycle.ts | 24 + .../lib/workflows/schedules/orchestration.ts | 18 + packages/audit/src/index.ts | 2 +- packages/audit/src/log.test.ts | 27 +- packages/audit/src/log.ts | 39 +- packages/audit/src/types.ts | 32 ++ packages/testing/src/mocks/audit.mock.ts | 28 + 60 files changed, 2147 insertions(+), 296 deletions(-) diff --git a/apps/realtime/src/handlers/variables.ts b/apps/realtime/src/handlers/variables.ts index 98dc3a5b7af..7a4303e70b3 100644 --- a/apps/realtime/src/handlers/variables.ts +++ b/apps/realtime/src/handlers/variables.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -18,6 +19,9 @@ type PendingVariable = { latest: { variableId: string; field: string; value: any; timestamp: number } timeout: NodeJS.Timeout opToSocket: Map + /** Most recent writer, used as the audit actor when the coalesced update is persisted. */ + actorId: string + actorName?: string } // Keyed by `${workflowId}:${variableId}:${field}` @@ -177,6 +181,8 @@ export function setupVariablesHandlers(socket: AuthenticatedSocket, roomManager: if (existing) { clearTimeout(existing.timeout) existing.latest = { variableId, field, value, timestamp } + existing.actorId = session.userId + existing.actorName = session.userName if (operationId) existing.opToSocket.set(operationId, socket.id) existing.timeout = setTimeout(async () => { await flushVariableUpdate(workflowId, existing, roomManager) @@ -196,6 +202,8 @@ export function setupVariablesHandlers(socket: AuthenticatedSocket, roomManager: latest: { variableId, field, value, timestamp }, timeout, opToSocket, + actorId: session.userId, + actorName: session.userName, }) } } catch (error) { @@ -231,7 +239,11 @@ async function flushVariableUpdate( try { const workflowExists = await db - .select({ id: workflow.id }) + .select({ + id: workflow.id, + name: workflow.name, + workspaceId: workflow.workspaceId, + }) .from(workflow) .where(eq(workflow.id, workflowId)) .limit(1) @@ -294,6 +306,19 @@ async function flushVariableUpdate( }) if (updateSuccessful) { + const workflowRow = workflowExists[0] + recordAudit({ + workspaceId: workflowRow.workspaceId ?? null, + actorId: pending.actorId, + actorName: pending.actorName, + action: AuditAction.WORKFLOW_VARIABLES_UPDATED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: workflowId, + resourceName: workflowRow.name ?? undefined, + description: `Updated workflow variables`, + metadata: { variableId, field }, + }) + // Broadcast to room excluding all senders (works cross-pod via Redis adapter) const senderSocketIds = [...pending.opToSocket.values()] const broadcastPayload = { diff --git a/apps/sim/app/api/auth/oauth/token/route.ts b/apps/sim/app/api/auth/oauth/token/route.ts index d7d1b9cd9f3..77c1da4291f 100644 --- a/apps/sim/app/api/auth/oauth/token/route.ts +++ b/apps/sim/app/api/auth/oauth/token/route.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' @@ -11,6 +12,7 @@ import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types' +import { captureServerEvent } from '@/lib/posthog/server' import { getAtlassianServiceAccountSecret, getCredential, @@ -96,6 +98,24 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + recordAudit({ + actorId: auth.userId, + action: AuditAction.CREDENTIAL_ACCESSED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: providerId, + description: `Accessed OAuth credential for provider ${providerId}`, + metadata: { + provider: providerId, + credentialType: 'oauth', + credentialAccountUserId, + }, + request, + }) + captureServerEvent(auth.userId, 'credential_used', { + credential_type: 'oauth', + provider_id: providerId, + }) + return NextResponse.json({ accessToken }, { status: 200 }) } catch (error) { const message = getErrorMessage(error, 'Failed to get OAuth token') @@ -120,9 +140,41 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) } + const saActorId = authz.requesterUserId + const saWorkspaceId = resolved.workspaceId ?? authz.workspaceId ?? null + // Emitted only after the secret is successfully retrieved, so a failed + // provider call never records a spurious credential access. + const emitServiceAccountAccess = () => { + if (!saActorId) return + recordAudit({ + workspaceId: saWorkspaceId, + actorId: saActorId, + action: AuditAction.CREDENTIAL_ACCESSED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: resolved.credentialId ?? credentialId, + description: `Accessed service account credential for provider ${resolved.providerId ?? 'unknown'}`, + metadata: { + provider: resolved.providerId, + credentialType: 'service_account', + }, + request, + }) + captureServerEvent( + saActorId, + 'credential_used', + { + credential_type: 'service_account', + provider_id: resolved.providerId ?? 'unknown', + ...(saWorkspaceId ? { workspace_id: saWorkspaceId } : {}), + }, + saWorkspaceId ? { groups: { workspace: saWorkspaceId } } : undefined + ) + } + try { if (resolved.providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) { const secret = await getAtlassianServiceAccountSecret(resolved.credentialId) + emitServiceAccountAccess() return NextResponse.json( { accessToken: secret.apiToken, @@ -137,6 +189,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { scopes ?? [], impersonateEmail ) + emitServiceAccountAccess() return NextResponse.json({ accessToken }, { status: 200 }) } catch (error) { logger.error(`[${requestId}] Service account token error:`, error) @@ -165,6 +218,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) } + const oauthActorId = authz.requesterUserId + const oauthWorkspaceId = authz.workspaceId ?? null + try { const { accessToken } = await refreshTokenIfNeeded( requestId, @@ -172,6 +228,34 @@ export const POST = withRouteHandler(async (request: NextRequest) => { resolvedCredentialId ) + // Emitted only after the token is successfully resolved, so a failed + // refresh never records a spurious credential access. + if (oauthActorId) { + recordAudit({ + workspaceId: oauthWorkspaceId, + actorId: oauthActorId, + action: AuditAction.CREDENTIAL_ACCESSED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: resolvedCredentialId, + description: `Accessed OAuth credential for provider ${credential.providerId}`, + metadata: { + provider: credential.providerId, + credentialType: 'oauth', + }, + request, + }) + captureServerEvent( + oauthActorId, + 'credential_used', + { + credential_type: 'oauth', + provider_id: credential.providerId, + ...(oauthWorkspaceId ? { workspace_id: oauthWorkspaceId } : {}), + }, + oauthWorkspaceId ? { groups: { workspace: oauthWorkspaceId } } : undefined + ) + } + let instanceUrl: string | undefined if (credential.providerId === 'salesforce' && credential.scope) { const instanceMatch = credential.scope.match(SALESFORCE_INSTANCE_URL_REGEX) @@ -247,6 +331,34 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'No access token available' }, { status: 400 }) } + const actorId = authz.requesterUserId + if (actorId) { + const workspaceId = authz.workspaceId ?? null + recordAudit({ + workspaceId, + actorId, + action: AuditAction.CREDENTIAL_ACCESSED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: resolvedCredentialId, + description: `Accessed OAuth credential for provider ${credential.providerId}`, + metadata: { + provider: credential.providerId, + credentialType: 'oauth', + }, + request, + }) + captureServerEvent( + actorId, + 'credential_used', + { + credential_type: 'oauth', + provider_id: credential.providerId, + ...(workspaceId ? { workspace_id: workspaceId } : {}), + }, + workspaceId ? { groups: { workspace: workspaceId } } : undefined + ) + } + try { const { accessToken } = await refreshTokenIfNeeded( requestId, diff --git a/apps/sim/app/api/billing/switch-plan/route.ts b/apps/sim/app/api/billing/switch-plan/route.ts index c066afbedb0..7e56b9cba49 100644 --- a/apps/sim/app/api/billing/switch-plan/route.ts +++ b/apps/sim/app/api/billing/switch-plan/route.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { subscription as subscriptionTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -174,6 +175,29 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { set: { plan: targetPlanName } } ) + if (isOrgScopedSubscription(sub, userId)) { + recordAudit({ + actorId: userId, + action: AuditAction.ORG_PLAN_CONVERTED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: sub.referenceId, + description: `Plan converted from ${sub.plan ?? 'unknown'} to ${targetPlanName}`, + metadata: { + organizationId: sub.referenceId, + subscriptionId: sub.id, + fromPlan: sub.plan, + toPlan: targetPlanName, + interval: targetInterval, + }, + request, + }) + captureServerEvent(userId, 'plan_converted', { + organization_id: sub.referenceId, + from_plan: sub.plan ?? 'unknown', + to_plan: targetPlanName, + }) + } + return NextResponse.json({ success: true, plan: targetPlanName, interval: targetInterval }) } catch (error) { logger.error('Failed to switch subscription', { diff --git a/apps/sim/app/api/environment/route.ts b/apps/sim/app/api/environment/route.ts index a976da6b6ee..75162c53e0b 100644 --- a/apps/sim/app/api/environment/route.ts +++ b/apps/sim/app/api/environment/route.ts @@ -14,6 +14,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { syncPersonalEnvCredentialsForUser } from '@/lib/credentials/environment' import type { EnvironmentVariable } from '@/lib/environment/api' +import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('EnvironmentAPI') @@ -89,6 +90,11 @@ export const POST = withRouteHandler(async (req: NextRequest) => { request: req, }) + captureServerEvent(session.user.id, 'environment_updated', { + key_count: Object.keys(variables).length, + scope: 'personal', + }) + return NextResponse.json({ success: true }) } catch (error) { logger.error(`[${requestId}] Error updating environment variables`, error) diff --git a/apps/sim/app/api/files/download/route.ts b/apps/sim/app/api/files/download/route.ts index 33f1ce61146..400e33c1c2a 100644 --- a/apps/sim/app/api/files/download/route.ts +++ b/apps/sim/app/api/files/download/route.ts @@ -1,9 +1,11 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { fileDownloadContract } from '@/lib/api/contracts/storage-transfer' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' import type { StorageContext } from '@/lib/uploads/config' import { hasCloudStorage } from '@/lib/uploads/core/storage-service' import { verifyFileAccess } from '@/app/api/files/authorization' @@ -84,10 +86,27 @@ export const POST = withRouteHandler(async (request: NextRequest) => { logger.info(`Generated download URL for ${storageContext ?? 'inferred'} file: ${key}`) + const downloadName = name || key.split('/').pop() || 'download' + recordAudit({ + workspaceId: null, + actorId: userId, + action: AuditAction.FILE_DOWNLOADED, + resourceType: AuditResourceType.FILE, + resourceName: downloadName, + description: `Downloaded file "${downloadName}"`, + metadata: { key, fileName: downloadName, context: storageContext }, + request, + }) + captureServerEvent(userId, 'file_downloaded', { + workspace_id: '', + is_bulk: false, + file_count: 1, + }) + return NextResponse.json({ downloadUrl, expiresIn: null, - fileName: name || key.split('/').pop() || 'download', + fileName: downloadName, }) } catch (error) { logger.error('Error in file download endpoint:', error) diff --git a/apps/sim/app/api/files/export/[id]/route.ts b/apps/sim/app/api/files/export/[id]/route.ts index 26dc06abe0d..be979641733 100644 --- a/apps/sim/app/api/files/export/[id]/route.ts +++ b/apps/sim/app/api/files/export/[id]/route.ts @@ -1,4 +1,5 @@ import path from 'node:path' +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import JSZip from 'jszip' @@ -9,6 +10,7 @@ import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { extractEmbeddedImageIds } from '@/lib/copilot/tools/server/files/embedded-image-refs' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' import type { StorageContext } from '@/lib/uploads/config' import { USE_BLOB_STORAGE } from '@/lib/uploads/config' import { downloadFile } from '@/lib/uploads/core/storage-service' @@ -84,6 +86,35 @@ export const GET = withRouteHandler( logger.info('Exporting markdown', { id, imageCount: imageIds.length }) + const exportFormat = imageIds.length === 0 ? 'markdown' : 'zip' + recordAudit({ + workspaceId: record.workspaceId ?? null, + actorId: userId, + action: AuditAction.FILE_DOWNLOADED, + resourceType: AuditResourceType.FILE, + resourceId: record.id, + resourceName: record.originalName, + description: `Exported file "${record.originalName}"`, + metadata: { + fileId: record.id, + fileName: record.originalName, + bytes: record.size, + format: exportFormat, + assetCount: imageIds.length, + }, + request, + }) + captureServerEvent( + userId, + 'file_downloaded', + { + workspace_id: record.workspaceId ?? '', + is_bulk: imageIds.length > 0, + file_count: 1 + imageIds.length, + }, + record.workspaceId ? { groups: { workspace: record.workspaceId } } : undefined + ) + if (imageIds.length === 0) { const mdName = safeFilename(record.originalName) const mdBytes = Buffer.from(mdContent, 'utf-8') diff --git a/apps/sim/app/api/files/public/[token]/content/route.ts b/apps/sim/app/api/files/public/[token]/content/route.ts index 8db42e412bd..346c6435f55 100644 --- a/apps/sim/app/api/files/public/[token]/content/route.ts +++ b/apps/sim/app/api/files/public/[token]/content/route.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' @@ -7,6 +8,7 @@ import { resolveServableDoc } from '@/lib/copilot/tools/server/files/doc-compile import { validateDeploymentAuth } from '@/lib/core/security/deployment-auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit' import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager' import { downloadFile } from '@/lib/uploads/core/storage-service' @@ -76,6 +78,29 @@ export const GET = withRouteHandler( logger.info('Public shared file served', { token, key: file.key, size: buffer.length }) + recordAudit({ + workspaceId: file.workspaceId ?? null, + actorId: file.userId, + action: AuditAction.FILE_DOWNLOADED, + resourceType: AuditResourceType.FILE, + resourceId: file.id, + resourceName: file.originalName, + description: `Public share download of "${file.originalName}"`, + metadata: { + access: 'public_share', + anonymous: true, + fileName: file.originalName, + bytes: buffer.length, + }, + request, + }) + captureServerEvent( + file.userId, + 'file_downloaded', + { workspace_id: file.workspaceId ?? '', is_bulk: false, file_count: 1 }, + file.workspaceId ? { groups: { workspace: file.workspaceId } } : undefined + ) + // Revalidate every request: a shared file can be unshared, edited, or deleted, // so the fixed token URL must never serve stale bytes from a long-lived cache. return createFileResponse({ diff --git a/apps/sim/app/api/files/public/[token]/inline/route.ts b/apps/sim/app/api/files/public/[token]/inline/route.ts index 87c343a26a8..581351da5d9 100644 --- a/apps/sim/app/api/files/public/[token]/inline/route.ts +++ b/apps/sim/app/api/files/public/[token]/inline/route.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' @@ -10,6 +11,7 @@ import { import { validateDeploymentAuth } from '@/lib/core/security/deployment-auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit' import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager' import { downloadFile } from '@/lib/uploads/core/storage-service' @@ -87,7 +89,28 @@ export const GET = withRouteHandler( } // Content-truth gate (`sniff`): render only genuine raster image bytes. - return await serveInlineImage(image, { sniff: true }) + // Audit only after the bytes are accepted so a sniff rejection does not + // record a spurious download. + const response = await serveInlineImage(image, { sniff: true }) + + recordAudit({ + workspaceId: doc.workspaceId, + actorId: doc.userId, + action: AuditAction.FILE_DOWNLOADED, + resourceType: AuditResourceType.FILE, + resourceName: image.filename, + description: `Public share inline image "${image.filename}"`, + metadata: { access: 'public_share', anonymous: true, inline: true }, + request, + }) + captureServerEvent( + doc.userId, + 'file_downloaded', + { workspace_id: doc.workspaceId, is_bulk: false, file_count: 1 }, + { groups: { workspace: doc.workspaceId } } + ) + + return response } catch (error) { if (error instanceof FileNotFoundError) { return createErrorResponse(error) diff --git a/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts b/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts index 69433ca0b7d..38e2e831c8e 100644 --- a/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts @@ -18,6 +18,7 @@ import { } from '@/lib/billing/organizations/membership' import { reconcileOrganizationSeats } from '@/lib/billing/organizations/seats' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('OrganizationMemberAPI') @@ -253,6 +254,13 @@ export const PUT = withRouteHandler( request, }) + captureServerEvent( + session.user.id, + 'org_member_role_changed', + { organization_id: organizationId, new_role: role }, + { groups: { organization: organizationId } } + ) + return NextResponse.json({ success: true, message: 'Member role updated successfully', @@ -382,6 +390,13 @@ export const DELETE = withRouteHandler( request, }) + captureServerEvent( + session.user.id, + 'org_member_removed', + { organization_id: organizationId, is_self_removal: session.user.id === targetUserId }, + { groups: { organization: organizationId } } + ) + return NextResponse.json({ success: true, message: 'External member removed successfully', @@ -422,6 +437,7 @@ export const DELETE = withRouteHandler( seatReduction = await reconcileOrganizationSeats({ organizationId, reason: 'member-removed', + actorId: session.user.id, }) } catch (seatError) { logger.error('Failed to reduce seats after member removal', { @@ -479,6 +495,13 @@ export const DELETE = withRouteHandler( request, }) + captureServerEvent( + session.user.id, + 'org_member_removed', + { organization_id: organizationId, is_self_removal: session.user.id === targetUserId }, + { groups: { organization: organizationId } } + ) + return NextResponse.json({ success: true, message: diff --git a/apps/sim/app/api/organizations/route.ts b/apps/sim/app/api/organizations/route.ts index ba1fece2f3d..81194ab7069 100644 --- a/apps/sim/app/api/organizations/route.ts +++ b/apps/sim/app/api/organizations/route.ts @@ -23,6 +23,7 @@ import { import { isOrgPlan } from '@/lib/billing/plan-helpers' import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' import { attachOwnedWorkspacesToOrganization, WorkspaceOrganizationMembershipConflictError, @@ -256,6 +257,12 @@ export const POST = withRouteHandler(async (request: Request) => { metadata: { organizationSlug }, request, }) + captureServerEvent( + user.id, + 'organization_created', + { organization_id: organizationId, name: organizationName ?? '' }, + { groups: { organization: organizationId } } + ) } return NextResponse.json({ diff --git a/apps/sim/app/api/table/[tableId]/export-async/route.test.ts b/apps/sim/app/api/table/[tableId]/export-async/route.test.ts index 5be95a85ee6..4a7e6e70c24 100644 --- a/apps/sim/app/api/table/[tableId]/export-async/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/export-async/route.test.ts @@ -90,6 +90,7 @@ describe('POST /api/table/[tableId]/export-async', () => { tableId: 'tbl_1', workspaceId: 'workspace-1', format: 'csv', + userId: 'user-1', }) }) diff --git a/apps/sim/app/api/table/[tableId]/export-async/route.ts b/apps/sim/app/api/table/[tableId]/export-async/route.ts index 9208808e61e..31ccd11f581 100644 --- a/apps/sim/app/api/table/[tableId]/export-async/route.ts +++ b/apps/sim/app/api/table/[tableId]/export-async/route.ts @@ -58,7 +58,13 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro return NextResponse.json({ error: 'Failed to start export' }, { status: 409 }) } - const payload: TableExportPayload = { jobId, tableId, workspaceId, format } + const payload: TableExportPayload = { + jobId, + tableId, + workspaceId, + format, + userId: authResult.userId, + } if (isTriggerDevEnabled) { try { const [{ tableExportTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([ diff --git a/apps/sim/app/api/table/[tableId]/export/route.ts b/apps/sim/app/api/table/[tableId]/export/route.ts index 75208e3d982..df7de7b1e23 100644 --- a/apps/sim/app/api/table/[tableId]/export/route.ts +++ b/apps/sim/app/api/table/[tableId]/export/route.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { tableExportFormatSchema, tableIdParamsSchema } from '@/lib/api/contracts/tables' @@ -6,6 +7,7 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { neutralizeCsvFormula } from '@/lib/core/utils/csv' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' import { buildNameById, getColumnId, rowDataIdToName } from '@/lib/table/column-keys' import { queryRows } from '@/lib/table/rows/service' import { accessError, checkAccess } from '@/app/api/table/utils' @@ -29,6 +31,7 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou if (!auth.success || !auth.userId) { return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) } + const userId = auth.userId const { searchParams } = new URL(request.url) const formatValidation = tableExportFormatSchema.safeParse( @@ -98,6 +101,26 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou format, rowCount: table.rowCount, }) + + recordAudit({ + workspaceId: table.workspaceId ?? null, + actorId: userId, + action: AuditAction.TABLE_EXPORTED, + resourceType: AuditResourceType.TABLE, + resourceId: tableId, + resourceName: table.name, + description: `Exported table "${table.name}" as ${format.toUpperCase()}`, + metadata: { format, rowCount: table.rowCount }, + request, + }) + if (table.workspaceId) { + captureServerEvent( + userId, + 'table_exported', + { table_id: tableId, workspace_id: table.workspaceId }, + { groups: { workspace: table.workspaceId } } + ) + } } catch (err) { logger.error(`[${requestId}] Export failed for table ${tableId}`, err) controller.error(err) diff --git a/apps/sim/app/api/table/[tableId]/route.ts b/apps/sim/app/api/table/[tableId]/route.ts index bd398867eeb..5d0069fa570 100644 --- a/apps/sim/app/api/table/[tableId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/route.ts @@ -125,7 +125,7 @@ export const PATCH = withRouteHandler( return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - const updated = await renameTable(tableId, validated.name, requestId) + const updated = await renameTable(tableId, validated.name, requestId, authResult.userId) return NextResponse.json({ success: true, @@ -172,7 +172,7 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - await deleteTable(tableId, requestId) + await deleteTable(tableId, requestId, authResult.userId) captureServerEvent( authResult.userId, diff --git a/apps/sim/app/api/users/me/api-keys/[id]/route.ts b/apps/sim/app/api/users/me/api-keys/[id]/route.ts index 4ae92aff51d..514832d4a06 100644 --- a/apps/sim/app/api/users/me/api-keys/[id]/route.ts +++ b/apps/sim/app/api/users/me/api-keys/[id]/route.ts @@ -9,6 +9,7 @@ import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('ApiKeyAPI') @@ -59,6 +60,11 @@ export const DELETE = withRouteHandler( request, }) + captureServerEvent(userId, 'api_key_revoked', { + key_name: deletedKey.name, + scope: 'personal', + }) + return NextResponse.json({ success: true }) } catch (error) { logger.error('Failed to delete API key', { error }) diff --git a/apps/sim/app/api/users/me/api-keys/route.ts b/apps/sim/app/api/users/me/api-keys/route.ts index e14a00a8118..fb2411ace55 100644 --- a/apps/sim/app/api/users/me/api-keys/route.ts +++ b/apps/sim/app/api/users/me/api-keys/route.ts @@ -11,6 +11,7 @@ import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth' import { hashApiKey } from '@/lib/api-key/crypto' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('ApiKeysAPI') @@ -122,6 +123,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { request, }) + captureServerEvent(userId, 'api_key_created', { + key_name: name, + scope: 'personal', + }) + return NextResponse.json({ key: { ...newKey, diff --git a/apps/sim/app/api/users/me/subscription/[id]/transfer/route.ts b/apps/sim/app/api/users/me/subscription/[id]/transfer/route.ts index c1a64e93175..adce3c771ef 100644 --- a/apps/sim/app/api/users/me/subscription/[id]/transfer/route.ts +++ b/apps/sim/app/api/users/me/subscription/[id]/transfer/route.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { member, organization, subscription } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -13,6 +14,7 @@ import { hasPaidSubscriptionStatus, } from '@/lib/billing/subscriptions/utils' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('SubscriptionTransferAPI') @@ -132,6 +134,26 @@ export const POST = withRouteHandler( organizationId, userId, }) + + recordAudit({ + actorId: userId, + action: AuditAction.SUBSCRIPTION_TRANSFERRED, + resourceType: AuditResourceType.SUBSCRIPTION, + resourceId: subscriptionId, + description: `Subscription transferred to organization ${organizationId}`, + metadata: { + subscriptionId, + organizationId, + fromEntity: 'user', + toEntity: 'organization', + }, + request, + }) + captureServerEvent(userId, 'subscription_transferred', { + subscription_id: subscriptionId, + from_entity: 'user', + to_entity: 'organization', + }) } return NextResponse.json({ success: true, message: outcome.message }) diff --git a/apps/sim/app/api/v1/admin/credits/route.ts b/apps/sim/app/api/v1/admin/credits/route.ts index 756f1efc304..e7e5ed81833 100644 --- a/apps/sim/app/api/v1/admin/credits/route.ts +++ b/apps/sim/app/api/v1/admin/credits/route.ts @@ -23,6 +23,7 @@ * Usage limits are updated accordingly to allow spending the credits. */ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { organization, subscription, user, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -209,6 +210,24 @@ export const POST = withRouteHandler( reason: reason || 'No reason provided', }) + recordAudit({ + actorId: 'admin-api', + action: AuditAction.CREDIT_ISSUED, + resourceType: AuditResourceType.BILLING, + resourceId: entityId, + description: `Admin API issued $${Number(amount).toFixed(2)} credits to ${entityType} ${entityId}`, + metadata: { + targetUserId: resolvedUserId, + ...(entityType === 'organization' ? { targetOrgId: entityId } : {}), + entityType, + amount, + currency: 'usd', + reason: reason || null, + newCreditBalance, + }, + request, + }) + return singleResponse({ success: true, userId: resolvedUserId, diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts index 0f25618fc2c..68b79e3a78a 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts @@ -25,6 +25,7 @@ * Response: { success: true, memberId: string, billingActions: {...} } */ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { member, organization, user, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -205,6 +206,22 @@ export const PATCH = withRouteHandler( previousRole: existingMember.role, }) + recordAudit({ + workspaceId: null, + actorId: 'admin-api', + action: AuditAction.ORG_MEMBER_ROLE_CHANGED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + description: `Admin API changed organization member role to ${role}`, + metadata: { + memberId, + targetUserId: existingMember.userId, + previousRole: existingMember.role, + role, + }, + request, + }) + return singleResponse(data) } catch (error) { logger.error('Admin API: Failed to update member', { error, organizationId, memberId }) @@ -275,6 +292,17 @@ export const DELETE = withRouteHandler( billingActions: result.billingActions, }) + recordAudit({ + workspaceId: null, + actorId: 'admin-api', + action: AuditAction.ORG_MEMBER_REMOVED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + description: 'Admin API removed member from organization', + metadata: { memberId, targetUserId: userId }, + request, + }) + return singleResponse({ success: true, memberId, diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts index 99e6d2c791c..5f77c3aa1e9 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts @@ -28,6 +28,7 @@ * }> */ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { member, organization, user, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -204,6 +205,17 @@ export const POST = withRouteHandler( } ) + recordAudit({ + workspaceId: null, + actorId: 'admin-api', + action: AuditAction.ORG_MEMBER_ROLE_CHANGED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + description: `Admin API changed organization member role to ${role}`, + metadata: { targetUserId: userId, previousRole: existingMember.role, role }, + request, + }) + return singleResponse({ id: existingMember.id, userId, @@ -268,6 +280,17 @@ export const POST = withRouteHandler( billingActions: result.billingActions, }) + recordAudit({ + workspaceId: null, + actorId: 'admin-api', + action: AuditAction.ORG_MEMBER_ADDED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + description: `Admin API added member to organization as ${role}`, + metadata: { targetUserId: userId, role, memberId: result.memberId }, + request, + }) + return singleResponse({ ...data, action: 'created' as const, diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/route.ts index 5f8881dc739..a1ef18a9cd0 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/route.ts @@ -16,6 +16,7 @@ * Response: AdminSingleResponse */ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { member, organization, subscription } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -161,6 +162,18 @@ export const PATCH = withRouteHandler( fields: Object.keys(updateData).filter((k) => k !== 'updatedAt'), }) + recordAudit({ + workspaceId: null, + actorId: 'admin-api', + action: AuditAction.ORGANIZATION_UPDATED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + resourceName: updated.name, + description: `Admin API updated organization "${updated.name}"`, + metadata: { fields: Object.keys(updateData).filter((k) => k !== 'updatedAt') }, + request, + }) + return singleResponse(toAdminOrganization(updated)) } catch (error) { if (error instanceof OrganizationSlugInvalidError) { diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/transfer-ownership/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/transfer-ownership/route.ts index 9da30b930d2..026d3458488 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/transfer-ownership/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/transfer-ownership/route.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { member, organization, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -108,6 +109,22 @@ export const POST = withRouteHandler( billingBlockInherited: result.billingBlockInherited, }) + recordAudit({ + workspaceId: null, + actorId: 'admin-api', + action: AuditAction.ORG_MEMBER_ROLE_CHANGED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + description: 'Admin API transferred organization ownership', + metadata: { + currentOwnerUserId, + newOwnerUserId, + workspacesReassigned: result.workspacesReassigned, + billedAccountReassigned: result.billedAccountReassigned, + }, + request, + }) + return singleResponse({ organizationId, currentOwnerUserId, diff --git a/apps/sim/app/api/v1/admin/organizations/route.ts b/apps/sim/app/api/v1/admin/organizations/route.ts index 5799c2bf1ad..26a2a652868 100644 --- a/apps/sim/app/api/v1/admin/organizations/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/route.ts @@ -21,6 +21,7 @@ * Response: AdminSingleResponse */ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db, dbReplica } from '@sim/db' import { member, organization, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -167,6 +168,18 @@ export const POST = withRouteHandler( memberId, }) + recordAudit({ + workspaceId: null, + actorId: 'admin-api', + action: AuditAction.ORGANIZATION_CREATED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + resourceName: name, + description: `Admin API created organization "${name}"`, + metadata: { slug, ownerId, memberId }, + request, + }) + return singleResponse({ ...toAdminOrganization(createdOrg), memberId, diff --git a/apps/sim/app/api/v1/admin/workflows/export/route.ts b/apps/sim/app/api/v1/admin/workflows/export/route.ts index e5021518a3e..9d4461f899a 100644 --- a/apps/sim/app/api/v1/admin/workflows/export/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/export/route.ts @@ -14,6 +14,7 @@ * - JSON: AdminListResponse */ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -101,6 +102,20 @@ export const POST = withRouteHandler( logger.info(`Admin API: Exporting ${workflowExports.length} workflows`) + recordAudit({ + actorId: 'admin-api', + action: AuditAction.WORKFLOW_EXPORTED, + resourceType: AuditResourceType.WORKFLOW, + description: `Admin API exported ${workflowExports.length} workflow(s)`, + metadata: { + format, + requestedCount: body.ids.length, + exportedCount: workflowExports.length, + requestedIds: body.ids, + }, + request, + }) + if (format === 'json') { return listResponse(workflowExports, { total: workflowExports.length, diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts index dcc1638c9b9..ff69ed39445 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts @@ -11,6 +11,7 @@ * - JSON: WorkspaceExportPayload */ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { workflow, workflowFolder, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -124,6 +125,22 @@ export const GET = withRouteHandler( `Admin API: Exporting workspace ${workspaceId} with ${workflowExports.length} workflows and ${folderExports.length} folders` ) + recordAudit({ + workspaceId, + actorId: 'admin-api', + action: AuditAction.WORKSPACE_EXPORTED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: workspaceId, + resourceName: workspaceData.name, + description: `Admin API exported workspace "${workspaceData.name}"`, + metadata: { + format, + workflowCount: workflowExports.length, + folderCount: folderExports.length, + }, + request, + }) + if (format === 'json') { const exportPayload: WorkspaceExportPayload = { version: '1.0', diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/members/[memberId]/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/members/[memberId]/route.ts index c4c0dfe0324..fc3082427ab 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/members/[memberId]/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/members/[memberId]/route.ts @@ -21,6 +21,7 @@ * Response: AdminSingleResponse<{ removed: true, memberId: string, userId: string }> */ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { permissions, user, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -196,6 +197,22 @@ export const PATCH = withRouteHandler( previousPermissions: existingMember.permissionType, }) + recordAudit({ + workspaceId, + actorId: 'admin-api', + action: AuditAction.MEMBER_ROLE_CHANGED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: workspaceId, + description: `Admin API changed workspace member permissions to ${permissionLevel}`, + metadata: { + memberId, + targetUserId: existingMember.userId, + previousPermissions: existingMember.permissionType, + permissions: permissionLevel, + }, + request, + }) + return singleResponse(data) } catch (error) { logger.error('Admin API: Failed to update workspace member', { error, workspaceId, memberId }) @@ -275,6 +292,17 @@ export const DELETE = withRouteHandler( userId: existingMember.userId, }) + recordAudit({ + workspaceId, + actorId: 'admin-api', + action: AuditAction.MEMBER_REMOVED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: workspaceId, + description: 'Admin API removed member from workspace', + metadata: { memberId, targetUserId: existingMember.userId }, + request, + }) + return singleResponse({ removed: true, memberId, diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/members/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/members/route.ts index 8d81b839099..0be8954b693 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/members/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/members/route.ts @@ -30,6 +30,7 @@ * Response: AdminSingleResponse<{ removed: true }> */ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { permissions, user, workspace, workspaceEnvironment } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -199,6 +200,21 @@ export const POST = withRouteHandler( newPermissions: permissionLevel, }) + recordAudit({ + workspaceId, + actorId: 'admin-api', + action: AuditAction.MEMBER_ROLE_CHANGED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: workspaceId, + description: `Admin API changed workspace member permissions to ${permissionLevel}`, + metadata: { + targetUserId: userId, + previousPermissions: existingPermission.permissionType, + permissions: permissionLevel, + }, + request, + }) + return singleResponse({ id: existingPermission.id, workspaceId, @@ -245,6 +261,17 @@ export const POST = withRouteHandler( permissionId, }) + recordAudit({ + workspaceId, + actorId: 'admin-api', + action: AuditAction.MEMBER_ADDED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: workspaceId, + description: `Admin API added member to workspace with ${permissionLevel} permissions`, + metadata: { targetUserId: userId, permissions: permissionLevel }, + request, + }) + const [wsEnvRow] = await db .select({ variables: workspaceEnvironment.variables }) .from(workspaceEnvironment) @@ -348,6 +375,17 @@ export const DELETE = withRouteHandler( logger.info(`Admin API: Removed user ${userId} from workspace ${workspaceId}`) + recordAudit({ + workspaceId, + actorId: 'admin-api', + action: AuditAction.MEMBER_REMOVED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: workspaceId, + description: 'Admin API removed member from workspace', + metadata: { targetUserId: userId }, + request, + }) + return singleResponse({ removed: true, userId, workspaceId }) } catch (error) { if (error instanceof WorkspaceBillingAccountRemovalError) { diff --git a/apps/sim/app/api/v1/files/[fileId]/route.ts b/apps/sim/app/api/v1/files/[fileId]/route.ts index 5482a4d378f..258e36b9ffb 100644 --- a/apps/sim/app/api/v1/files/[fileId]/route.ts +++ b/apps/sim/app/api/v1/files/[fileId]/route.ts @@ -1,9 +1,11 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { v1DeleteFileContract, v1DownloadFileContract } from '@/lib/api/contracts/v1/files' import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration' import { @@ -48,6 +50,29 @@ export const GET = withRouteHandler(async (request: NextRequest, context: FileRo const buffer = await fetchWorkspaceFileBuffer(fileRecord) + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.FILE_DOWNLOADED, + resourceType: AuditResourceType.FILE, + resourceId: fileRecord.id, + resourceName: fileRecord.name, + description: `Downloaded file "${fileRecord.name}" via API`, + metadata: { + fileId: fileRecord.id, + fileName: fileRecord.name, + bytes: buffer.length, + source: 'api_v1', + }, + request, + }) + captureServerEvent( + userId, + 'file_downloaded', + { workspace_id: workspaceId, is_bulk: false, file_count: 1 }, + { groups: { workspace: workspaceId } } + ) + return new Response(new Uint8Array(buffer), { status: 200, headers: { diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.ts b/apps/sim/app/api/workflows/[id]/deploy/route.ts index 75eca4dc779..440636186e1 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db, workflow } from '@sim/db' import { createLogger } from '@sim/logger' import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' @@ -186,6 +187,19 @@ export const PATCH = withRouteHandler( logger.info(`[${requestId}] Updated isPublicApi for workflow ${id} to ${isPublicApi}`) const wsId = workflowData?.workspaceId + + recordAudit({ + workspaceId: wsId ?? null, + actorId: session!.user.id, + action: AuditAction.WORKFLOW_PUBLIC_API_TOGGLED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: id, + resourceName: workflowData?.name ?? undefined, + description: `${isPublicApi ? 'Enabled' : 'Disabled'} public API for workflow "${workflowData?.name ?? id}"`, + metadata: { isPublicApi }, + request, + }) + captureServerEvent( session!.user.id, 'workflow_public_api_toggled', diff --git a/apps/sim/app/api/workflows/[id]/route.ts b/apps/sim/app/api/workflows/[id]/route.ts index d2c1f607b2d..08867154aea 100644 --- a/apps/sim/app/api/workflows/[id]/route.ts +++ b/apps/sim/app/api/workflows/[id]/route.ts @@ -319,6 +319,7 @@ export const PUT = withRouteHandler( workspaceId: workflowData.workspaceId, currentName: workflowData.name, currentFolderId: workflowData.folderId, + currentLocked: workflowData.locked, ...updates, requestId, }) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/download/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/download/route.ts index d56e7ab6442..77c8900a718 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/download/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/download/route.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' @@ -6,6 +7,7 @@ import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' @@ -55,6 +57,24 @@ export const POST = withRouteHandler( logger.info(`[${requestId}] Generated download URL for workspace file: ${fileRecord.name}`) + recordAudit({ + workspaceId, + actorId: session.user.id, + action: AuditAction.FILE_DOWNLOADED, + resourceType: AuditResourceType.FILE, + resourceId: fileId, + resourceName: fileRecord.name, + description: `Downloaded file "${fileRecord.name}"`, + metadata: { fileId, fileName: fileRecord.name, bytes: fileRecord.size }, + request, + }) + captureServerEvent( + session.user.id, + 'file_downloaded', + { workspace_id: workspaceId, is_bulk: false, file_count: 1 }, + { groups: { workspace: workspaceId } } + ) + return NextResponse.json({ success: true, downloadUrl: serveUrl, diff --git a/apps/sim/app/api/workspaces/[id]/files/download/route.ts b/apps/sim/app/api/workspaces/[id]/files/download/route.ts index c65b5438158..b6220308e55 100644 --- a/apps/sim/app/api/workspaces/[id]/files/download/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/download/route.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import JSZip from 'jszip' import { type NextRequest, NextResponse } from 'next/server' @@ -5,6 +6,7 @@ import { downloadWorkspaceFileItemsContract } from '@/lib/api/contracts/workspac import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' import { buildWorkspaceFileFolderPathMap, fetchWorkspaceFileBuffer, @@ -135,6 +137,23 @@ export const GET = withRouteHandler( } const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' }) + + recordAudit({ + workspaceId, + actorId: session.user.id, + action: AuditAction.FILE_DOWNLOADED, + resourceType: AuditResourceType.FILE, + description: `Downloaded ${filesToZip.length} file${filesToZip.length === 1 ? '' : 's'} as zip`, + metadata: { fileCount: filesToZip.length, totalBytes }, + request, + }) + captureServerEvent( + session.user.id, + 'file_downloaded', + { workspace_id: workspaceId, is_bulk: true, file_count: filesToZip.length }, + { groups: { workspace: workspaceId } } + ) + return new NextResponse(new Uint8Array(zipBuffer), { headers: { 'Content-Type': 'application/zip', diff --git a/apps/sim/app/api/workspaces/members/[id]/route.ts b/apps/sim/app/api/workspaces/members/[id]/route.ts index 54889076a58..51a1e038c63 100644 --- a/apps/sim/app/api/workspaces/members/[id]/route.ts +++ b/apps/sim/app/api/workspaces/members/[id]/route.ts @@ -188,6 +188,7 @@ export const DELETE = withRouteHandler( seatReduction = await reconcileOrganizationSeats({ organizationId, reason: 'member-removed', + actorId: session.user.id, }) } catch (seatError) { logger.error('Failed to reduce seats after workspace member removal', { diff --git a/apps/sim/app/workspace/[workspaceId]/providers/workspace-scope-sync.tsx b/apps/sim/app/workspace/[workspaceId]/providers/workspace-scope-sync.tsx index 72c859a4a56..612b1c1864a 100644 --- a/apps/sim/app/workspace/[workspaceId]/providers/workspace-scope-sync.tsx +++ b/apps/sim/app/workspace/[workspaceId]/providers/workspace-scope-sync.tsx @@ -3,6 +3,7 @@ import { useEffect } from 'react' import { useParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' +import { useWorkspacesWithMetadata } from '@/hooks/queries/workspace' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' /** @@ -13,11 +14,19 @@ export function WorkspaceScopeSync() { const hydrationWorkspaceId = useWorkflowRegistry((state) => state.hydration.workspaceId) const switchToWorkspace = useWorkflowRegistry((state) => state.switchToWorkspace) const posthog = usePostHog() + const { data: workspaceData } = useWorkspacesWithMetadata() + + const activeWorkspace = workspaceData?.workspaces.find((ws) => ws.id === workspaceId) + const workspaceName = activeWorkspace?.name + const organizationId = activeWorkspace?.organizationId ?? null useEffect(() => { if (!workspaceId) return - posthog?.group('workspace', workspaceId) - }, [posthog, workspaceId]) + posthog?.group('workspace', workspaceId, workspaceName ? { name: workspaceName } : undefined) + if (organizationId) { + posthog?.group('organization', organizationId) + } + }, [posthog, workspaceId, workspaceName, organizationId]) useEffect(() => { if (!workspaceId || hydrationWorkspaceId === workspaceId) { diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 3640723a32f..9eaf0d5c335 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -2,6 +2,7 @@ import { createHash } from 'crypto' import { cache } from 'react' import { sso } from '@better-auth/sso' import { stripe } from '@better-auth/stripe' +import { AuditAction, AuditResourceType, recordAudit, recordAuditNow } from '@sim/audit' import { db } from '@sim/db' import * as schema from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -206,7 +207,7 @@ export const auth = betterAuth({ user: { deleteUser: { enabled: false, - beforeDelete: async (deletingUser) => { + beforeDelete: async (deletingUser, request) => { const { isSoleOwnerOfPaidOrganization } = await import( '@/lib/billing/organizations/membership' ) @@ -238,6 +239,26 @@ export const auth = betterAuth({ `Your account owns ${ownedUnresolved.length} workspace${ownedUnresolved.length === 1 ? '' : 's'} with no other admin to take over ownership. Add another admin to ${ownedUnresolved.length === 1 ? 'that workspace' : 'those workspaces'} or delete ${ownedUnresolved.length === 1 ? 'it' : 'them'} before deleting your account.` ) } + + /** + * All ownership/billing blockers passed — the account deletion will + * proceed after this callback returns. Await the write so the row lands + * while the `user` row still exists; a deferred (fire-and-forget) insert + * would race the cascade delete and FK-violate. actorName/actorEmail are + * captured explicitly so the actor stays identifiable after the FK is + * nulled by `onDelete: set null`. + */ + await recordAuditNow({ + actorId: deletingUser.id, + actorName: deletingUser.name, + actorEmail: deletingUser.email, + action: AuditAction.ACCOUNT_DELETED, + resourceType: AuditResourceType.USER, + resourceId: deletingUser.id, + resourceName: deletingUser.name ?? deletingUser.email, + description: `Account deleted for ${deletingUser.email}`, + request, + }) }, }, }, @@ -621,6 +642,15 @@ export const auth = betterAuth({ logger.warn('Blocking session creation for blocked account', { userId: session.userId, }) + recordAudit({ + actorId: session.userId, + actorEmail: sessionUser?.email, + action: AuditAction.USER_SIGNIN_BLOCKED, + resourceType: AuditResourceType.SESSION, + resourceId: session.userId, + description: 'Sign-in blocked by access control policy', + metadata: { reason: 'blocked_email_or_domain', email: sessionUser?.email }, + }) throw new APIError('FORBIDDEN', { message: 'Access restricted. Please contact your administrator.', }) @@ -851,6 +881,31 @@ export const auth = betterAuth({ }, hooks: { before: createAuthMiddleware(async (ctx) => { + /** + * Record a logout while the session is still live. The `/sign-out` route + * deletes the session row before any after-hook runs, so the acting user + * can no longer be resolved afterwards; resolve and audit it here. + */ + if (ctx.path === '/sign-out' && ctx.headers) { + try { + const sessionData = await auth.api.getSession({ headers: ctx.headers }) + if (sessionData?.user?.id) { + recordAudit({ + actorId: sessionData.user.id, + actorName: sessionData.user.name, + actorEmail: sessionData.user.email, + action: AuditAction.USER_LOGOUT, + resourceType: AuditResourceType.SESSION, + resourceId: sessionData.session?.id ?? sessionData.user.id, + description: 'User signed out', + request: { headers: ctx.headers }, + }) + } + } catch (error) { + logger.warn('Failed to record sign-out audit', { error }) + } + } + /** * Restrict the unauthenticated sign-in endpoints to first-party login * providers. Better Auth registers every generic-OAuth integration @@ -895,6 +950,36 @@ export const auth = betterAuth({ const accessControl = await getAccessControlConfig() const requestEmail = ctx.body?.email?.toLowerCase() + /** + * Audit a policy-blocked sign-in. The audit row's `actorId` is a FK to + * `user.id`, so it is only written when the email maps to an existing + * user (the common sign-in case). Blocked sign-ups for emails with no + * account are intentionally skipped to avoid an FK violation. + */ + const recordSignInBlocked = async (reason: string) => { + if (!requestEmail) return + try { + const [blockedUser] = await db + .select({ id: schema.user.id }) + .from(schema.user) + .where(eq(sql`lower(${schema.user.email})`, requestEmail)) + .limit(1) + if (!blockedUser) return + recordAudit({ + actorId: blockedUser.id, + actorEmail: requestEmail, + action: AuditAction.USER_SIGNIN_BLOCKED, + resourceType: AuditResourceType.SESSION, + resourceId: blockedUser.id, + description: 'Sign-in blocked by access control policy', + metadata: { reason, email: requestEmail, path: ctx.path }, + request: ctx.headers ? { headers: ctx.headers } : undefined, + }) + } catch (error) { + logger.warn('Failed to record blocked sign-in audit', { error }) + } + } + // Banning an existing account is owned by better-auth's admin plugin (a // `session.create.before` hook that blocks banned users at sign-in across // all providers), so it is not re-checked here. @@ -907,6 +992,7 @@ export const auth = betterAuth({ accessControl.allowedLoginEmails.includes(requestEmail) || (!!emailDomain && accessControl.allowedLoginDomains.includes(emailDomain)) if (!isAllowed) { + await recordSignInBlocked('not_in_allowlist') throw new APIError('FORBIDDEN', { message: 'Access restricted. Please contact your administrator.', }) @@ -916,6 +1002,7 @@ export const auth = betterAuth({ // Blocked emails/domains gate both signup and sign-in. OAuth/SSO sign-ins // have no email in the body here; the session.create.before hook covers them. if (isEmailBlockedByAccessControl(requestEmail, accessControl)) { + await recordSignInBlocked('blocked_email_or_domain') throw new APIError('FORBIDDEN', { message: isSignUp ? 'Sign-ups from this email are not allowed.' @@ -933,6 +1020,7 @@ export const auth = betterAuth({ accessControl.blockedEmailMxHosts ) if (!mxCheck.allowed) { + await recordSignInBlocked('mx_validation_failed') throw new APIError('FORBIDDEN', { message: 'Sign-ups from this email domain are not allowed.', }) @@ -942,6 +1030,71 @@ export const auth = betterAuth({ return }), + after: createAuthMiddleware(async (ctx) => { + const request = ctx.headers ? { headers: ctx.headers } : undefined + + /** + * A freshly minted `newSession` marks a successful authentication. It is + * set by every session-establishing flow (email/password, OAuth and SSO + * callbacks, email-OTP). Session refreshes (`/get-session`, + * `/update-session`, `/update-user`) also set it, so the login audit is + * gated on an authentication entry path to avoid auditing a refresh as a + * new login. + */ + const newSession = ctx.context.newSession + const isLoginPath = + ctx.path.startsWith('/sign-in') || + ctx.path.startsWith('/sign-up') || + ctx.path.startsWith('/callback') || + ctx.path.startsWith('/oauth2/callback') || + ctx.path.startsWith('/sso/callback') || + ctx.path.startsWith('/magic-link') || + ctx.path.startsWith('/email-otp') || + ctx.path.startsWith('/verify-email') + if (newSession?.user?.id && isLoginPath) { + recordAudit({ + actorId: newSession.user.id, + actorName: newSession.user.name, + actorEmail: newSession.user.email, + action: AuditAction.USER_LOGIN, + resourceType: AuditResourceType.SESSION, + resourceId: newSession.session?.id ?? newSession.user.id, + description: 'User signed in', + metadata: { + method: ctx.path, + ...(typeof ctx.body?.provider === 'string' ? { provider: ctx.body.provider } : {}), + }, + request, + }) + } + + /** + * Session revocation endpoints run `sensitiveSessionMiddleware`, so the + * acting user is resolved onto `ctx.context.session`. The revoked token is + * deliberately not stored in metadata to avoid leaking a session secret. + */ + if ( + ctx.path === '/revoke-session' || + ctx.path === '/revoke-sessions' || + ctx.path === '/revoke-other-sessions' + ) { + const actor = ctx.context.session?.user + if (actor?.id) { + const isSingle = ctx.path === '/revoke-session' + recordAudit({ + actorId: actor.id, + actorName: actor.name, + actorEmail: actor.email, + action: AuditAction.SESSION_REVOKED, + resourceType: AuditResourceType.SESSION, + resourceId: actor.id, + description: isSingle ? 'Session revoked' : 'Sessions revoked', + metadata: { scope: isSingle ? 'single' : 'all', path: ctx.path }, + request, + }) + } + } + }), }, plugins: [ ...(env.TURNSTILE_SECRET_KEY diff --git a/apps/sim/lib/billing/organizations/seats.ts b/apps/sim/lib/billing/organizations/seats.ts index a956a38d444..bd87a02608d 100644 --- a/apps/sim/lib/billing/organizations/seats.ts +++ b/apps/sim/lib/billing/organizations/seats.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { member, subscription } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -8,6 +9,7 @@ import { USABLE_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers' import { isBillingEnabled } from '@/lib/core/config/env-flags' import { enqueueOutboxEvent } from '@/lib/core/outbox/service' +import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('OrganizationSeats') @@ -22,6 +24,13 @@ export interface ReconcileOrganizationSeatsResult { interface ReconcileOrganizationSeatsParams { organizationId: string reason: string + /** + * Real `user.id` of the actor whose action triggered this reconcile, used to + * attribute the seat-change audit log and analytics event. Omit for system + * reconciles (e.g. the seat-drift sweep) that have no acting user; the audit + * is then skipped and analytics fall back to the organization id. + */ + actorId?: string } /** @@ -41,6 +50,7 @@ interface ReconcileOrganizationSeatsParams { export async function reconcileOrganizationSeats({ organizationId, reason, + actorId, }: ReconcileOrganizationSeatsParams): Promise { if (!isBillingEnabled) { return { changed: false, reason: 'Billing is not enabled' } @@ -142,6 +152,30 @@ export async function reconcileOrganizationSeats({ outboxEventId: outcome.outboxEventId, }) + const increased = outcome.seats > outcome.previousSeats + if (actorId) { + recordAudit({ + workspaceId: null, + actorId, + action: increased ? AuditAction.ORG_SEAT_PROVISIONED : AuditAction.ORG_SEAT_DEPROVISIONED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + description: `${increased ? 'Provisioned' : 'Deprovisioned'} organization seats: ${outcome.previousSeats} → ${outcome.seats}`, + metadata: { previousSeats: outcome.previousSeats, seats: outcome.seats, reason }, + }) + } + captureServerEvent( + actorId ?? organizationId, + increased ? 'seats_provisioned' : 'seats_deprovisioned', + { + organization_id: organizationId, + previous_seats: outcome.previousSeats, + seats: outcome.seats, + reason, + }, + { groups: { organization: organizationId } } + ) + return { changed: true, previousSeats: outcome.previousSeats, diff --git a/apps/sim/lib/billing/threshold-billing.ts b/apps/sim/lib/billing/threshold-billing.ts index 1f5cfd67d4f..43d73aa3024 100644 --- a/apps/sim/lib/billing/threshold-billing.ts +++ b/apps/sim/lib/billing/threshold-billing.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { member, organization, subscription, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -19,6 +20,7 @@ import { toDecimal, toNumber } from '@/lib/billing/utils/decimal' import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers' import { env, envNumber } from '@/lib/core/config/env' import { enqueueOutboxEvent } from '@/lib/core/outbox/service' +import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('ThresholdBilling') @@ -118,73 +120,91 @@ export async function checkAndBillOverageThreshold(userId: string): Promise { - await tx.execute(sql.raw(`SET LOCAL lock_timeout = '${BILLING_LOCK_TIMEOUT_MS}ms'`)) + const billedResult = await db.transaction( + async (tx): Promise<{ amount: number; creditsApplied: number } | null> => { + await tx.execute(sql.raw(`SET LOCAL lock_timeout = '${BILLING_LOCK_TIMEOUT_MS}ms'`)) - const statsRecords = await tx - .select() - .from(userStats) - .where(eq(userStats.userId, userId)) - .for('update') - .limit(1) + const statsRecords = await tx + .select() + .from(userStats) + .where(eq(userStats.userId, userId)) + .for('update') + .limit(1) + + if (statsRecords.length === 0) { + logger.warn('User stats not found for threshold billing', { userId }) + return null + } + + const stats = statsRecords[0] + const lockedUsageSnapshot = personalUsageSnapshotFromStats(stats) + if (!personalUsageSnapshotMatches(usageSnapshot, lockedUsageSnapshot)) { + logger.debug('Personal usage changed during threshold billing check; retry later', { + userId, + usageSnapshot, + lockedUsageSnapshot, + }) + return null + } - if (statsRecords.length === 0) { - logger.warn('User stats not found for threshold billing', { userId }) - return - } + const billedOverageThisPeriod = toNumber(toDecimal(stats.billedOverageThisPeriod)) + const unbilledOverage = Math.max(0, currentOverage - billedOverageThisPeriod) - const stats = statsRecords[0] - const lockedUsageSnapshot = personalUsageSnapshotFromStats(stats) - if (!personalUsageSnapshotMatches(usageSnapshot, lockedUsageSnapshot)) { - logger.debug('Personal usage changed during threshold billing check; retry later', { + logger.debug('Threshold billing check', { userId, - usageSnapshot, - lockedUsageSnapshot, + plan: userSubscription.plan, + currentOverage, + billedOverageThisPeriod, + unbilledOverage, + threshold, }) - return - } - - const billedOverageThisPeriod = toNumber(toDecimal(stats.billedOverageThisPeriod)) - const unbilledOverage = Math.max(0, currentOverage - billedOverageThisPeriod) - - logger.debug('Threshold billing check', { - userId, - plan: userSubscription.plan, - currentOverage, - billedOverageThisPeriod, - unbilledOverage, - threshold, - }) - if (unbilledOverage < threshold) { - return - } - - // Apply credits to reduce the amount to bill (use stats from locked row) - let amountToBill = unbilledOverage - let creditsApplied = 0 - const creditBalance = toNumber(toDecimal(stats.creditBalance)) - - if (creditBalance > 0) { - creditsApplied = Math.min(creditBalance, amountToBill) - await tx - .update(userStats) - .set({ - creditBalance: sql`GREATEST(0, ${userStats.creditBalance} - ${creditsApplied})`, + if (unbilledOverage < threshold) { + return null + } + + // Apply credits to reduce the amount to bill (use stats from locked row) + let amountToBill = unbilledOverage + let creditsApplied = 0 + const creditBalance = toNumber(toDecimal(stats.creditBalance)) + + if (creditBalance > 0) { + creditsApplied = Math.min(creditBalance, amountToBill) + await tx + .update(userStats) + .set({ + creditBalance: sql`GREATEST(0, ${userStats.creditBalance} - ${creditsApplied})`, + }) + .where(eq(userStats.userId, userId)) + amountToBill = amountToBill - creditsApplied + + logger.info('Applied credits to reduce threshold overage', { + userId, + creditBalance, + creditsApplied, + remainingToBill: amountToBill, }) - .where(eq(userStats.userId, userId)) - amountToBill = amountToBill - creditsApplied + } + + // If credits covered everything, bump billed tracker but don't enqueue Stripe invoice. + if (amountToBill <= 0) { + await tx + .update(userStats) + .set({ + billedOverageThisPeriod: sql`${userStats.billedOverageThisPeriod} + ${unbilledOverage}`, + }) + .where(eq(userStats.userId, userId)) + + logger.info('Credits fully covered threshold overage', { + userId, + creditsApplied, + unbilledOverage, + }) + return null + } - logger.info('Applied credits to reduce threshold overage', { - userId, - creditBalance, - creditsApplied, - remainingToBill: amountToBill, - }) - } + const amountCents = Math.round(amountToBill * 100) - // If credits covered everything, bump billed tracker but don't enqueue Stripe invoice. - if (amountToBill <= 0) { await tx .update(userStats) .set({ @@ -192,51 +212,63 @@ export async function checkAndBillOverageThreshold(userId: string): Promise { - await tx.execute(sql.raw(`SET LOCAL lock_timeout = '${BILLING_LOCK_TIMEOUT_MS}ms'`)) - - const lockedOwnerRows = await tx - .select({ userId: member.userId }) - .from(member) - .where(and(eq(member.organizationId, organizationId), eq(member.role, 'owner'))) - .for('update') - .limit(1) - const lockedOwnerId = lockedOwnerRows[0]?.userId - if (!lockedOwnerId) { - logger.error('Organization owner not found after locking organization', { organizationId }) - return - } + const orgBilledResult = await db.transaction( + async (tx): Promise<{ amount: number; creditsApplied: number; ownerId: string } | null> => { + await tx.execute(sql.raw(`SET LOCAL lock_timeout = '${BILLING_LOCK_TIMEOUT_MS}ms'`)) + + const lockedOwnerRows = await tx + .select({ userId: member.userId }) + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.role, 'owner'))) + .for('update') + .limit(1) + const lockedOwnerId = lockedOwnerRows[0]?.userId + if (!lockedOwnerId) { + logger.error('Organization owner not found after locking organization', { + organizationId, + }) + return null + } - const ownerStatsLock = await tx - .select() - .from(userStats) - .where(eq(userStats.userId, lockedOwnerId)) - .for('update') - .limit(1) - if (ownerStatsLock.length === 0) { - logger.error('Owner stats not found', { organizationId, ownerId: lockedOwnerId }) - return - } + const ownerStatsLock = await tx + .select() + .from(userStats) + .where(eq(userStats.userId, lockedOwnerId)) + .for('update') + .limit(1) + if (ownerStatsLock.length === 0) { + logger.error('Owner stats not found', { organizationId, ownerId: lockedOwnerId }) + return null + } + + const orgLock = await tx + .select() + .from(organization) + .where(eq(organization.id, organizationId)) + .for('update') + .limit(1) + + if (orgLock.length === 0) { + logger.error('Organization not found', { organizationId }) + return null + } + + const lockedMemberUsageRows = await tx + .select({ + userId: member.userId, + role: member.role, + currentPeriodCost: userStats.currentPeriodCost, + departedMemberUsage: organization.departedMemberUsage, + }) + .from(member) + .leftJoin(userStats, eq(member.userId, userStats.userId)) + .innerJoin(organization, eq(organization.id, member.organizationId)) + .where(eq(member.organizationId, organizationId)) + + const lockedUsageSnapshot = buildOrganizationUsageSnapshot(lockedMemberUsageRows) + if ( + !lockedUsageSnapshot || + lockedOwnerId !== usageSnapshot.ownerId || + !organizationUsageSnapshotMatches(usageSnapshot, lockedUsageSnapshot) + ) { + logger.debug('Organization usage changed during threshold billing check; retry later', { + organizationId, + usageSnapshot, + lockedUsageSnapshot, + lockedOwnerId, + }) + return null + } - const orgLock = await tx - .select() - .from(organization) - .where(eq(organization.id, organizationId)) - .for('update') - .limit(1) + const totalBilledOverage = toNumber(toDecimal(ownerStatsLock[0].billedOverageThisPeriod)) + const orgCreditBalance = toNumber(toDecimal(orgLock[0].creditBalance)) - if (orgLock.length === 0) { - logger.error('Organization not found', { organizationId }) - return - } + const unbilledOverage = Math.max(0, currentOverage - totalBilledOverage) - const lockedMemberUsageRows = await tx - .select({ - userId: member.userId, - role: member.role, - currentPeriodCost: userStats.currentPeriodCost, - departedMemberUsage: organization.departedMemberUsage, - }) - .from(member) - .leftJoin(userStats, eq(member.userId, userStats.userId)) - .innerJoin(organization, eq(organization.id, member.organizationId)) - .where(eq(member.organizationId, organizationId)) - - const lockedUsageSnapshot = buildOrganizationUsageSnapshot(lockedMemberUsageRows) - if ( - !lockedUsageSnapshot || - lockedOwnerId !== usageSnapshot.ownerId || - !organizationUsageSnapshotMatches(usageSnapshot, lockedUsageSnapshot) - ) { - logger.debug('Organization usage changed during threshold billing check; retry later', { + logger.debug('Organization threshold billing check', { organizationId, - usageSnapshot, - lockedUsageSnapshot, - lockedOwnerId, + totalTeamUsage: + usageSnapshot.pooledCurrentPeriodCost + ledgerUsage + usageSnapshot.departedMemberUsage, + ledgerUsage, + effectiveTeamUsage, + basePrice, + currentOverage, + totalBilledOverage, + unbilledOverage, + threshold, }) - return - } - - const totalBilledOverage = toNumber(toDecimal(ownerStatsLock[0].billedOverageThisPeriod)) - const orgCreditBalance = toNumber(toDecimal(orgLock[0].creditBalance)) - - const unbilledOverage = Math.max(0, currentOverage - totalBilledOverage) - - logger.debug('Organization threshold billing check', { - organizationId, - totalTeamUsage: - usageSnapshot.pooledCurrentPeriodCost + ledgerUsage + usageSnapshot.departedMemberUsage, - ledgerUsage, - effectiveTeamUsage, - basePrice, - currentOverage, - totalBilledOverage, - unbilledOverage, - threshold, - }) - - if (unbilledOverage < threshold) { - return - } - let amountToBill = unbilledOverage - let creditsApplied = 0 - - if (orgCreditBalance > 0) { - creditsApplied = Math.min(orgCreditBalance, amountToBill) - await tx - .update(organization) - .set({ - creditBalance: sql`GREATEST(0, ${organization.creditBalance} - ${creditsApplied})`, + if (unbilledOverage < threshold) { + return null + } + + let amountToBill = unbilledOverage + let creditsApplied = 0 + + if (orgCreditBalance > 0) { + creditsApplied = Math.min(orgCreditBalance, amountToBill) + await tx + .update(organization) + .set({ + creditBalance: sql`GREATEST(0, ${organization.creditBalance} - ${creditsApplied})`, + }) + .where(eq(organization.id, organizationId)) + amountToBill = amountToBill - creditsApplied + + logger.info('Applied org credits to reduce threshold overage', { + organizationId, + creditBalance: orgCreditBalance, + creditsApplied, + remainingToBill: amountToBill, }) - .where(eq(organization.id, organizationId)) - amountToBill = amountToBill - creditsApplied + } + + // If credits covered everything, bump billed tracker but don't enqueue Stripe invoice. + if (amountToBill <= 0) { + await tx + .update(userStats) + .set({ + billedOverageThisPeriod: sql`${userStats.billedOverageThisPeriod} + ${unbilledOverage}`, + }) + .where(eq(userStats.userId, lockedOwnerId)) + + logger.info('Credits fully covered org threshold overage', { + organizationId, + creditsApplied, + unbilledOverage, + }) + return null + } - logger.info('Applied org credits to reduce threshold overage', { - organizationId, - creditBalance: orgCreditBalance, - creditsApplied, - remainingToBill: amountToBill, - }) - } + const amountCents = Math.round(amountToBill * 100) - // If credits covered everything, bump billed tracker but don't enqueue Stripe invoice. - if (amountToBill <= 0) { + // Bump billed tracker and enqueue Stripe invoice atomically. + // See user-path above for the full retry-invariant reasoning. await tx .update(userStats) .set({ @@ -489,52 +543,62 @@ async function checkAndBillOrganizationOverageThreshold(organizationId: string): }) .where(eq(userStats.userId, lockedOwnerId)) - logger.info('Credits fully covered org threshold overage', { + await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_THRESHOLD_OVERAGE_INVOICE, { + customerId, + stripeSubscriptionId, + amountCents, + description: `Team threshold overage billing – ${billingPeriod}`, + itemDescription: `Team usage overage ($${amountToBill.toFixed(2)})`, + billingPeriod, + invoiceIdemKeyStem: `threshold-overage-org-invoice:${customerId}:${stripeSubscriptionId}:${billingPeriod}:${totalOverageCents}:${amountCents}`, + itemIdemKeyStem: `threshold-overage-org-item:${customerId}:${stripeSubscriptionId}:${billingPeriod}:${totalOverageCents}:${amountCents}`, + metadata: { + type: 'overage_threshold_billing_org', + organizationId, + subscriptionId: stripeSubscriptionId, + billingPeriod, + totalOverageAtTimeOfBilling: currentOverage.toFixed(2), + }, + }) + + logger.info('Queued organization threshold overage invoice for Stripe', { organizationId, + ownerId: lockedOwnerId, creditsApplied, - unbilledOverage, + amountBilled: amountToBill, + totalProcessed: unbilledOverage, + billingPeriod, }) - return - } - - const amountCents = Math.round(amountToBill * 100) - // Bump billed tracker and enqueue Stripe invoice atomically. - // See user-path above for the full retry-invariant reasoning. - await tx - .update(userStats) - .set({ - billedOverageThisPeriod: sql`${userStats.billedOverageThisPeriod} + ${unbilledOverage}`, - }) - .where(eq(userStats.userId, lockedOwnerId)) - - await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_THRESHOLD_OVERAGE_INVOICE, { - customerId, - stripeSubscriptionId, - amountCents, - description: `Team threshold overage billing – ${billingPeriod}`, - itemDescription: `Team usage overage ($${amountToBill.toFixed(2)})`, - billingPeriod, - invoiceIdemKeyStem: `threshold-overage-org-invoice:${customerId}:${stripeSubscriptionId}:${billingPeriod}:${totalOverageCents}:${amountCents}`, - itemIdemKeyStem: `threshold-overage-org-item:${customerId}:${stripeSubscriptionId}:${billingPeriod}:${totalOverageCents}:${amountCents}`, + return { amount: amountToBill, creditsApplied, ownerId: lockedOwnerId } + } + ) + + if (orgBilledResult) { + const { amount, creditsApplied, ownerId } = orgBilledResult + recordAudit({ + actorId: ownerId, + action: AuditAction.OVERAGE_BILLED, + resourceType: AuditResourceType.BILLING, + resourceId: orgSubscription.id, + description: `Overage of $${amount.toFixed(2)} billed for organization ${organizationId}`, metadata: { - type: 'overage_threshold_billing_org', - organizationId, - subscriptionId: stripeSubscriptionId, + entityType: 'organization', + referenceId: organizationId, + plan: orgSubscription.plan, + amount, + currency: 'usd', + creditsApplied, billingPeriod, - totalOverageAtTimeOfBilling: currentOverage.toFixed(2), }, }) - - logger.info('Queued organization threshold overage invoice for Stripe', { - organizationId, - ownerId: lockedOwnerId, - creditsApplied, - amountBilled: amountToBill, - totalProcessed: unbilledOverage, - billingPeriod, + captureServerEvent(ownerId, 'overage_billed', { + amount, + currency: 'usd', + entity_type: 'organization', + reference_id: organizationId, }) - }) + } } catch (error) { logger.error('Error in organization threshold billing', { organizationId, diff --git a/apps/sim/lib/billing/webhooks/disputes.ts b/apps/sim/lib/billing/webhooks/disputes.ts index 647ad8a9cae..556e595e880 100644 --- a/apps/sim/lib/billing/webhooks/disputes.ts +++ b/apps/sim/lib/billing/webhooks/disputes.ts @@ -1,10 +1,12 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { subscription, user, userStats } from '@sim/db/schema' +import { member, subscription, user, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import type Stripe from 'stripe' import { blockOrgMembers, unblockOrgMembers } from '@/lib/billing' import { requireStripeClient } from '@/lib/billing/stripe-client' +import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('DisputeWebhooks') @@ -17,6 +19,59 @@ async function getCustomerIdFromDispute(dispute: Stripe.Dispute): Promise { + try { + const rows = await db + .select({ userId: member.userId }) + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.role, 'owner'))) + .limit(1) + return rows[0]?.userId ?? null + } catch (error) { + logger.warn('Failed to resolve organization owner for dispute audit', { organizationId, error }) + return null + } +} + +/** + * Record audit + PostHog instrumentation for a charge dispute money event. + * `actorId` must be the responsible user (org owner for org-scoped charges). + */ +function recordDisputeInstrumentation( + status: 'opened' | 'closed', + dispute: Stripe.Dispute, + customerId: string, + actorId: string, + entity: { type: 'user' | 'organization'; id: string } +): void { + const amount = dispute.amount / 100 + recordAudit({ + actorId, + action: + status === 'opened' ? AuditAction.CHARGE_DISPUTE_OPENED : AuditAction.CHARGE_DISPUTE_CLOSED, + resourceType: AuditResourceType.BILLING, + resourceId: dispute.id, + description: `Charge dispute ${status} for $${amount.toFixed(2)} (${dispute.reason})`, + metadata: { + entityType: entity.type, + entityId: entity.id, + customerId, + amount, + currency: dispute.currency, + reason: dispute.reason, + status: dispute.status, + }, + }) + captureServerEvent(actorId, 'charge_disputed', { + amount, + currency: dispute.currency, + reason: dispute.reason, + status, + entity_type: entity.type, + reference_id: entity.id, + }) +} + /** * Handles charge.dispute.created - blocks the responsible user */ @@ -46,6 +101,11 @@ export async function handleChargeDispute(event: Stripe.Event): Promise { disputeId: dispute.id, userId: users[0].id, }) + + recordDisputeInstrumentation('opened', dispute, customerId, users[0].id, { + type: 'user', + id: users[0].id, + }) return } @@ -67,6 +127,12 @@ export async function handleChargeDispute(event: Stripe.Event): Promise { memberCount, }) } + + const actorId = (await getOrganizationOwnerId(orgId)) ?? orgId + recordDisputeInstrumentation('opened', dispute, customerId, actorId, { + type: 'organization', + id: orgId, + }) } } @@ -115,6 +181,11 @@ export async function handleDisputeClosed(event: Stripe.Event): Promise { userId: users[0].id, status: dispute.status, }) + + recordDisputeInstrumentation('closed', dispute, customerId, users[0].id, { + type: 'user', + id: users[0].id, + }) return } @@ -135,5 +206,11 @@ export async function handleDisputeClosed(event: Stripe.Event): Promise { memberCount, status: dispute.status, }) + + const actorId = (await getOrganizationOwnerId(orgId)) ?? orgId + recordDisputeInstrumentation('closed', dispute, customerId, actorId, { + type: 'organization', + id: orgId, + }) } } diff --git a/apps/sim/lib/billing/webhooks/enterprise.ts b/apps/sim/lib/billing/webhooks/enterprise.ts index c717024001e..13ca7d0bf8e 100644 --- a/apps/sim/lib/billing/webhooks/enterprise.ts +++ b/apps/sim/lib/billing/webhooks/enterprise.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { organization, subscription, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -7,6 +8,7 @@ import type Stripe from 'stripe' import { getEmailSubject, renderEnterpriseSubscriptionEmail } from '@/components/emails' import { sendEmail } from '@/lib/messaging/email/mailer' import { getFromEmailAddress } from '@/lib/messaging/email/utils' +import { captureServerEvent } from '@/lib/posthog/server' import { parseEnterpriseSubscriptionMetadata } from '../types' const logger = createLogger('BillingEnterprise') @@ -145,6 +147,43 @@ export async function handleManualEnterpriseSubscription(event: Stripe.Event) { note: 'Seats from metadata, Stripe quantity set to 1', }) + let actorId = referenceId + try { + const [provisioningUser] = await db + .select({ id: user.id }) + .from(user) + .where(eq(user.stripeCustomerId, stripeCustomerId)) + .limit(1) + actorId = provisioningUser?.id ?? referenceId + } catch (error) { + logger.warn('Failed to resolve enterprise provisioning actor; falling back to reference id', { + referenceId, + error, + }) + } + + recordAudit({ + actorId, + action: AuditAction.ENTERPRISE_SUBSCRIPTION_PROVISIONED, + resourceType: AuditResourceType.SUBSCRIPTION, + resourceId: subscriptionRow.id, + description: `Enterprise subscription provisioned for organization ${referenceId} (${seats} seats)`, + metadata: { + organizationId: referenceId, + stripeCustomerId, + stripeSubscriptionId: stripeSubscription.id, + seats, + monthlyPrice, + currency: 'usd', + }, + }) + captureServerEvent(actorId, 'enterprise_subscription_created', { + reference_id: referenceId, + seats, + monthly_price: monthlyPrice, + currency: 'usd', + }) + try { const userDetails = await db .select({ diff --git a/apps/sim/lib/billing/webhooks/invoices.ts b/apps/sim/lib/billing/webhooks/invoices.ts index a3e026f2006..c144af3559b 100644 --- a/apps/sim/lib/billing/webhooks/invoices.ts +++ b/apps/sim/lib/billing/webhooks/invoices.ts @@ -1,4 +1,5 @@ import { render } from '@react-email/render' +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { member, @@ -31,9 +32,35 @@ import { getBaseUrl } from '@/lib/core/utils/urls' import { sendEmail } from '@/lib/messaging/email/mailer' import { getPersonalEmailFrom } from '@/lib/messaging/email/utils' import { quickValidateEmail } from '@/lib/messaging/email/validation' +import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('StripeInvoiceWebhooks') +/** + * Resolve the audit actor for a billing event. For org-scoped subscriptions the + * actor is the org owner; for personal subscriptions it is the reference (user) + * id. The owner lookup is best-effort — a failure must never break webhook + * processing, so it falls back to the reference id (which the audit layer nulls + * to a system actor if it is not a real user id). + */ +async function resolveBillingActorId(isOrgScoped: boolean, referenceId: string): Promise { + if (!isOrgScoped) return referenceId + try { + const ownerRows = await db + .select({ userId: member.userId }) + .from(member) + .where(and(eq(member.organizationId, referenceId), eq(member.role, 'owner'))) + .limit(1) + return ownerRows[0]?.userId ?? referenceId + } catch (error) { + logger.warn('Failed to resolve billing actor; falling back to reference id', { + referenceId, + error, + }) + return referenceId + } +} + function getSubscriptionLinePeriod( invoice: Stripe.Invoice, stripeSubscriptionId: string @@ -711,6 +738,30 @@ async function handleCreditPurchaseSuccess(invoice: Stripe.Invoice): Promise = [] @@ -862,6 +913,33 @@ export async function handleInvoicePaymentSucceeded(event: Stripe.Event) { periodEnd: invoicePeriod?.periodEnd ?? null, }) } + + const entityType = subIsOrgScoped ? 'organization' : 'user' + const amountPaid = (invoice.amount_paid ?? 0) / 100 + const actorId = await resolveBillingActorId(subIsOrgScoped, sub.referenceId) + + recordAudit({ + actorId, + action: AuditAction.INVOICE_PAYMENT_SUCCEEDED, + resourceType: AuditResourceType.BILLING, + resourceId: invoice.id, + description: `Invoice payment of $${amountPaid.toFixed(2)} succeeded for ${entityType} ${sub.referenceId}`, + metadata: { + entityType, + referenceId: sub.referenceId, + plan: sub.plan, + amount: amountPaid, + currency: invoice.currency ?? 'usd', + invoiceId: invoice.id, + }, + }) + captureServerEvent(actorId, 'payment_succeeded', { + plan: sub.plan ?? 'unknown', + amount: amountPaid, + currency: invoice.currency ?? 'usd', + entity_type: entityType, + reference_id: sub.referenceId, + }) } ) } catch (error) { @@ -914,6 +992,36 @@ export async function handleInvoicePaymentFailed(event: Stripe.Event) { resolutionSource, }) + const failureOrgScoped = await isSubscriptionOrgScoped(sub) + const failureEntityType = failureOrgScoped ? 'organization' : 'user' + const failureActorId = await resolveBillingActorId(failureOrgScoped, sub.referenceId) + + recordAudit({ + actorId: failureActorId, + action: AuditAction.INVOICE_PAYMENT_FAILED, + resourceType: AuditResourceType.BILLING, + resourceId: invoice.id, + description: `Invoice payment of $${failedAmount.toFixed(2)} failed for ${failureEntityType} ${sub.referenceId} (attempt ${attemptCount})`, + metadata: { + entityType: failureEntityType, + referenceId: sub.referenceId, + plan: sub.plan, + amount: failedAmount, + currency: invoice.currency ?? 'usd', + attemptCount, + invoiceType: invoiceType ?? 'subscription', + invoiceId: invoice.id, + }, + }) + captureServerEvent(failureActorId, 'payment_failed', { + plan: sub.plan ?? 'unknown', + amount: failedAmount, + currency: invoice.currency ?? 'usd', + entity_type: failureEntityType, + reference_id: sub.referenceId, + attempt_count: attemptCount, + }) + if (attemptCount >= 1) { logger.error('Payment failure - blocking users', { customerId, @@ -924,7 +1032,7 @@ export async function handleInvoicePaymentFailed(event: Stripe.Event) { stripeSubscriptionId, }) - if (await isSubscriptionOrgScoped(sub)) { + if (failureOrgScoped) { const memberCount = await blockOrgMembers(sub.referenceId, 'payment_failed') logger.info('Blocked org members due to payment failure', { invoiceType: invoiceType ?? 'subscription', diff --git a/apps/sim/lib/billing/webhooks/subscription.ts b/apps/sim/lib/billing/webhooks/subscription.ts index 13b6b0517c9..8a022d8624f 100644 --- a/apps/sim/lib/billing/webhooks/subscription.ts +++ b/apps/sim/lib/billing/webhooks/subscription.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { member, subscription } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -18,6 +19,28 @@ import { detachOrganizationWorkspaces } from '@/lib/workspaces/organization-work const logger = createLogger('StripeSubscriptionWebhooks') +/** + * Resolve a real `user.id` to use as the audit actor for a subscription + * event. Org-scoped subscriptions resolve to the org owner; personally + * scoped subscriptions already reference a user. + */ +async function resolveSubscriptionActorId(referenceId: string): Promise { + try { + const rows = await db + .select({ userId: member.userId }) + .from(member) + .where(and(eq(member.organizationId, referenceId), eq(member.role, 'owner'))) + .limit(1) + return rows[0]?.userId ?? referenceId + } catch (error) { + logger.warn('Failed to resolve subscription actor; falling back to reference id', { + referenceId, + error, + }) + return referenceId + } +} + /** * Restore personal Pro subscriptions for every member of an organization * when the team/enterprise subscription ends. Errors propagate so the @@ -253,6 +276,19 @@ export async function handleSubscriptionCreated(subscriptionData: { } if (wasFreePreviously && isPaidPlan) { + const actorId = await resolveSubscriptionActorId(subscriptionData.referenceId) + recordAudit({ + actorId, + action: AuditAction.SUBSCRIPTION_CREATED, + resourceType: AuditResourceType.SUBSCRIPTION, + resourceId: subscriptionData.id, + description: `Subscription created on ${subscriptionData.plan ?? 'unknown'} plan for ${subscriptionData.referenceId}`, + metadata: { + plan: subscriptionData.plan, + status: subscriptionData.status, + referenceId: subscriptionData.referenceId, + }, + }) captureServerEvent(subscriptionData.referenceId, 'subscription_created', { plan: subscriptionData.plan ?? 'unknown', status: subscriptionData.status, @@ -329,6 +365,19 @@ export async function handleSubscriptionDeleted( ...dormantResult, }) + const enterpriseActorId = await resolveSubscriptionActorId(subscription.referenceId) + recordAudit({ + actorId: enterpriseActorId, + action: AuditAction.SUBSCRIPTION_CANCELLED, + resourceType: AuditResourceType.SUBSCRIPTION, + resourceId: subscription.id, + description: `Enterprise subscription cancelled for ${subscription.referenceId}`, + metadata: { + plan: subscription.plan, + referenceId: subscription.referenceId, + kind: 'enterprise', + }, + }) captureServerEvent(subscription.referenceId, 'subscription_cancelled', { plan: subscription.plan ?? 'unknown', reference_id: subscription.referenceId, @@ -451,6 +500,19 @@ export async function handleSubscriptionDeleted( workspacesDetached, }) + const cancelActorId = await resolveSubscriptionActorId(subscription.referenceId) + recordAudit({ + actorId: cancelActorId, + action: AuditAction.SUBSCRIPTION_CANCELLED, + resourceType: AuditResourceType.SUBSCRIPTION, + resourceId: subscription.id, + description: `Subscription cancelled on ${subscription.plan ?? 'unknown'} plan for ${subscription.referenceId}`, + metadata: { + plan: subscription.plan, + referenceId: subscription.referenceId, + totalOverage, + }, + }) captureServerEvent(subscription.referenceId, 'subscription_cancelled', { plan: subscription.plan ?? 'unknown', reference_id: subscription.referenceId, diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index ddd8ba36252..ca81ebac0bb 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -45,6 +45,7 @@ import type { ExecutionContext, OrchestratorResult } from '@/lib/copilot/request import { persistChatResources } from '@/lib/copilot/resources/persistence' import { prepareExecutionContext } from '@/lib/copilot/tools/handlers/context' import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' +import { captureServerEvent } from '@/lib/posthog/server' import { resolveWorkflowIdForUser } from '@/lib/workflows/utils' import { getUserEntityPermissions, @@ -1076,6 +1077,19 @@ export async function handleUnifiedChatPost(req: NextRequest) { // all side-channel work on this request appear as child spans // of this same trace in Tempo instead of disconnected roots. // W3C traceparent format: `00---`. + captureServerEvent( + authenticatedUserId, + 'copilot_chat_sent', + { + workflow_id: branch.kind === 'workflow' ? branch.workflowId : '', + workspace_id: workspaceId ?? '', + has_file_attachments: (body.fileAttachments?.length ?? 0) > 0, + has_contexts: normalizedContexts.length > 0, + mode: branch.kind === 'workflow' ? branch.mode : 'agent', + }, + workspaceId ? { groups: { workspace: workspaceId } } : undefined + ) + const rootCtx = activeOtelRoot.span.spanContext() const rootTraceparent = `00-${rootCtx.traceId}-${rootCtx.spanId}-${ (rootCtx.traceFlags & 0x1) === 0x1 ? '01' : '00' diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts index 9387e71f93e..1e22c7aabef 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts @@ -1,6 +1,8 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { captureServerEvent } from '@/lib/posthog/server' import { deleteCustomTool, getCustomToolById, @@ -100,6 +102,30 @@ export async function executeManageCustomTool( }) const created = resultTools.find((tool) => tool.title === title) + recordAudit({ + workspaceId, + actorId: context.userId, + action: AuditAction.CUSTOM_TOOL_CREATED, + resourceType: AuditResourceType.CUSTOM_TOOL, + resourceId: created?.id, + resourceName: title, + description: `Created custom tool "${title}"`, + metadata: { source: 'tool_input' }, + }) + if (created?.id) { + captureServerEvent( + context.userId, + 'custom_tool_saved', + { + tool_id: created.id, + workspace_id: workspaceId, + tool_name: title, + source: 'tool_input', + }, + { groups: { workspace: workspaceId } } + ) + } + return { success: true, output: { @@ -148,6 +174,28 @@ export async function executeManageCustomTool( userId: context.userId, }) + recordAudit({ + workspaceId, + actorId: context.userId, + action: AuditAction.CUSTOM_TOOL_UPDATED, + resourceType: AuditResourceType.CUSTOM_TOOL, + resourceId: params.toolId, + resourceName: title, + description: `Updated custom tool "${title}"`, + metadata: { source: 'tool_input' }, + }) + captureServerEvent( + context.userId, + 'custom_tool_saved', + { + tool_id: params.toolId, + workspace_id: workspaceId, + tool_name: title, + source: 'tool_input', + }, + { groups: { workspace: workspaceId } } + ) + return { success: true, output: { @@ -182,6 +230,26 @@ export async function executeManageCustomTool( } } + for (const toolId of deleted) { + recordAudit({ + workspaceId: workspaceId ?? null, + actorId: context.userId, + action: AuditAction.CUSTOM_TOOL_DELETED, + resourceType: AuditResourceType.CUSTOM_TOOL, + resourceId: toolId, + description: 'Deleted custom tool', + metadata: { source: 'tool_input' }, + }) + if (workspaceId) { + captureServerEvent( + context.userId, + 'custom_tool_deleted', + { tool_id: toolId, workspace_id: workspaceId, source: 'tool_input' }, + { groups: { workspace: workspaceId } } + ) + } + } + return { success: deleted.length > 0, output: { diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts index a9e0da7ece0..dc551e382e3 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts @@ -1,6 +1,8 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { captureServerEvent } from '@/lib/posthog/server' import { deleteSkill, listSkills, upsertSkills } from '@/lib/workflows/skills/operations' const logger = createLogger('CopilotToolExecutor') @@ -79,6 +81,30 @@ export async function executeManageSkill( }) const created = resultSkills.find((s) => s.name === params.name) + recordAudit({ + workspaceId, + actorId: context.userId, + action: AuditAction.SKILL_CREATED, + resourceType: AuditResourceType.SKILL, + resourceId: created?.id, + resourceName: params.name, + description: `Created skill "${params.name}"`, + metadata: { source: 'tool_input' }, + }) + if (created?.id) { + captureServerEvent( + context.userId, + 'skill_created', + { + skill_id: created.id, + skill_name: params.name, + workspace_id: workspaceId, + source: 'tool_input', + }, + { groups: { workspace: workspaceId } } + ) + } + return { success: true, output: { @@ -121,6 +147,29 @@ export async function executeManageSkill( userId: context.userId, }) + const updatedName = params.name || found.name + recordAudit({ + workspaceId, + actorId: context.userId, + action: AuditAction.SKILL_UPDATED, + resourceType: AuditResourceType.SKILL, + resourceId: params.skillId, + resourceName: updatedName, + description: `Updated skill "${updatedName}"`, + metadata: { source: 'tool_input' }, + }) + captureServerEvent( + context.userId, + 'skill_updated', + { + skill_id: params.skillId, + skill_name: updatedName, + workspace_id: workspaceId, + source: 'tool_input', + }, + { groups: { workspace: workspaceId } } + ) + return { success: true, output: { @@ -143,6 +192,22 @@ export async function executeManageSkill( return { success: false, error: `Skill not found: ${params.skillId}` } } + recordAudit({ + workspaceId, + actorId: context.userId, + action: AuditAction.SKILL_DELETED, + resourceType: AuditResourceType.SKILL, + resourceId: params.skillId, + description: 'Deleted skill', + metadata: { source: 'tool_input' }, + }) + captureServerEvent( + context.userId, + 'skill_deleted', + { skill_id: params.skillId, workspace_id: workspaceId, source: 'tool_input' }, + { groups: { workspace: workspaceId } } + ) + return { success: true, output: { diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.ts b/apps/sim/lib/copilot/tools/server/table/user-table.ts index ef1a8fed1b2..f23d2ec83f0 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' @@ -9,6 +10,7 @@ import { } from '@/lib/copilot/tools/server/base-tool' import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' import { runDetached } from '@/lib/core/utils/background' +import { captureServerEvent } from '@/lib/posthog/server' import { buildAutoMapping, COLUMN_TYPES, @@ -373,6 +375,17 @@ export const userTableServerTool: BaseServerTool requestId ) + recordAudit({ + workspaceId, + actorId: context.userId, + action: AuditAction.TABLE_CREATED, + resourceType: AuditResourceType.TABLE, + resourceId: table.id, + resourceName: table.name, + description: `Created table "${table.name}"`, + metadata: { source: 'tool_input' }, + }) + return { success: true, message: `Created table "${table.name}" (${table.id})`, @@ -445,7 +458,13 @@ export const userTableServerTool: BaseServerTool const requestId = generateId().slice(0, 8) assertNotAborted() - await deleteTable(tableId, requestId) + await deleteTable(tableId, requestId, context.userId) + captureServerEvent( + context.userId, + 'table_deleted', + { table_id: tableId, workspace_id: workspaceId }, + { groups: { workspace: workspaceId } } + ) deleted.push(tableId) } @@ -894,6 +913,21 @@ export const userTableServerTool: BaseServerTool await releaseJobClaim(table.id, inlineDeleteId).catch(() => {}) } + recordAudit({ + workspaceId, + actorId: context.userId, + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: table.id, + resourceName: table.name, + description: `Deleted ${result.affectedCount} row(s) from table "${table.name}"`, + metadata: { + op: 'bulk_delete', + rowsDeleted: result.affectedCount, + source: 'tool_input', + }, + }) + return { success: true, message: `Deleted ${result.affectedCount} rows`, @@ -996,6 +1030,16 @@ export const userTableServerTool: BaseServerTool requestId ) + recordAudit({ + workspaceId, + actorId: context.userId, + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: args.tableId, + description: `Deleted ${result.deletedCount} row(s)`, + metadata: { op: 'bulk_delete', rowsDeleted: result.deletedCount, source: 'tool_input' }, + }) + return { success: true, message: `Deleted ${result.deletedCount} rows`, @@ -1530,7 +1574,7 @@ export const userTableServerTool: BaseServerTool const requestId = generateId().slice(0, 8) assertNotAborted() - const renamed = await renameTable(args.tableId, newName, requestId) + const renamed = await renameTable(args.tableId, newName, requestId, context.userId) return { success: true, diff --git a/apps/sim/lib/invitations/core.test.ts b/apps/sim/lib/invitations/core.test.ts index 02cfaa0b2cc..6168529fced 100644 --- a/apps/sim/lib/invitations/core.test.ts +++ b/apps/sim/lib/invitations/core.test.ts @@ -316,6 +316,7 @@ describe('acceptInvitation', () => { expect(mockReconcileOrganizationSeats).toHaveBeenCalledWith({ organizationId: 'org-new', reason: 'member-accepted-invite', + actorId: 'invitee-user', }) expect(mockSetActiveOrganizationForCurrentSession).toHaveBeenCalledWith('org-new') }) diff --git a/apps/sim/lib/invitations/core.ts b/apps/sim/lib/invitations/core.ts index ae99f6f3b11..2e245865831 100644 --- a/apps/sim/lib/invitations/core.ts +++ b/apps/sim/lib/invitations/core.ts @@ -374,6 +374,25 @@ export async function acceptInvitation( } else { membershipAlreadyExists = membershipResult.alreadyMember + if (!membershipResult.alreadyMember) { + const memberRole = inv.role || 'member' + recordAudit({ + workspaceId: null, + actorId: input.userId, + action: AuditAction.ORG_MEMBER_ADDED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: targetOrganizationId, + description: `Joined organization as ${memberRole} via invite acceptance`, + metadata: { invitationId: inv.id, memberRole }, + }) + captureServerEvent( + input.userId, + 'org_member_added', + { organization_id: targetOrganizationId, member_role: memberRole }, + { groups: { organization: targetOrganizationId } } + ) + } + // Grow the paid seat count to match the new member and push the charge // to Stripe asynchronously (Team plans only; Enterprise seats are // fixed). Best-effort: the member is already in, and a transient @@ -381,35 +400,11 @@ export async function acceptInvitation( // removal path's seat accounting. if (billingManagesSeats && !membershipResult.alreadyMember) { try { - const seatResult = await reconcileOrganizationSeats({ + await reconcileOrganizationSeats({ organizationId: targetOrganizationId, reason: 'member-accepted-invite', + actorId: input.userId, }) - - if (seatResult.changed) { - const previousSeats = seatResult.previousSeats ?? 0 - const seats = seatResult.seats ?? 0 - recordAudit({ - workspaceId: null, - actorId: input.userId, - action: AuditAction.ORG_SEAT_PROVISIONED, - resourceType: AuditResourceType.ORGANIZATION, - resourceId: targetOrganizationId, - description: `Provisioned ${seats} seat(s) after invite acceptance`, - metadata: { - invitationId: inv.id, - previousSeats, - seats, - reason: 'member-accepted-invite', - }, - }) - captureServerEvent(input.userId, 'seats_provisioned', { - organization_id: targetOrganizationId, - previous_seats: previousSeats, - seats, - reason: 'member-accepted-invite', - }) - } } catch (seatError) { logger.error('Failed to reconcile organization seats after invite acceptance', { userId: input.userId, diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index 8accb7a6e85..0ac61339db9 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -15,6 +15,11 @@ import { tasks } from '@trigger.dev/sdk' import { and, asc, desc, eq, inArray, isNotNull, isNull, type SQL, sql } from 'drizzle-orm' import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor' import { recordUsage } from '@/lib/billing/core/usage-log' +import { + checkStorageQuota, + decrementStorageUsage, + incrementStorageUsage, +} from '@/lib/billing/storage' import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing' import type { ChunkingStrategy, StrategyOptions } from '@/lib/chunkers/types' import { resolveTriggerRegion } from '@/lib/core/async-jobs/region' @@ -870,7 +875,9 @@ export async function createDocumentRecords( requestId: string, uploadedBy: string | null = null ): Promise { - return await db.transaction(async (tx) => { + let storageBilling: { userId: string; workspaceId: string | null; bytes: number } | null = null + + const returnData = await db.transaction(async (tx) => { await tx.execute(sql`SELECT 1 FROM knowledge_base WHERE id = ${knowledgeBaseId} FOR UPDATE`) const kb = await tx @@ -896,6 +903,17 @@ export async function createDocumentRecords( tx ) + // Bill stored source bytes to the uploader when known, else the KB owner. + const billedUserId = uploadedBy ?? kb[0].userId + const totalBytes = documents.reduce((sum, docData) => sum + (docData.fileSize || 0), 0) + if (totalBytes > 0) { + const quotaCheck = await checkStorageQuota(billedUserId, totalBytes) + if (!quotaCheck.allowed) { + throw new Error(quotaCheck.error || 'Storage limit exceeded') + } + storageBilling = { userId: billedUserId, workspaceId: kbWorkspaceId, bytes: totalBytes } + } + // One load per batch (was N+1); skip entirely if no doc carries tags. const hasTaggedDocs = documents.some((d) => d.documentTagsData) const tagDefinitions = hasTaggedDocs @@ -984,6 +1002,17 @@ export async function createDocumentRecords( return returnData }) + + if (storageBilling) { + const billing: { userId: string; workspaceId: string | null; bytes: number } = storageBilling + try { + await incrementStorageUsage(billing.userId, billing.bytes, billing.workspaceId ?? undefined) + } catch (storageError) { + logger.error(`[${requestId}] Failed to update storage tracking:`, storageError) + } + } + + return returnData } export async function getDocuments( @@ -1302,6 +1331,8 @@ export async function createSingleDocument( ...processedTags, } + let storageBilling: { userId: string; workspaceId: string | null; bytes: number } | null = null + await db.transaction(async (tx) => { await tx.execute(sql`SELECT 1 FROM knowledge_base WHERE id = ${knowledgeBaseId} FOR UPDATE`) @@ -1327,6 +1358,20 @@ export async function createSingleDocument( tx ) + // Bill stored source bytes to the uploader when known, else the KB owner. + const billedUserId = uploadedBy ?? kb[0].userId + if (documentData.fileSize > 0) { + const quotaCheck = await checkStorageQuota(billedUserId, documentData.fileSize) + if (!quotaCheck.allowed) { + throw new Error(quotaCheck.error || 'Storage limit exceeded') + } + storageBilling = { + userId: billedUserId, + workspaceId: kb[0].workspaceId, + bytes: documentData.fileSize, + } + } + await tx.insert(document).values(newDocument) await tx @@ -1334,6 +1379,16 @@ export async function createSingleDocument( .set({ updatedAt: now }) .where(eq(knowledgeBase.id, knowledgeBaseId)) }) + + if (storageBilling) { + const billing: { userId: string; workspaceId: string | null; bytes: number } = storageBilling + try { + await incrementStorageUsage(billing.userId, billing.bytes, billing.workspaceId ?? undefined) + } catch (storageError) { + logger.error(`[${requestId}] Failed to update storage tracking:`, storageError) + } + } + logger.info(`[${requestId}] Document created: ${documentId} in knowledge base ${knowledgeBaseId}`) return newDocument as { @@ -1980,7 +2035,11 @@ export async function hardDeleteDocuments( .select({ id: document.id, fileUrl: document.fileUrl, + fileSize: document.fileSize, + uploadedBy: document.uploadedBy, + connectorId: document.connectorId, workspaceId: knowledgeBase.workspaceId, + kbUserId: knowledgeBase.userId, }) .from(document) .innerJoin(knowledgeBase, eq(document.knowledgeBaseId, knowledgeBase.id)) @@ -1999,6 +2058,21 @@ export async function hardDeleteDocuments( await deleteDocumentStorageFiles(documentsToDelete, requestId) + for (const doc of documentsToDelete) { + // Connector-synced documents are never metered at ingest (the sync engine + // inserts them directly), so they must not be decremented here — doing so + // would erode legitimately-counted bytes in the same owner counter. + if (doc.connectorId) continue + const billedUserId = doc.uploadedBy ?? doc.kbUserId + if (billedUserId && doc.fileSize > 0) { + try { + await decrementStorageUsage(billedUserId, doc.fileSize, doc.workspaceId ?? undefined) + } catch (storageError) { + logger.error(`[${requestId}] Failed to update storage tracking:`, storageError) + } + } + } + logger.info(`[${requestId}] Hard deleted ${existingIds.length} documents`, { documentIds: existingIds, }) diff --git a/apps/sim/lib/posthog/events.ts b/apps/sim/lib/posthog/events.ts index a408325000e..f537e17d0c2 100644 --- a/apps/sim/lib/posthog/events.ts +++ b/apps/sim/lib/posthog/events.ts @@ -292,14 +292,16 @@ export interface PostHogEventMap { } api_key_created: { - workspace_id: string + workspace_id?: string key_name: string + scope?: 'workspace' | 'personal' source?: 'settings' | 'deploy_modal' } api_key_revoked: { - workspace_id: string + workspace_id?: string key_name: string + scope?: 'workspace' | 'personal' } mcp_server_connected: { @@ -339,13 +341,15 @@ export interface PostHogEventMap { } environment_updated: { - workspace_id: string + workspace_id?: string key_count: number + scope?: 'workspace' | 'personal' } environment_deleted: { - workspace_id: string + workspace_id?: string key_count: number + scope?: 'workspace' | 'personal' } seats_provisioned: { @@ -653,6 +657,128 @@ export interface PostHogEventMap { file_name: string file_size: number } + + organization_created: { + organization_id: string + name: string + } + + /** Org membership lifecycle (distinct from workspace-level membership). */ + org_member_added: { + organization_id: string + member_role: string + } + + org_member_removed: { + organization_id: string + is_self_removal: boolean + } + + org_member_role_changed: { + organization_id: string + new_role: string + } + + /** Org seat count decreased (member removal / drift reconciliation). */ + seats_deprovisioned: { + organization_id: string + previous_seats: number + seats: number + reason: string + } + + /** A workflow's edit-lock was toggled on or off. */ + workflow_lock_toggled: { + workflow_id: string + workspace_id: string + locked: boolean + } + + workflow_schedule_created: { + workflow_id: string + workspace_id: string + } + + workflow_schedule_deleted: { + workflow_id: string + workspace_id: string + } + + /** Recurring runtime KB retrieval — the core knowledge-base usage signal. */ + knowledge_base_searched: { + knowledge_base_id: string + workspace_id: string + result_count: number + top_k: number + reranking_enabled: boolean + duration_ms: number + } + + /** A stored credential's plaintext secret was deliberately retrieved via the token API. */ + credential_used: { + credential_type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' + provider_id: string + workspace_id?: string + } + + payment_succeeded: { + plan: string + amount: number + currency: string + entity_type: 'user' | 'organization' + reference_id: string + } + + payment_failed: { + plan: string + amount: number + currency: string + entity_type: 'user' | 'organization' + reference_id: string + attempt_count: number + } + + overage_billed: { + amount: number + currency: string + entity_type: 'user' | 'organization' + reference_id: string + } + + credits_purchased: { + amount: number + currency: string + entity_type: 'user' | 'organization' + reference_id: string + } + + charge_disputed: { + amount: number + currency: string + reason: string + status: 'opened' | 'closed' + entity_type: 'user' | 'organization' + reference_id: string + } + + plan_converted: { + organization_id: string + from_plan: string + to_plan: string + } + + enterprise_subscription_created: { + reference_id: string + seats: number + monthly_price: number + currency: string + } + + subscription_transferred: { + subscription_id: string + from_entity: 'user' | 'organization' + to_entity: 'user' | 'organization' + } } export type PostHogEventName = keyof PostHogEventMap diff --git a/apps/sim/lib/table/export-runner.ts b/apps/sim/lib/table/export-runner.ts index 6c72a01ff20..8d2bfa9f52a 100644 --- a/apps/sim/lib/table/export-runner.ts +++ b/apps/sim/lib/table/export-runner.ts @@ -1,6 +1,8 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { captureServerEvent } from '@/lib/posthog/server' import { buildNameById, getColumnId, rowDataIdToName } from '@/lib/table/column-keys' import { appendTableEvent } from '@/lib/table/events' import { @@ -37,6 +39,8 @@ export interface TableExportPayload { tableId: string workspaceId: string format: 'csv' | 'json' + /** The user who requested the export; attributes the audit/analytics record. */ + userId?: string } /** @@ -49,7 +53,7 @@ export interface TableExportPayload { * scratch and overwrites nothing (fresh key per attempt; failures clean up their partial upload). */ export async function runTableExport(payload: TableExportPayload): Promise { - const { jobId, tableId, workspaceId, format } = payload + const { jobId, tableId, workspaceId, format, userId } = payload const requestId = generateId().slice(0, 8) let handle: MultipartUploadHandle | null = null let uploadedKey: string | null = null @@ -128,6 +132,26 @@ export async function runTableExport(payload: TableExportPayload): Promise progress: exported, }) logger.info(`[${requestId}] Export complete`, { tableId, rows: exported, format }) + + const actorId = userId ?? table.createdBy + if (actorId) { + recordAudit({ + workspaceId, + actorId, + action: AuditAction.TABLE_EXPORTED, + resourceType: AuditResourceType.TABLE, + resourceId: tableId, + resourceName: table.name, + description: `Exported table "${table.name}" as ${format.toUpperCase()}`, + metadata: { format, rowCount: exported, async: true }, + }) + captureServerEvent( + actorId, + 'table_exported', + { table_id: tableId, workspace_id: workspaceId }, + { groups: { workspace: workspaceId } } + ) + } } else { // Canceled at the very end — the file is orphaned; remove it (janitor would otherwise // only catch it via the pruned job's resultKey). diff --git a/apps/sim/lib/table/import-data.ts b/apps/sim/lib/table/import-data.ts index 52343113892..43e697d634f 100644 --- a/apps/sim/lib/table/import-data.ts +++ b/apps/sim/lib/table/import-data.ts @@ -164,7 +164,7 @@ export async function importAppendRows( // the order and deadlocking concurrent inserts on this table. The lock is // re-entrant, so the per-batch acquire below is a no-op. await acquireRowOrderLock(trx, table.id) - working = await addTableColumnsWithTx(trx, table, additions, ctx.requestId) + working = await addTableColumnsWithTx(trx, table, additions, ctx.requestId, ctx.userId) } const inserted: TableRow[] = [] for (let i = 0; i < rows.length; i += CSV_MAX_BATCH_SIZE) { @@ -209,7 +209,7 @@ export async function importReplaceRows( let working = table if (additions.length > 0) { await acquireRowOrderLock(trx, table.id) - working = await addTableColumnsWithTx(trx, table, additions, requestId) + working = await addTableColumnsWithTx(trx, table, additions, requestId, data.userId) } return replaceTableRowsWithTx( trx, diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 0f70cbab1aa..9c015d5eb13 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -7,6 +7,7 @@ * Note: API routes have their own implementations for HTTP-specific concerns. */ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { tableJobs, userTableDefinitions, userTableRows } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -282,8 +283,6 @@ export async function createTable( ? { id: data.jobId, type: data.jobType ?? 'import', startedAt: now } : null - // Starter rows count against the plan too. Checked before the tx (the lookup is a - // separate pool read) — a new table starts empty, so the footprint is just these. const initialRowCount = data.initialRowCount ?? 0 let rowLimit: number | undefined if (initialRowCount > 0) { @@ -416,7 +415,8 @@ export async function addTableColumnsWithTx( trx: DbTransaction, table: TableDefinition, columns: { id?: string; name: string; type: string; required?: boolean; unique?: boolean }[], - requestId: string + requestId: string, + actingUserId?: string ): Promise { if (columns.length === 0) return table @@ -479,6 +479,20 @@ export async function addTableColumnsWithTx( `[${requestId}] Added ${additions.length} column(s) to table ${table.id}: ${additions.map((c) => c.name).join(', ')}` ) + const columnActorId = actingUserId ?? table.createdBy + if (columnActorId) { + recordAudit({ + workspaceId: table.workspaceId ?? null, + actorId: columnActorId, + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: table.id, + resourceName: table.name, + description: `Added ${additions.length} column(s) to table "${table.name}"`, + metadata: { op: 'add_columns', columns: additions.map((c) => c.name) }, + }) + } + return { ...table, schema: updatedSchema, @@ -498,7 +512,8 @@ export async function addTableColumnsWithTx( export async function renameTable( tableId: string, newName: string, - requestId: string + requestId: string, + actingUserId?: string ): Promise<{ id: string; name: string }> { const nameValidation = validateTableName(newName) if (!nameValidation.valid) { @@ -511,12 +526,31 @@ export async function renameTable( .update(userTableDefinitions) .set({ name: newName, updatedAt: now }) .where(eq(userTableDefinitions.id, tableId)) - .returning({ id: userTableDefinitions.id }) + .returning({ + id: userTableDefinitions.id, + createdBy: userTableDefinitions.createdBy, + workspaceId: userTableDefinitions.workspaceId, + }) if (result.length === 0) { throw new Error(`Table ${tableId} not found`) } + const { createdBy, workspaceId } = result[0] + const renameActorId = actingUserId ?? createdBy + if (renameActorId) { + recordAudit({ + workspaceId: workspaceId ?? null, + actorId: renameActorId, + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: tableId, + resourceName: newName, + description: `Renamed table to "${newName}"`, + metadata: { op: 'rename' }, + }) + } + logger.info(`[${requestId}] Renamed table ${tableId} to "${newName}"`) return { id: tableId, name: newName } } catch (error: unknown) { @@ -598,11 +632,38 @@ export async function updateTableMetadata( * @param tableId - Table ID to delete * @param requestId - Request ID for logging */ -export async function deleteTable(tableId: string, requestId: string): Promise { - await db +export async function deleteTable( + tableId: string, + requestId: string, + actingUserId?: string +): Promise { + const now = new Date() + const result = await db .update(userTableDefinitions) - .set({ archivedAt: new Date(), updatedAt: new Date() }) + .set({ archivedAt: now, updatedAt: now }) .where(eq(userTableDefinitions.id, tableId)) + .returning({ + createdBy: userTableDefinitions.createdBy, + workspaceId: userTableDefinitions.workspaceId, + name: userTableDefinitions.name, + }) + + const deleted = result[0] + // Audit only genuine user-initiated deletes — rollback/cleanup callers omit + // `actingUserId`. The `table_deleted` PostHog event is emitted by the caller + // (route handler / copilot tool) where the acting user is known, so it is not + // emitted here to avoid double-counting. + if (deleted && actingUserId) { + recordAudit({ + workspaceId: deleted.workspaceId ?? null, + actorId: actingUserId, + action: AuditAction.TABLE_DELETED, + resourceType: AuditResourceType.TABLE, + resourceId: tableId, + resourceName: deleted.name, + description: `Archived table "${deleted.name}"`, + }) + } logger.info(`[${requestId}] Archived table ${tableId}`) } diff --git a/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts b/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts index 2e9b3fdf93d..d13250b7644 100644 --- a/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts @@ -1,4 +1,9 @@ import { createLogger } from '@sim/logger' +import { + checkStorageQuota, + decrementStorageUsage, + incrementStorageUsage, +} from '@/lib/billing/storage' import { getBaseUrl } from '@/lib/core/utils/urls' import { deleteFile, @@ -7,6 +12,7 @@ import { generatePresignedUploadUrl, uploadFile, } from '@/lib/uploads/core/storage-service' +import { deleteFileMetadata, getFileMetadataByKey } from '@/lib/uploads/server/metadata' import type { PresignedUrlResponse } from '@/lib/uploads/shared/types' import { isImageFileType } from '@/lib/uploads/utils/file-utils' @@ -116,6 +122,11 @@ export async function uploadCopilotFile(options: { contentType: string userId: string }): Promise { + const quotaCheck = await checkStorageQuota(options.userId, options.buffer.length) + if (!quotaCheck.allowed) { + throw new Error(quotaCheck.error || 'Storage limit exceeded') + } + const fileInfo = await uploadFile({ file: options.buffer, fileName: options.fileName, @@ -137,6 +148,12 @@ export async function uploadCopilotFile(options: { userId: options.userId, }) + try { + await incrementStorageUsage(options.userId, options.buffer.length) + } catch (storageError) { + logger.error('Failed to update storage tracking:', storageError) + } + return { id: fileInfo.key, key: fileInfo.key, @@ -239,10 +256,26 @@ export async function generateCopilotDownloadUrl( * @param key File storage key */ export async function deleteCopilotFile(key: string): Promise { + // Storage accounting is best-effort: reading metadata must never prevent the + // file delete (the primary operation). + const metadata = await getFileMetadataByKey(key, 'copilot').catch((error) => { + logger.error('Failed to read copilot file metadata for storage accounting:', error) + return null + }) + await deleteFile({ key, context: 'copilot', }) + if (metadata) { + try { + await deleteFileMetadata(key) + await decrementStorageUsage(metadata.userId, metadata.size, metadata.workspaceId ?? undefined) + } catch (storageError) { + logger.error('Failed to update storage tracking:', storageError) + } + } + logger.info(`Successfully deleted copilot file: ${key}`) } diff --git a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts index d3c38cdbbb0..245dff21dd4 100644 --- a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts @@ -7,6 +7,7 @@ import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, isNull, min, ne } from 'drizzle-orm' import { generateRequestId } from '@/lib/core/utils/request' +import { captureServerEvent } from '@/lib/posthog/server' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' import { archiveWorkflow, restoreWorkflow } from '@/lib/workflows/lifecycle' import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types' @@ -51,6 +52,8 @@ export interface PerformUpdateWorkflowParams { workspaceId: string currentName: string currentFolderId?: string | null + /** Prior `locked` value, used to detect lock-state transitions for instrumentation. */ + currentLocked?: boolean | null name?: string description?: string | null folderId?: string | null @@ -338,6 +341,27 @@ export async function performUpdateWorkflow( updates: updateData, }) + if (params.locked !== undefined && params.locked !== (params.currentLocked ?? false)) { + const workspaceId = updatedWorkflow.workspaceId + recordAudit({ + workspaceId: workspaceId ?? null, + actorId: params.userId, + action: params.locked ? AuditAction.WORKFLOW_LOCKED : AuditAction.WORKFLOW_UNLOCKED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: params.workflowId, + resourceName: updatedWorkflow.name, + description: `${params.locked ? 'Locked' : 'Unlocked'} workflow "${updatedWorkflow.name}"`, + metadata: { locked: params.locked }, + }) + + captureServerEvent( + params.userId, + 'workflow_lock_toggled', + { workflow_id: params.workflowId, workspace_id: workspaceId ?? '', locked: params.locked }, + workspaceId ? { groups: { workspace: workspaceId } } : undefined + ) + } + return { success: true, workflow: updatedWorkflow } } catch (error) { logger.error(`[${requestId}] Failed to update workflow ${params.workflowId}`, { error }) diff --git a/apps/sim/lib/workflows/schedules/orchestration.ts b/apps/sim/lib/workflows/schedules/orchestration.ts index 36a9662e1ee..47da9c5e71d 100644 --- a/apps/sim/lib/workflows/schedules/orchestration.ts +++ b/apps/sim/lib/workflows/schedules/orchestration.ts @@ -255,6 +255,15 @@ export async function performCreateJob( { groups: { workspace: params.workspaceId } } ) + if (schedule?.workflowId) { + captureServerEvent( + params.userId, + 'workflow_schedule_created', + { workflow_id: schedule.workflowId, workspace_id: params.workspaceId }, + { groups: { workspace: params.workspaceId } } + ) + } + return { success: true, schedule, humanReadable } } catch (error) { logger.error('Failed to create job', { error: toError(error).message }) @@ -503,6 +512,15 @@ export async function performDeleteJob( { groups: { workspace: params.workspaceId } } ) + if (job.workflowId) { + captureServerEvent( + params.userId, + 'workflow_schedule_deleted', + { workflow_id: job.workflowId, workspace_id: params.workspaceId }, + { groups: { workspace: params.workspaceId } } + ) + } + return { success: true, schedule: job } } diff --git a/packages/audit/src/index.ts b/packages/audit/src/index.ts index f3ec73f8c5d..09129ed1f18 100644 --- a/packages/audit/src/index.ts +++ b/packages/audit/src/index.ts @@ -1,3 +1,3 @@ -export { recordAudit } from './log' +export { recordAudit, recordAuditNow } from './log' export type { AuditActionType, AuditResourceTypeValue } from './types' export { AuditAction, AuditResourceType } from './types' diff --git a/packages/audit/src/log.test.ts b/packages/audit/src/log.test.ts index 7f294499fb6..e7495232943 100644 --- a/packages/audit/src/log.test.ts +++ b/packages/audit/src/log.test.ts @@ -340,7 +340,7 @@ describe('recordAudit', () => { ) }) - it('sets actor info to null when user is not found', async () => { + it('nulls the actor FK and labels it System when the user is not found', async () => { dbChainMockFns.limit.mockResolvedValue([]) recordAudit({ @@ -355,8 +355,29 @@ describe('recordAudit', () => { expect(dbChainMockFns.select).toHaveBeenCalledTimes(1) expect(dbChainMockFns.values).toHaveBeenCalledWith( expect.objectContaining({ - actorId: 'deleted-user', - actorName: undefined, + actorId: null, + actorName: 'System', + actorEmail: undefined, + }) + ) + }) + + it('labels the admin-api system actor while nulling its FK', async () => { + dbChainMockFns.limit.mockResolvedValue([]) + + recordAudit({ + workspaceId: 'ws-1', + actorId: 'admin-api', + action: AuditAction.WORKFLOW_DELETED, + resourceType: AuditResourceType.WORKFLOW, + }) + + await flush() + + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: null, + actorName: 'Admin API', actorEmail: undefined, }) ) diff --git a/packages/audit/src/log.ts b/packages/audit/src/log.ts index 4ee040ba43a..0cb6afa0c91 100644 --- a/packages/audit/src/log.ts +++ b/packages/audit/src/log.ts @@ -38,30 +38,57 @@ export function recordAudit(params: AuditLogParams): void { }) } +/** + * Awaitable audit log write for callers that must guarantee the row is persisted + * before they proceed — e.g. inside a pre-delete hook where the referenced + * `user` row is about to be removed and a deferred insert would race the delete + * (FK violation). Never throws; failures are logged. + */ +export async function recordAuditNow(params: AuditLogParams): Promise { + try { + await insertAuditLog(params) + } catch (error) { + logger.error('Failed to record audit log', { error, action: params.action }) + } +} + async function insertAuditLog(params: AuditLogParams): Promise { const ipAddress = params.request ? getClientIp(params.request) : undefined const userAgent = params.request?.headers.get('user-agent') ?? undefined let { actorName, actorEmail } = params - if (actorName === undefined && actorEmail === undefined && params.actorId) { + /** + * `actorId` is a FK to `user.id`. System actors (e.g. the shared `'admin-api'` + * key) have no user row, so we persist a null FK with a readable label instead + * of letting the insert fail. When the caller already supplies actorName/Email + * we trust the id is a real user and skip the lookup. + */ + let actorId: string | null = params.actorId + + if (actorName === undefined && actorEmail === undefined && actorId) { try { const [row] = await db .select({ name: user.name, email: user.email }) .from(user) - .where(eq(user.id, params.actorId)) + .where(eq(user.id, actorId)) .limit(1) - actorName = row?.name ?? undefined - actorEmail = row?.email ?? undefined + if (row) { + actorName = row.name ?? undefined + actorEmail = row.email ?? undefined + } else { + actorName = actorId === 'admin-api' ? 'Admin API' : 'System' + actorId = null + } } catch (error) { - logger.warn('Failed to resolve actor info', { error, actorId: params.actorId }) + logger.warn('Failed to resolve actor info', { error, actorId }) } } await db.insert(auditLog).values({ id: generateShortId(), workspaceId: params.workspaceId || null, - actorId: params.actorId, + actorId, action: params.action, resourceType: params.resourceType, resourceId: params.resourceId, diff --git a/packages/audit/src/types.ts b/packages/audit/src/types.ts index 0d5bbe02388..b695234217c 100644 --- a/packages/audit/src/types.ts +++ b/packages/audit/src/types.ts @@ -2,6 +2,15 @@ * All auditable actions in the platform, grouped by resource type. */ export const AuditAction = { + // Account / Authentication + USER_LOGIN: 'user.login', + USER_LOGIN_FAILED: 'user.login_failed', + USER_SIGNIN_BLOCKED: 'user.signin_blocked', + USER_LOGOUT: 'user.logout', + SESSION_REVOKED: 'session.revoked', + ACCOUNT_DELETED: 'account.deleted', + ACCOUNT_EMAIL_CHANGED: 'account.email_changed', + // API Keys API_KEY_CREATED: 'api_key.created', API_KEY_UPDATED: 'api_key.updated', @@ -33,6 +42,19 @@ export const AuditAction = { // Billing CREDIT_PURCHASED: 'credit.purchased', + CREDIT_ISSUED: 'credit.issued', + INVOICE_PAYMENT_SUCCEEDED: 'invoice.payment_succeeded', + INVOICE_PAYMENT_FAILED: 'invoice.payment_failed', + OVERAGE_BILLED: 'billing.overage_billed', + CHARGE_DISPUTE_OPENED: 'charge.dispute_opened', + CHARGE_DISPUTE_CLOSED: 'charge.dispute_closed', + + // Subscriptions + SUBSCRIPTION_CREATED: 'subscription.created', + SUBSCRIPTION_UPDATED: 'subscription.updated', + SUBSCRIPTION_CANCELLED: 'subscription.cancelled', + SUBSCRIPTION_TRANSFERRED: 'subscription.transferred', + ENTERPRISE_SUBSCRIPTION_PROVISIONED: 'subscription.enterprise_provisioned', // Credential Sets CREDENTIAL_SET_CREATED: 'credential_set.created', @@ -66,6 +88,7 @@ export const AuditAction = { FILE_MOVED: 'file.moved', FILE_SHARED: 'file.shared', FILE_SHARE_DISABLED: 'file.share_disabled', + FILE_DOWNLOADED: 'file.downloaded', // Folders FOLDER_CREATED: 'folder.created', @@ -112,6 +135,7 @@ export const AuditAction = { CREDENTIAL_RENAMED: 'credential.renamed', CREDENTIAL_RECONNECTED: 'credential.reconnected', CREDENTIAL_DELETED: 'credential.deleted', + CREDENTIAL_ACCESSED: 'credential.accessed', CREDENTIAL_MEMBER_ADDED: 'credential_member.added', CREDENTIAL_MEMBER_REMOVED: 'credential_member.removed', CREDENTIAL_MEMBER_ROLE_CHANGED: 'credential_member.role_changed', @@ -135,6 +159,7 @@ export const AuditAction = { ORG_INVITATION_REVOKED: 'org_invitation.revoked', ORG_INVITATION_RESENT: 'org_invitation.resent', ORG_SEAT_PROVISIONED: 'org_seat.provisioned', + ORG_SEAT_DEPROVISIONED: 'org_seat.deprovisioned', ORG_PLAN_CONVERTED: 'org_plan.converted', // Permission Groups @@ -159,6 +184,7 @@ export const AuditAction = { TABLE_UPDATED: 'table.updated', TABLE_DELETED: 'table.deleted', TABLE_RESTORED: 'table.restored', + TABLE_EXPORTED: 'table.exported', // Webhooks WEBHOOK_CREATED: 'webhook.created', @@ -176,6 +202,8 @@ export const AuditAction = { WORKFLOW_LOCKED: 'workflow.locked', WORKFLOW_UNLOCKED: 'workflow.unlocked', WORKFLOW_VARIABLES_UPDATED: 'workflow.variables_updated', + WORKFLOW_PUBLIC_API_TOGGLED: 'workflow.public_api_toggled', + WORKFLOW_EXPORTED: 'workflow.exported', // Workspaces WORKSPACE_CREATED: 'workspace.created', @@ -185,6 +213,7 @@ export const AuditAction = { WORKSPACE_FORKED: 'workspace.forked', WORKSPACE_FORK_PROMOTED: 'workspace.fork_promoted', WORKSPACE_FORK_ROLLED_BACK: 'workspace.fork_rolled_back', + WORKSPACE_EXPORTED: 'workspace.exported', } as const export type AuditActionType = (typeof AuditAction)[keyof typeof AuditAction] @@ -213,8 +242,11 @@ export const AuditResourceType = { PASSWORD: 'password', PERMISSION_GROUP: 'permission_group', SCHEDULE: 'schedule', + SESSION: 'session', SKILL: 'skill', + SUBSCRIPTION: 'subscription', TABLE: 'table', + USER: 'user', WEBHOOK: 'webhook', WORKFLOW: 'workflow', WORKSPACE: 'workspace', diff --git a/packages/testing/src/mocks/audit.mock.ts b/packages/testing/src/mocks/audit.mock.ts index d7a16b82364..2baa3fa326a 100644 --- a/packages/testing/src/mocks/audit.mock.ts +++ b/packages/testing/src/mocks/audit.mock.ts @@ -157,6 +157,31 @@ export const auditMock = { WORKSPACE_FORKED: 'workspace.forked', WORKSPACE_FORK_PROMOTED: 'workspace.fork_promoted', WORKSPACE_FORK_ROLLED_BACK: 'workspace.fork_rolled_back', + WORKSPACE_EXPORTED: 'workspace.exported', + USER_LOGIN: 'user.login', + USER_LOGIN_FAILED: 'user.login_failed', + USER_SIGNIN_BLOCKED: 'user.signin_blocked', + USER_LOGOUT: 'user.logout', + SESSION_REVOKED: 'session.revoked', + ACCOUNT_DELETED: 'account.deleted', + ACCOUNT_EMAIL_CHANGED: 'account.email_changed', + CREDIT_ISSUED: 'credit.issued', + INVOICE_PAYMENT_SUCCEEDED: 'invoice.payment_succeeded', + INVOICE_PAYMENT_FAILED: 'invoice.payment_failed', + OVERAGE_BILLED: 'billing.overage_billed', + CHARGE_DISPUTE_OPENED: 'charge.dispute_opened', + CHARGE_DISPUTE_CLOSED: 'charge.dispute_closed', + SUBSCRIPTION_CREATED: 'subscription.created', + SUBSCRIPTION_UPDATED: 'subscription.updated', + SUBSCRIPTION_CANCELLED: 'subscription.cancelled', + SUBSCRIPTION_TRANSFERRED: 'subscription.transferred', + ENTERPRISE_SUBSCRIPTION_PROVISIONED: 'subscription.enterprise_provisioned', + CREDENTIAL_ACCESSED: 'credential.accessed', + FILE_DOWNLOADED: 'file.downloaded', + ORG_SEAT_DEPROVISIONED: 'org_seat.deprovisioned', + TABLE_EXPORTED: 'table.exported', + WORKFLOW_PUBLIC_API_TOGGLED: 'workflow.public_api_toggled', + WORKFLOW_EXPORTED: 'workflow.exported', }, AuditResourceType: { API_KEY: 'api_key', @@ -179,8 +204,11 @@ export const auditMock = { PASSWORD: 'password', PERMISSION_GROUP: 'permission_group', SCHEDULE: 'schedule', + SESSION: 'session', SKILL: 'skill', + SUBSCRIPTION: 'subscription', TABLE: 'table', + USER: 'user', WEBHOOK: 'webhook', WORKFLOW: 'workflow', WORKSPACE: 'workspace', From 56835def1a61baef159f84d412d927df6b40e127 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 13:51:25 -0700 Subject: [PATCH 02/29] fix(observability): audit file/credential egress only on success MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Cursor Bugbot review: - GET OAuth token: emit CREDENTIAL_ACCESSED/credential_used after refreshTokenIfNeeded succeeds (matches POST), not before. - File export: audit on each actual success exit via a shared helper — including the non-markdown serve redirect (previously unaudited) and after the zip is generated (previously before asset fetch). --- apps/sim/app/api/auth/oauth/token/route.ts | 55 ++++++++--------- apps/sim/app/api/files/export/[id]/route.ts | 65 ++++++++++++--------- 2 files changed, 65 insertions(+), 55 deletions(-) diff --git a/apps/sim/app/api/auth/oauth/token/route.ts b/apps/sim/app/api/auth/oauth/token/route.ts index 77c1da4291f..efbe2d0d8c5 100644 --- a/apps/sim/app/api/auth/oauth/token/route.ts +++ b/apps/sim/app/api/auth/oauth/token/route.ts @@ -332,32 +332,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } const actorId = authz.requesterUserId - if (actorId) { - const workspaceId = authz.workspaceId ?? null - recordAudit({ - workspaceId, - actorId, - action: AuditAction.CREDENTIAL_ACCESSED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: resolvedCredentialId, - description: `Accessed OAuth credential for provider ${credential.providerId}`, - metadata: { - provider: credential.providerId, - credentialType: 'oauth', - }, - request, - }) - captureServerEvent( - actorId, - 'credential_used', - { - credential_type: 'oauth', - provider_id: credential.providerId, - ...(workspaceId ? { workspace_id: workspaceId } : {}), - }, - workspaceId ? { groups: { workspace: workspaceId } } : undefined - ) - } + const workspaceId = authz.workspaceId ?? null try { const { accessToken } = await refreshTokenIfNeeded( @@ -366,6 +341,34 @@ export const GET = withRouteHandler(async (request: NextRequest) => { resolvedCredentialId ) + // Emitted only after the token is successfully resolved, so a failed + // refresh never records a spurious credential access. + if (actorId) { + recordAudit({ + workspaceId, + actorId, + action: AuditAction.CREDENTIAL_ACCESSED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: resolvedCredentialId, + description: `Accessed OAuth credential for provider ${credential.providerId}`, + metadata: { + provider: credential.providerId, + credentialType: 'oauth', + }, + request, + }) + captureServerEvent( + actorId, + 'credential_used', + { + credential_type: 'oauth', + provider_id: credential.providerId, + ...(workspaceId ? { workspace_id: workspaceId } : {}), + }, + workspaceId ? { groups: { workspace: workspaceId } } : undefined + ) + } + // For Salesforce, extract instanceUrl from the scope field let instanceUrl: string | undefined if (credential.providerId === 'salesforce' && credential.scope) { diff --git a/apps/sim/app/api/files/export/[id]/route.ts b/apps/sim/app/api/files/export/[id]/route.ts index be979641733..2be201967a2 100644 --- a/apps/sim/app/api/files/export/[id]/route.ts +++ b/apps/sim/app/api/files/export/[id]/route.ts @@ -70,9 +70,43 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } + // Records the egress only once the export response is actually produced, so a + // mid-export failure never logs a download that never happened. Covers every + // exit (redirect to serve, plain markdown, and the bundled zip). + const auditExport = (format: 'file' | 'markdown' | 'zip', assetCount: number) => { + recordAudit({ + workspaceId: record.workspaceId ?? null, + actorId: userId, + action: AuditAction.FILE_DOWNLOADED, + resourceType: AuditResourceType.FILE, + resourceId: record.id, + resourceName: record.originalName, + description: `Exported file "${record.originalName}"`, + metadata: { + fileId: record.id, + fileName: record.originalName, + bytes: record.size, + format, + assetCount, + }, + request, + }) + captureServerEvent( + userId, + 'file_downloaded', + { + workspace_id: record.workspaceId ?? '', + is_bulk: assetCount > 0, + file_count: 1 + assetCount, + }, + record.workspaceId ? { groups: { workspace: record.workspaceId } } : undefined + ) + } + if (!isMarkdown(record.originalName, record.contentType)) { const storagePrefix = USE_BLOB_STORAGE ? 'blob' : 's3' const servePath = `/api/files/serve/${storagePrefix}/${encodeURIComponent(record.key)}` + auditExport('file', 0) return NextResponse.redirect(new URL(servePath, request.url), { status: 302 }) } @@ -86,38 +120,10 @@ export const GET = withRouteHandler( logger.info('Exporting markdown', { id, imageCount: imageIds.length }) - const exportFormat = imageIds.length === 0 ? 'markdown' : 'zip' - recordAudit({ - workspaceId: record.workspaceId ?? null, - actorId: userId, - action: AuditAction.FILE_DOWNLOADED, - resourceType: AuditResourceType.FILE, - resourceId: record.id, - resourceName: record.originalName, - description: `Exported file "${record.originalName}"`, - metadata: { - fileId: record.id, - fileName: record.originalName, - bytes: record.size, - format: exportFormat, - assetCount: imageIds.length, - }, - request, - }) - captureServerEvent( - userId, - 'file_downloaded', - { - workspace_id: record.workspaceId ?? '', - is_bulk: imageIds.length > 0, - file_count: 1 + imageIds.length, - }, - record.workspaceId ? { groups: { workspace: record.workspaceId } } : undefined - ) - if (imageIds.length === 0) { const mdName = safeFilename(record.originalName) const mdBytes = Buffer.from(mdContent, 'utf-8') + auditExport('markdown', 0) return new NextResponse(new Uint8Array(mdBytes), { status: 200, headers: { @@ -182,6 +188,7 @@ export const GET = withRouteHandler( const zipBuffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }) const zipName = safeFilename(`${record.originalName.replace(/\.[^.]+$/, '')}.zip`) + auditExport('zip', assetMap.size) return new NextResponse(new Uint8Array(zipBuffer), { status: 200, headers: { From 4bd30ae0f00075f8d5fddefaeb4faa2f06f87b7e Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 13:55:39 -0700 Subject: [PATCH 03/29] fix(audit): record null actor for anonymous public-share downloads Address Greptile P1: setting actorId to the file owner made every anonymous external download read as a self-download, undermining the exfiltration trail. recordAudit now accepts a null actor; the public content/inline routes record actorId=null with the owner in metadata.sharedByUserId and rely on ip/user-agent for the forensic trail. The misleading owner-attributed file_downloaded PostHog event is dropped on these anonymous paths. --- .../api/files/public/[token]/content/route.ts | 13 +++++-------- .../api/files/public/[token]/inline/route.ts | 18 +++++++++--------- packages/audit/src/log.ts | 8 +++++++- 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/apps/sim/app/api/files/public/[token]/content/route.ts b/apps/sim/app/api/files/public/[token]/content/route.ts index 346c6435f55..623b7432ddf 100644 --- a/apps/sim/app/api/files/public/[token]/content/route.ts +++ b/apps/sim/app/api/files/public/[token]/content/route.ts @@ -8,7 +8,6 @@ import { resolveServableDoc } from '@/lib/copilot/tools/server/files/doc-compile import { validateDeploymentAuth } from '@/lib/core/security/deployment-auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit' import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager' import { downloadFile } from '@/lib/uploads/core/storage-service' @@ -78,9 +77,12 @@ export const GET = withRouteHandler( logger.info('Public shared file served', { token, key: file.key, size: buffer.length }) + // Anonymous external access: the actor is genuinely unknown, so the actor + // FK is null (not the owner — that would read as a self-download) and the + // share owner is captured in metadata. ip/user-agent carry the trail. recordAudit({ workspaceId: file.workspaceId ?? null, - actorId: file.userId, + actorId: null, action: AuditAction.FILE_DOWNLOADED, resourceType: AuditResourceType.FILE, resourceId: file.id, @@ -89,17 +91,12 @@ export const GET = withRouteHandler( metadata: { access: 'public_share', anonymous: true, + sharedByUserId: file.userId, fileName: file.originalName, bytes: buffer.length, }, request, }) - captureServerEvent( - file.userId, - 'file_downloaded', - { workspace_id: file.workspaceId ?? '', is_bulk: false, file_count: 1 }, - file.workspaceId ? { groups: { workspace: file.workspaceId } } : undefined - ) // Revalidate every request: a shared file can be unshared, edited, or deleted, // so the fixed token URL must never serve stale bytes from a long-lived cache. diff --git a/apps/sim/app/api/files/public/[token]/inline/route.ts b/apps/sim/app/api/files/public/[token]/inline/route.ts index 581351da5d9..165f6413c41 100644 --- a/apps/sim/app/api/files/public/[token]/inline/route.ts +++ b/apps/sim/app/api/files/public/[token]/inline/route.ts @@ -11,7 +11,6 @@ import { import { validateDeploymentAuth } from '@/lib/core/security/deployment-auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit' import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager' import { downloadFile } from '@/lib/uploads/core/storage-service' @@ -93,22 +92,23 @@ export const GET = withRouteHandler( // record a spurious download. const response = await serveInlineImage(image, { sniff: true }) + // Anonymous external access: null actor FK (not the owner), share owner in + // metadata, ip/user-agent carry the trail. recordAudit({ workspaceId: doc.workspaceId, - actorId: doc.userId, + actorId: null, action: AuditAction.FILE_DOWNLOADED, resourceType: AuditResourceType.FILE, resourceName: image.filename, description: `Public share inline image "${image.filename}"`, - metadata: { access: 'public_share', anonymous: true, inline: true }, + metadata: { + access: 'public_share', + anonymous: true, + inline: true, + sharedByUserId: doc.userId, + }, request, }) - captureServerEvent( - doc.userId, - 'file_downloaded', - { workspace_id: doc.workspaceId, is_bulk: false, file_count: 1 }, - { groups: { workspace: doc.workspaceId } } - ) return response } catch (error) { diff --git a/packages/audit/src/log.ts b/packages/audit/src/log.ts index 0cb6afa0c91..11777f6d66e 100644 --- a/packages/audit/src/log.ts +++ b/packages/audit/src/log.ts @@ -8,7 +8,13 @@ const logger = createLogger('AuditLog') interface AuditLogParams { workspaceId?: string | null - actorId: string + /** + * The acting user's id (FK to `user.id`). Pass `null` for genuinely + * actor-less events such as anonymous public-share access — the row is then + * persisted with a null actor and the forensic context (ip/user-agent, + * metadata) carries the trail instead. + */ + actorId: string | null action: AuditActionType resourceType: AuditResourceTypeValue resourceId?: string From b19912f8a59c358bbb4c447b7413e0f06addf35c Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 14:04:07 -0700 Subject: [PATCH 04/29] fix(observability): audit admin exports after zip; tidy comments - Admin workflow/workspace ZIP exports now audit only after the archive is built (via a local helper), so a zip/build failure no longer logs an export. - Remove redundant 'success-only placement' inline comments and tighten the remaining design-rationale notes (export helper now TSDoc). --- apps/sim/app/api/auth/oauth/token/route.ts | 6 ---- apps/sim/app/api/files/export/[id]/route.ts | 8 +++-- .../api/files/public/[token]/content/route.ts | 5 ++- .../api/files/public/[token]/inline/route.ts | 4 +-- .../api/v1/admin/workflows/export/route.ts | 29 ++++++++-------- .../v1/admin/workspaces/[id]/export/route.ts | 33 ++++++++++--------- apps/sim/lib/table/service.ts | 5 ++- 7 files changed, 44 insertions(+), 46 deletions(-) diff --git a/apps/sim/app/api/auth/oauth/token/route.ts b/apps/sim/app/api/auth/oauth/token/route.ts index efbe2d0d8c5..91b4d55ea4b 100644 --- a/apps/sim/app/api/auth/oauth/token/route.ts +++ b/apps/sim/app/api/auth/oauth/token/route.ts @@ -142,8 +142,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const saActorId = authz.requesterUserId const saWorkspaceId = resolved.workspaceId ?? authz.workspaceId ?? null - // Emitted only after the secret is successfully retrieved, so a failed - // provider call never records a spurious credential access. const emitServiceAccountAccess = () => { if (!saActorId) return recordAudit({ @@ -228,8 +226,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { resolvedCredentialId ) - // Emitted only after the token is successfully resolved, so a failed - // refresh never records a spurious credential access. if (oauthActorId) { recordAudit({ workspaceId: oauthWorkspaceId, @@ -341,8 +337,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { resolvedCredentialId ) - // Emitted only after the token is successfully resolved, so a failed - // refresh never records a spurious credential access. if (actorId) { recordAudit({ workspaceId, diff --git a/apps/sim/app/api/files/export/[id]/route.ts b/apps/sim/app/api/files/export/[id]/route.ts index 2be201967a2..445a0bc9919 100644 --- a/apps/sim/app/api/files/export/[id]/route.ts +++ b/apps/sim/app/api/files/export/[id]/route.ts @@ -70,9 +70,11 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } - // Records the egress only once the export response is actually produced, so a - // mid-export failure never logs a download that never happened. Covers every - // exit (redirect to serve, plain markdown, and the bundled zip). + /** + * Records the egress only at a real success exit (serve redirect, plain + * markdown, or bundled zip) so a mid-export failure never logs a download + * that never happened. + */ const auditExport = (format: 'file' | 'markdown' | 'zip', assetCount: number) => { recordAudit({ workspaceId: record.workspaceId ?? null, diff --git a/apps/sim/app/api/files/public/[token]/content/route.ts b/apps/sim/app/api/files/public/[token]/content/route.ts index 623b7432ddf..1a08f3a474c 100644 --- a/apps/sim/app/api/files/public/[token]/content/route.ts +++ b/apps/sim/app/api/files/public/[token]/content/route.ts @@ -77,9 +77,8 @@ export const GET = withRouteHandler( logger.info('Public shared file served', { token, key: file.key, size: buffer.length }) - // Anonymous external access: the actor is genuinely unknown, so the actor - // FK is null (not the owner — that would read as a self-download) and the - // share owner is captured in metadata. ip/user-agent carry the trail. + // Anonymous external access: null actor (owner-as-actor would read as a + // self-download); share owner in metadata, ip/user-agent carry the trail. recordAudit({ workspaceId: file.workspaceId ?? null, actorId: null, diff --git a/apps/sim/app/api/files/public/[token]/inline/route.ts b/apps/sim/app/api/files/public/[token]/inline/route.ts index 165f6413c41..98a6d0d2822 100644 --- a/apps/sim/app/api/files/public/[token]/inline/route.ts +++ b/apps/sim/app/api/files/public/[token]/inline/route.ts @@ -87,9 +87,7 @@ export const GET = withRouteHandler( throw new FileNotFoundError('Not found') } - // Content-truth gate (`sniff`): render only genuine raster image bytes. - // Audit only after the bytes are accepted so a sniff rejection does not - // record a spurious download. + // Content-truth gate (`sniff`): render only genuine raster image bytes; audit after. const response = await serveInlineImage(image, { sniff: true }) // Anonymous external access: null actor FK (not the owner), share owner in diff --git a/apps/sim/app/api/v1/admin/workflows/export/route.ts b/apps/sim/app/api/v1/admin/workflows/export/route.ts index 9d4461f899a..cc730404d4f 100644 --- a/apps/sim/app/api/v1/admin/workflows/export/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/export/route.ts @@ -102,21 +102,23 @@ export const POST = withRouteHandler( logger.info(`Admin API: Exporting ${workflowExports.length} workflows`) - recordAudit({ - actorId: 'admin-api', - action: AuditAction.WORKFLOW_EXPORTED, - resourceType: AuditResourceType.WORKFLOW, - description: `Admin API exported ${workflowExports.length} workflow(s)`, - metadata: { - format, - requestedCount: body.ids.length, - exportedCount: workflowExports.length, - requestedIds: body.ids, - }, - request, - }) + const auditExport = () => + recordAudit({ + actorId: 'admin-api', + action: AuditAction.WORKFLOW_EXPORTED, + resourceType: AuditResourceType.WORKFLOW, + description: `Admin API exported ${workflowExports.length} workflow(s)`, + metadata: { + format, + requestedCount: body.ids.length, + exportedCount: workflowExports.length, + requestedIds: body.ids, + }, + request, + }) if (format === 'json') { + auditExport() return listResponse(workflowExports, { total: workflowExports.length, limit: workflowExports.length, @@ -137,6 +139,7 @@ export const POST = withRouteHandler( const filename = `workflows-export-${new Date().toISOString().split('T')[0]}.zip` + auditExport() return new NextResponse(arrayBuffer, { status: 200, headers: { diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts index ff69ed39445..e8913c74e0f 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts @@ -125,23 +125,25 @@ export const GET = withRouteHandler( `Admin API: Exporting workspace ${workspaceId} with ${workflowExports.length} workflows and ${folderExports.length} folders` ) - recordAudit({ - workspaceId, - actorId: 'admin-api', - action: AuditAction.WORKSPACE_EXPORTED, - resourceType: AuditResourceType.WORKSPACE, - resourceId: workspaceId, - resourceName: workspaceData.name, - description: `Admin API exported workspace "${workspaceData.name}"`, - metadata: { - format, - workflowCount: workflowExports.length, - folderCount: folderExports.length, - }, - request, - }) + const auditExport = () => + recordAudit({ + workspaceId, + actorId: 'admin-api', + action: AuditAction.WORKSPACE_EXPORTED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: workspaceId, + resourceName: workspaceData.name, + description: `Admin API exported workspace "${workspaceData.name}"`, + metadata: { + format, + workflowCount: workflowExports.length, + folderCount: folderExports.length, + }, + request, + }) if (format === 'json') { + auditExport() const exportPayload: WorkspaceExportPayload = { version: '1.0', exportedAt: new Date().toISOString(), @@ -173,6 +175,7 @@ export const GET = withRouteHandler( const sanitizedName = sanitizePathSegment(workspaceData.name) const filename = `${sanitizedName}-${new Date().toISOString().split('T')[0]}.zip` + auditExport() return new NextResponse(arrayBuffer, { status: 200, headers: { diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 9c015d5eb13..c2ac0cc1e14 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -650,9 +650,8 @@ export async function deleteTable( const deleted = result[0] // Audit only genuine user-initiated deletes — rollback/cleanup callers omit - // `actingUserId`. The `table_deleted` PostHog event is emitted by the caller - // (route handler / copilot tool) where the acting user is known, so it is not - // emitted here to avoid double-counting. + // `actingUserId`. The caller emits the `table_deleted` PostHog event (where the + // acting user is known), so it is not duplicated here. if (deleted && actingUserId) { recordAudit({ workspaceId: deleted.workspaceId ?? null, From 9da42705bc395b44515eab337d5075b02fc47a01 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 14:13:59 -0700 Subject: [PATCH 05/29] fix(billing): complete the chargeback + overage financial trail Address Cursor review: - handleDisputeClosed now records CHARGE_DISPUTE_CLOSED for every closed dispute (won/lost/warning_closed), unblocking only on favorable outcomes. dispute.status in the metadata distinguishes the outcome, so lost chargebacks are no longer missing from the trail. - Threshold overage now emits OVERAGE_BILLED + overage_billed even when credits fully cover the overage (settledVia: 'credits' vs 'stripe'), so credit-settled overages are audited instead of silently returning null. --- apps/sim/lib/billing/threshold-billing.ts | 49 ++++++++++++++++++----- apps/sim/lib/billing/webhooks/disputes.ts | 44 ++++++++++---------- apps/sim/lib/posthog/events.ts | 1 + 3 files changed, 61 insertions(+), 33 deletions(-) diff --git a/apps/sim/lib/billing/threshold-billing.ts b/apps/sim/lib/billing/threshold-billing.ts index 43d73aa3024..bb52ae62980 100644 --- a/apps/sim/lib/billing/threshold-billing.ts +++ b/apps/sim/lib/billing/threshold-billing.ts @@ -121,7 +121,13 @@ export async function checkAndBillOverageThreshold(userId: string): Promise => { + async ( + tx + ): Promise<{ + amount: number + creditsApplied: number + settledVia: 'stripe' | 'credits' + } | null> => { await tx.execute(sql.raw(`SET LOCAL lock_timeout = '${BILLING_LOCK_TIMEOUT_MS}ms'`)) const statsRecords = await tx @@ -200,7 +206,7 @@ export async function checkAndBillOverageThreshold(userId: string): Promise => { + async ( + tx + ): Promise<{ + amount: number + creditsApplied: number + ownerId: string + settledVia: 'stripe' | 'credits' + } | null> => { await tx.execute(sql.raw(`SET LOCAL lock_timeout = '${BILLING_LOCK_TIMEOUT_MS}ms'`)) const lockedOwnerRows = await tx @@ -529,7 +545,12 @@ async function checkAndBillOrganizationOverageThreshold(organizationId: string): creditsApplied, unbilledOverage, }) - return null + return { + amount: unbilledOverage, + creditsApplied, + ownerId: lockedOwnerId, + settledVia: 'credits', + } } const amountCents = Math.round(amountToBill * 100) @@ -570,18 +591,24 @@ async function checkAndBillOrganizationOverageThreshold(organizationId: string): billingPeriod, }) - return { amount: amountToBill, creditsApplied, ownerId: lockedOwnerId } + return { + amount: amountToBill, + creditsApplied, + ownerId: lockedOwnerId, + settledVia: 'stripe', + } } ) if (orgBilledResult) { - const { amount, creditsApplied, ownerId } = orgBilledResult + const { amount, creditsApplied, ownerId, settledVia } = orgBilledResult + const settledLabel = settledVia === 'credits' ? 'covered by credits' : 'billed' recordAudit({ actorId: ownerId, action: AuditAction.OVERAGE_BILLED, resourceType: AuditResourceType.BILLING, resourceId: orgSubscription.id, - description: `Overage of $${amount.toFixed(2)} billed for organization ${organizationId}`, + description: `Overage of $${amount.toFixed(2)} ${settledLabel} for organization ${organizationId}`, metadata: { entityType: 'organization', referenceId: organizationId, @@ -589,6 +616,7 @@ async function checkAndBillOrganizationOverageThreshold(organizationId: string): amount, currency: 'usd', creditsApplied, + settledVia, billingPeriod, }, }) @@ -597,6 +625,7 @@ async function checkAndBillOrganizationOverageThreshold(organizationId: string): currency: 'usd', entity_type: 'organization', reference_id: organizationId, + settled_via: settledVia, }) } } catch (error) { diff --git a/apps/sim/lib/billing/webhooks/disputes.ts b/apps/sim/lib/billing/webhooks/disputes.ts index 556e595e880..3bffd3d84db 100644 --- a/apps/sim/lib/billing/webhooks/disputes.ts +++ b/apps/sim/lib/billing/webhooks/disputes.ts @@ -147,23 +147,17 @@ export async function handleChargeDispute(event: Stripe.Event): Promise { export async function handleDisputeClosed(event: Stripe.Event): Promise { const dispute = event.data.object as Stripe.Dispute - // Only unblock if we won or the warning was closed without a full dispute - const shouldUnblock = dispute.status === 'won' || dispute.status === 'warning_closed' - - if (!shouldUnblock) { - logger.info('Dispute resolved against us, user remains blocked', { - disputeId: dispute.id, - status: dispute.status, - }) - return - } - const customerId = await getCustomerIdFromDispute(dispute) if (!customerId) { return } - // Find and unblock user (Pro plans) - only if blocked for dispute, not other reasons + // Unblock only when we won or the warning closed without a full dispute; a + // 'lost' dispute keeps the customer blocked (they owe us). The close is + // audited in every case so the chargeback trail is complete — `dispute.status` + // in the metadata distinguishes the outcome. + const shouldUnblock = dispute.status === 'won' || dispute.status === 'warning_closed' + const users = await db .select({ id: user.id }) .from(user) @@ -171,15 +165,19 @@ export async function handleDisputeClosed(event: Stripe.Event): Promise { .limit(1) if (users.length > 0) { - await db - .update(userStats) - .set({ billingBlocked: false, billingBlockedReason: null }) - .where(and(eq(userStats.userId, users[0].id), eq(userStats.billingBlockedReason, 'dispute'))) - - logger.info('Unblocked user after dispute resolved in our favor', { + if (shouldUnblock) { + await db + .update(userStats) + .set({ billingBlocked: false, billingBlockedReason: null }) + .where( + and(eq(userStats.userId, users[0].id), eq(userStats.billingBlockedReason, 'dispute')) + ) + } + logger.info('Dispute closed for user', { disputeId: dispute.id, userId: users[0].id, status: dispute.status, + unblocked: shouldUnblock, }) recordDisputeInstrumentation('closed', dispute, customerId, users[0].id, { @@ -189,7 +187,6 @@ export async function handleDisputeClosed(event: Stripe.Event): Promise { return } - // Find and unblock all org members (Team/Enterprise) - consistent with payment success const subs = await db .select({ referenceId: subscription.referenceId }) .from(subscription) @@ -198,13 +195,14 @@ export async function handleDisputeClosed(event: Stripe.Event): Promise { if (subs.length > 0) { const orgId = subs[0].referenceId - const memberCount = await unblockOrgMembers(orgId, 'dispute') - - logger.info('Unblocked all org members after dispute resolved in our favor', { + if (shouldUnblock) { + await unblockOrgMembers(orgId, 'dispute') + } + logger.info('Dispute closed for organization', { disputeId: dispute.id, organizationId: orgId, - memberCount, status: dispute.status, + unblocked: shouldUnblock, }) const actorId = (await getOrganizationOwnerId(orgId)) ?? orgId diff --git a/apps/sim/lib/posthog/events.ts b/apps/sim/lib/posthog/events.ts index f537e17d0c2..b9481465e50 100644 --- a/apps/sim/lib/posthog/events.ts +++ b/apps/sim/lib/posthog/events.ts @@ -743,6 +743,7 @@ export interface PostHogEventMap { currency: string entity_type: 'user' | 'organization' reference_id: string + settled_via: 'stripe' | 'credits' } credits_purchased: { From 4aaf1b196618b52bbac6658c6e4c6110c8d6f599 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 14:16:17 -0700 Subject: [PATCH 06/29] fix(storage): decrement copilot quota before deleting metadata Address Greptile P1: deleting the metadata row before the decrement meant a decrement failure left the quota permanently inflated with no record to retry from. Decrement first; only remove the metadata row once it succeeds. --- .../sim/lib/uploads/contexts/copilot/copilot-file-manager.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts b/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts index d13250b7644..c2582b78f1e 100644 --- a/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts @@ -270,8 +270,11 @@ export async function deleteCopilotFile(key: string): Promise { if (metadata) { try { - await deleteFileMetadata(key) + // Decrement before removing the metadata row: if the decrement fails the + // row is preserved (so the counter can still be reconciled) rather than + // leaving the quota permanently inflated with nothing to retry from. await decrementStorageUsage(metadata.userId, metadata.size, metadata.workspaceId ?? undefined) + await deleteFileMetadata(key) } catch (storageError) { logger.error('Failed to update storage tracking:', storageError) } From 8064b9adc9af44a1db4205bfc9570e79af1c0587 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 14:29:31 -0700 Subject: [PATCH 07/29] fix(observability): atomic copilot storage release; signup-blocked action - Copilot file delete now releases storage via a single transaction (releaseDeletedFileStorage): the soft-delete is the idempotency claim and the decrement shares the transaction, so neither a partial failure (inflated counter) nor a retry (double-decrement) can desync the quota. Resolves the conflicting Greptile/Cursor ordering findings. - Policy-blocked sign-ups now record USER_SIGNUP_BLOCKED instead of USER_SIGNIN_BLOCKED, so account-lifecycle events aren't mislabeled. --- apps/sim/lib/auth/auth.ts | 16 ++--- apps/sim/lib/billing/storage/index.ts | 6 +- apps/sim/lib/billing/storage/tracking.ts | 61 ++++++++++++++++++- .../contexts/copilot/copilot-file-manager.ts | 17 +++--- packages/audit/src/types.ts | 1 + packages/testing/src/mocks/audit.mock.ts | 1 + 6 files changed, 84 insertions(+), 18 deletions(-) diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 9eaf0d5c335..2a565301d4c 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -951,10 +951,12 @@ export const auth = betterAuth({ const requestEmail = ctx.body?.email?.toLowerCase() /** - * Audit a policy-blocked sign-in. The audit row's `actorId` is a FK to - * `user.id`, so it is only written when the email maps to an existing - * user (the common sign-in case). Blocked sign-ups for emails with no - * account are intentionally skipped to avoid an FK violation. + * Audit a policy-blocked authentication attempt. The audit row's + * `actorId` is a FK to `user.id`, so it is only written when the email + * maps to an existing user (the common sign-in case). Blocked sign-ups + * for emails with no account are intentionally skipped to avoid an FK + * violation. The action reflects whether the blocked attempt was a + * sign-in or a sign-up so account-lifecycle events aren't mislabeled. */ const recordSignInBlocked = async (reason: string) => { if (!requestEmail) return @@ -968,15 +970,15 @@ export const auth = betterAuth({ recordAudit({ actorId: blockedUser.id, actorEmail: requestEmail, - action: AuditAction.USER_SIGNIN_BLOCKED, + action: isSignUp ? AuditAction.USER_SIGNUP_BLOCKED : AuditAction.USER_SIGNIN_BLOCKED, resourceType: AuditResourceType.SESSION, resourceId: blockedUser.id, - description: 'Sign-in blocked by access control policy', + description: `${isSignUp ? 'Sign-up' : 'Sign-in'} blocked by access control policy`, metadata: { reason, email: requestEmail, path: ctx.path }, request: ctx.headers ? { headers: ctx.headers } : undefined, }) } catch (error) { - logger.warn('Failed to record blocked sign-in audit', { error }) + logger.warn('Failed to record blocked authentication audit', { error }) } } diff --git a/apps/sim/lib/billing/storage/index.ts b/apps/sim/lib/billing/storage/index.ts index 50e10480a3d..2bc758759ea 100644 --- a/apps/sim/lib/billing/storage/index.ts +++ b/apps/sim/lib/billing/storage/index.ts @@ -1,2 +1,6 @@ export { checkStorageQuota, getUserStorageLimit, getUserStorageUsage } from './limits' -export { decrementStorageUsage, incrementStorageUsage } from './tracking' +export { + decrementStorageUsage, + incrementStorageUsage, + releaseDeletedFileStorage, +} from './tracking' diff --git a/apps/sim/lib/billing/storage/tracking.ts b/apps/sim/lib/billing/storage/tracking.ts index 8c29fbb8b75..b198a98ed03 100644 --- a/apps/sim/lib/billing/storage/tracking.ts +++ b/apps/sim/lib/billing/storage/tracking.ts @@ -5,9 +5,9 @@ */ import { db } from '@sim/db' -import { organization, userStats } from '@sim/db/schema' +import { organization, userStats, workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq, sql } from 'drizzle-orm' +import { and, eq, isNull, sql } from 'drizzle-orm' import { maybeNotifyLimit } from '@/lib/billing/core/limit-notifications' import type { HighestPrioritySubscription } from '@/lib/billing/core/plan' import { getUserStorageLimit, getUserStorageUsage } from '@/lib/billing/storage/limits' @@ -165,3 +165,60 @@ export async function decrementStorageUsage( void maybeNotifyStorageLimit(userId, workspaceId, sub, true) } } + +/** + * Atomically soft-delete a file's metadata row and decrement the owner's storage + * counter in a single transaction. + * + * The soft-delete (`deletedAt` transition) is the idempotency claim: only the + * call that actually flips the row decrements, so a retry that finds the row + * already deleted does not double-count. Because the claim and the decrement + * share one transaction, a failure of either rolls both back — the counter is + * never left permanently inflated and never double-decremented. Best-effort: + * when billing is disabled it just soft-deletes the row. + */ +export async function releaseDeletedFileStorage( + key: string, + userId: string, + bytes: number, + workspaceId?: string +): Promise { + if (!isBillingEnabled || bytes <= 0) { + await db + .update(workspaceFiles) + .set({ deletedAt: new Date() }) + .where(and(eq(workspaceFiles.key, key), isNull(workspaceFiles.deletedAt))) + return + } + + const { getHighestPrioritySubscription } = await import('@/lib/billing/core/subscription') + const sub = await getHighestPrioritySubscription(userId) + const orgScoped = isOrgScopedSubscription(sub, userId) && sub !== null + + let claimed = false + await db.transaction(async (tx) => { + const claimedRows = await tx + .update(workspaceFiles) + .set({ deletedAt: new Date() }) + .where(and(eq(workspaceFiles.key, key), isNull(workspaceFiles.deletedAt))) + .returning({ id: workspaceFiles.id }) + if (claimedRows.length === 0) return + claimed = true + + if (orgScoped && sub) { + await tx + .update(organization) + .set({ storageUsedBytes: sql`GREATEST(0, ${organization.storageUsedBytes} - ${bytes})` }) + .where(eq(organization.id, sub.referenceId)) + } else { + await tx + .update(userStats) + .set({ storageUsedBytes: sql`GREATEST(0, ${userStats.storageUsedBytes} - ${bytes})` }) + .where(eq(userStats.userId, userId)) + } + }) + + if (claimed && workspaceId) { + void maybeNotifyStorageLimit(userId, workspaceId, sub, true) + } +} diff --git a/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts b/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts index c2582b78f1e..036305bcea5 100644 --- a/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts @@ -1,8 +1,8 @@ import { createLogger } from '@sim/logger' import { checkStorageQuota, - decrementStorageUsage, incrementStorageUsage, + releaseDeletedFileStorage, } from '@/lib/billing/storage' import { getBaseUrl } from '@/lib/core/utils/urls' import { @@ -12,7 +12,7 @@ import { generatePresignedUploadUrl, uploadFile, } from '@/lib/uploads/core/storage-service' -import { deleteFileMetadata, getFileMetadataByKey } from '@/lib/uploads/server/metadata' +import { getFileMetadataByKey } from '@/lib/uploads/server/metadata' import type { PresignedUrlResponse } from '@/lib/uploads/shared/types' import { isImageFileType } from '@/lib/uploads/utils/file-utils' @@ -270,13 +270,14 @@ export async function deleteCopilotFile(key: string): Promise { if (metadata) { try { - // Decrement before removing the metadata row: if the decrement fails the - // row is preserved (so the counter can still be reconciled) rather than - // leaving the quota permanently inflated with nothing to retry from. - await decrementStorageUsage(metadata.userId, metadata.size, metadata.workspaceId ?? undefined) - await deleteFileMetadata(key) + await releaseDeletedFileStorage( + key, + metadata.userId, + metadata.size, + metadata.workspaceId ?? undefined + ) } catch (storageError) { - logger.error('Failed to update storage tracking:', storageError) + logger.error('Failed to release copilot file storage:', storageError) } } diff --git a/packages/audit/src/types.ts b/packages/audit/src/types.ts index b695234217c..d7dff96fc6d 100644 --- a/packages/audit/src/types.ts +++ b/packages/audit/src/types.ts @@ -6,6 +6,7 @@ export const AuditAction = { USER_LOGIN: 'user.login', USER_LOGIN_FAILED: 'user.login_failed', USER_SIGNIN_BLOCKED: 'user.signin_blocked', + USER_SIGNUP_BLOCKED: 'user.signup_blocked', USER_LOGOUT: 'user.logout', SESSION_REVOKED: 'session.revoked', ACCOUNT_DELETED: 'account.deleted', diff --git a/packages/testing/src/mocks/audit.mock.ts b/packages/testing/src/mocks/audit.mock.ts index 2baa3fa326a..7e81cb59257 100644 --- a/packages/testing/src/mocks/audit.mock.ts +++ b/packages/testing/src/mocks/audit.mock.ts @@ -161,6 +161,7 @@ export const auditMock = { USER_LOGIN: 'user.login', USER_LOGIN_FAILED: 'user.login_failed', USER_SIGNIN_BLOCKED: 'user.signin_blocked', + USER_SIGNUP_BLOCKED: 'user.signup_blocked', USER_LOGOUT: 'user.logout', SESSION_REVOKED: 'session.revoked', ACCOUNT_DELETED: 'account.deleted', From 5a2191db10b9d842e83a7c4de9fcab4485378d19 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 14:42:13 -0700 Subject: [PATCH 08/29] fix(storage): meter copilot ingest centrally for path symmetry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Cursor review: only uploadCopilotFile incremented storage, but copilot files also enter via the generic upload route and presigned uploads — all of which persist metadata through insertFileMetadata. Move the increment into insertFileMetadata (scoped to context='copilot', on genuine insert/restore) so every ingest path is symmetric with the delete-time decrement, and drop the now redundant per-path increment in uploadCopilotFile. Other contexts are metered by their own managers and remain unaffected. --- .../contexts/copilot/copilot-file-manager.ts | 15 ++++-------- apps/sim/lib/uploads/server/metadata.ts | 24 +++++++++++++++++++ 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts b/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts index 036305bcea5..82df0c1fe85 100644 --- a/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts @@ -1,9 +1,5 @@ import { createLogger } from '@sim/logger' -import { - checkStorageQuota, - incrementStorageUsage, - releaseDeletedFileStorage, -} from '@/lib/billing/storage' +import { checkStorageQuota, releaseDeletedFileStorage } from '@/lib/billing/storage' import { getBaseUrl } from '@/lib/core/utils/urls' import { deleteFile, @@ -148,12 +144,9 @@ export async function uploadCopilotFile(options: { userId: options.userId, }) - try { - await incrementStorageUsage(options.userId, options.buffer.length) - } catch (storageError) { - logger.error('Failed to update storage tracking:', storageError) - } - + // Storage is metered centrally when the copilot metadata row is created + // (see insertFileMetadata), so every ingest path stays symmetric — no + // per-path increment here. return { id: fileInfo.key, key: fileInfo.key, diff --git a/apps/sim/lib/uploads/server/metadata.ts b/apps/sim/lib/uploads/server/metadata.ts index 4a3faa58366..a60e5080972 100644 --- a/apps/sim/lib/uploads/server/metadata.ts +++ b/apps/sim/lib/uploads/server/metadata.ts @@ -7,6 +7,28 @@ import type { StorageContext } from '../shared/types' const logger = createLogger('FileMetadata') +/** + * Meter a copilot file's bytes against the owner's storage quota when its + * metadata row is first created or restored. Copilot is the one context metered + * here so that every ingest path (tool output, the upload route, presigned + * uploads) is symmetric with the decrement on delete. Other contexts are metered + * by their own managers (workspace, knowledge-base) or intentionally unmetered + * (execution), so they are skipped. Best-effort: never blocks the metadata write. + */ +async function meterCopilotIngest( + context: StorageContext, + userId: string, + size: number +): Promise { + if (context !== 'copilot' || size <= 0) return + try { + const { incrementStorageUsage } = await import('@/lib/billing/storage') + await incrementStorageUsage(userId, size) + } catch (error) { + logger.error('Failed to meter copilot file storage on ingest', { error }) + } +} + export type FileMetadataRecord = typeof workspaceFiles.$inferSelect export interface FileMetadataInsertOptions { @@ -57,6 +79,7 @@ export async function insertFileMetadata( .returning() if (restored) { + await meterCopilotIngest(context, userId, size) return restored } } @@ -92,6 +115,7 @@ export async function insertFileMetadata( }) .returning() + await meterCopilotIngest(context, userId, size) return inserted } catch (error) { const code = (error as { code?: string } | null)?.code From b9ac77ec90c45a5e33913d5988277c7734ca995f Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 14:46:22 -0700 Subject: [PATCH 09/29] fix(audit): skip WORKFLOW_EXPORTED when admin export is empty Address Cursor review: when every requested workflow fails to load, the admin export still returned 200 but recorded a successful export of zero workflows. Guard auditExport so an empty result records nothing. --- apps/sim/app/api/v1/admin/workflows/export/route.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/api/v1/admin/workflows/export/route.ts b/apps/sim/app/api/v1/admin/workflows/export/route.ts index cc730404d4f..b3e2678bc31 100644 --- a/apps/sim/app/api/v1/admin/workflows/export/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/export/route.ts @@ -102,7 +102,10 @@ export const POST = withRouteHandler( logger.info(`Admin API: Exporting ${workflowExports.length} workflows`) - const auditExport = () => + // No workflow resolved (every requested id failed to load) — nothing was + // exfiltrated, so don't record a successful export. + const auditExport = () => { + if (workflowExports.length === 0) return recordAudit({ actorId: 'admin-api', action: AuditAction.WORKFLOW_EXPORTED, @@ -116,6 +119,7 @@ export const POST = withRouteHandler( }, request, }) + } if (format === 'json') { auditExport() From 9d9186ee26eebd0e0d8e6ee86ac3b99d920af64f Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 14:54:36 -0700 Subject: [PATCH 10/29] fix(storage): settle copilot accounting before deleting the blob Address Cursor review: the blob was removed before releaseDeletedFileStorage, so a release failure left the counter inflated and the metadata active with the blob gone. Now the atomic soft-delete + decrement runs first and the blob is deleted only if it succeeds, so a failure leaves the file fully intact and retryable. --- .../contexts/copilot/copilot-file-manager.ts | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts b/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts index 82df0c1fe85..caa7f4863e0 100644 --- a/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts @@ -249,18 +249,16 @@ export async function generateCopilotDownloadUrl( * @param key File storage key */ export async function deleteCopilotFile(key: string): Promise { - // Storage accounting is best-effort: reading metadata must never prevent the - // file delete (the primary operation). const metadata = await getFileMetadataByKey(key, 'copilot').catch((error) => { logger.error('Failed to read copilot file metadata for storage accounting:', error) return null }) - await deleteFile({ - key, - context: 'copilot', - }) - + // Settle storage accounting (atomic metadata soft-delete + quota decrement) + // BEFORE removing the blob. If it fails, the file is left fully intact — blob, + // active metadata, and counter all consistent — so a retry can re-run cleanly, + // rather than orphaning the blob with a permanently inflated counter. + let released = true if (metadata) { try { await releaseDeletedFileStorage( @@ -270,9 +268,16 @@ export async function deleteCopilotFile(key: string): Promise { metadata.workspaceId ?? undefined ) } catch (storageError) { + released = false logger.error('Failed to release copilot file storage:', storageError) } } - logger.info(`Successfully deleted copilot file: ${key}`) + if (released) { + await deleteFile({ + key, + context: 'copilot', + }) + logger.info(`Successfully deleted copilot file: ${key}`) + } } From ba23b00ce78865ae730acb9ca91141c1641b233a Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 15:14:27 -0700 Subject: [PATCH 11/29] fix(storage): decrement KB document storage atomically with deletion Address Cursor review: hardDeleteDocuments deleted the rows then decremented best-effort, so a decrement failure left billed storage inflated with no row to reconcile. Resolve each owner's subscription up front, then decrement inside the same transaction that deletes the embeddings/documents (decrementStorageUsageInTx, also now shared by releaseDeletedFileStorage), so the counter and the content commit or roll back together. Connector docs remain excluded. --- apps/sim/lib/billing/storage/index.ts | 1 + apps/sim/lib/billing/storage/tracking.ts | 44 +++++++++++++++------ apps/sim/lib/knowledge/documents/service.ts | 42 ++++++++++++-------- 3 files changed, 58 insertions(+), 29 deletions(-) diff --git a/apps/sim/lib/billing/storage/index.ts b/apps/sim/lib/billing/storage/index.ts index 2bc758759ea..5a7983ead83 100644 --- a/apps/sim/lib/billing/storage/index.ts +++ b/apps/sim/lib/billing/storage/index.ts @@ -1,6 +1,7 @@ export { checkStorageQuota, getUserStorageLimit, getUserStorageUsage } from './limits' export { decrementStorageUsage, + decrementStorageUsageInTx, incrementStorageUsage, releaseDeletedFileStorage, } from './tracking' diff --git a/apps/sim/lib/billing/storage/tracking.ts b/apps/sim/lib/billing/storage/tracking.ts index b198a98ed03..fc76c39c922 100644 --- a/apps/sim/lib/billing/storage/tracking.ts +++ b/apps/sim/lib/billing/storage/tracking.ts @@ -166,6 +166,36 @@ export async function decrementStorageUsage( } } +type StorageTransaction = Parameters[0]>[0] + +/** + * Decrement a user's (or their org's) storage counter inside an existing + * transaction, using a pre-resolved subscription. This lets a caller make the + * counter update atomic with the DB rows it is deleting (e.g. hard-deleting + * documents), so a failure of either rolls back both — no inflated counter, no + * over-decrement. The caller resolves the subscription (a read) before opening + * the transaction. + */ +export async function decrementStorageUsageInTx( + tx: StorageTransaction, + sub: HighestPrioritySubscription | null, + userId: string, + bytes: number +): Promise { + if (!isBillingEnabled || bytes <= 0) return + if (isOrgScopedSubscription(sub, userId) && sub) { + await tx + .update(organization) + .set({ storageUsedBytes: sql`GREATEST(0, ${organization.storageUsedBytes} - ${bytes})` }) + .where(eq(organization.id, sub.referenceId)) + } else { + await tx + .update(userStats) + .set({ storageUsedBytes: sql`GREATEST(0, ${userStats.storageUsedBytes} - ${bytes})` }) + .where(eq(userStats.userId, userId)) + } +} + /** * Atomically soft-delete a file's metadata row and decrement the owner's storage * counter in a single transaction. @@ -193,7 +223,6 @@ export async function releaseDeletedFileStorage( const { getHighestPrioritySubscription } = await import('@/lib/billing/core/subscription') const sub = await getHighestPrioritySubscription(userId) - const orgScoped = isOrgScopedSubscription(sub, userId) && sub !== null let claimed = false await db.transaction(async (tx) => { @@ -204,18 +233,7 @@ export async function releaseDeletedFileStorage( .returning({ id: workspaceFiles.id }) if (claimedRows.length === 0) return claimed = true - - if (orgScoped && sub) { - await tx - .update(organization) - .set({ storageUsedBytes: sql`GREATEST(0, ${organization.storageUsedBytes} - ${bytes})` }) - .where(eq(organization.id, sub.referenceId)) - } else { - await tx - .update(userStats) - .set({ storageUsedBytes: sql`GREATEST(0, ${userStats.storageUsedBytes} - ${bytes})` }) - .where(eq(userStats.userId, userId)) - } + await decrementStorageUsageInTx(tx, sub, userId, bytes) }) if (claimed && workspaceId) { diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index 0ac61339db9..6b1b9594294 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -14,10 +14,12 @@ import { generateId } from '@sim/utils/id' import { tasks } from '@trigger.dev/sdk' import { and, asc, desc, eq, inArray, isNotNull, isNull, type SQL, sql } from 'drizzle-orm' import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor' +import type { HighestPrioritySubscription } from '@/lib/billing/core/plan' +import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { recordUsage } from '@/lib/billing/core/usage-log' import { checkStorageQuota, - decrementStorageUsage, + decrementStorageUsageInTx, incrementStorageUsage, } from '@/lib/billing/storage' import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing' @@ -2051,28 +2053,36 @@ export async function hardDeleteDocuments( const existingIds = documentsToDelete.map((doc) => doc.id) + // Sum the billable bytes per owning user. Connector-synced documents are never + // metered at ingest (the sync engine inserts them directly), so they must not + // be decremented here. + const bytesByUser = new Map() + for (const doc of documentsToDelete) { + if (doc.connectorId || doc.fileSize <= 0) continue + const billedUserId = doc.uploadedBy ?? doc.kbUserId + if (!billedUserId) continue + bytesByUser.set(billedUserId, (bytesByUser.get(billedUserId) ?? 0) + doc.fileSize) + } + + // Resolve each owner's subscription before the transaction (these are reads), + // then decrement the counters inside the same transaction that deletes the + // rows, so a failure of either rolls both back — the billed storage can never + // be left inflated once the content is gone. + const subByUser = new Map() + for (const billedUserId of bytesByUser.keys()) { + subByUser.set(billedUserId, await getHighestPrioritySubscription(billedUserId)) + } + await db.transaction(async (tx) => { await tx.delete(embedding).where(inArray(embedding.documentId, existingIds)) await tx.delete(document).where(inArray(document.id, existingIds)) + for (const [billedUserId, bytes] of bytesByUser) { + await decrementStorageUsageInTx(tx, subByUser.get(billedUserId) ?? null, billedUserId, bytes) + } }) await deleteDocumentStorageFiles(documentsToDelete, requestId) - for (const doc of documentsToDelete) { - // Connector-synced documents are never metered at ingest (the sync engine - // inserts them directly), so they must not be decremented here — doing so - // would erode legitimately-counted bytes in the same owner counter. - if (doc.connectorId) continue - const billedUserId = doc.uploadedBy ?? doc.kbUserId - if (billedUserId && doc.fileSize > 0) { - try { - await decrementStorageUsage(billedUserId, doc.fileSize, doc.workspaceId ?? undefined) - } catch (storageError) { - logger.error(`[${requestId}] Failed to update storage tracking:`, storageError) - } - } - } - logger.info(`[${requestId}] Hard deleted ${existingIds.length} documents`, { documentIds: existingIds, }) From 59ee511d7d585f0a96d8b42dc1c561ff5a20a962 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 15:23:29 -0700 Subject: [PATCH 12/29] fix(audit): don't treat email verification as a login Address Cursor review: /verify-email could emit USER_LOGIN when verification mints or refreshes a session, mislabeling (or double-counting) a non-sign-in as a login. Restrict isLoginPath to genuine sign-in entrypoints. --- apps/sim/lib/auth/auth.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 2a565301d4c..852b4254055 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -1044,6 +1044,11 @@ export const auth = betterAuth({ * new login. */ const newSession = ctx.context.newSession + // Only genuine sign-in entrypoints count as a login. `/verify-email` is + // deliberately excluded: email verification is an account-lifecycle event + // that can mint or refresh a session without being a new sign-in, so + // treating it as a login would mislabel (or double-count, when the prior + // sign-up already recorded one). const isLoginPath = ctx.path.startsWith('/sign-in') || ctx.path.startsWith('/sign-up') || @@ -1051,8 +1056,7 @@ export const auth = betterAuth({ ctx.path.startsWith('/oauth2/callback') || ctx.path.startsWith('/sso/callback') || ctx.path.startsWith('/magic-link') || - ctx.path.startsWith('/email-otp') || - ctx.path.startsWith('/verify-email') + ctx.path.startsWith('/email-otp') if (newSession?.user?.id && isLoginPath) { recordAudit({ actorId: newSession.user.id, From f0a18efc2331ba4efe143e81a04ea750eaea5e41 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 15:29:10 -0700 Subject: [PATCH 13/29] fix(audit): null actor FK when the user lookup throws MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Greptile P1: the catch branch left the original actorId, so a system actor like 'admin-api' (or a since-deleted user) would FK-violate the insert and lose the row when the existence lookup errored. Mirror the not-found branch — null the FK with a readable label — so the audit row always persists. --- packages/audit/src/log.test.ts | 8 ++++---- packages/audit/src/log.ts | 5 +++++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/audit/src/log.test.ts b/packages/audit/src/log.test.ts index e7495232943..e47485ee085 100644 --- a/packages/audit/src/log.test.ts +++ b/packages/audit/src/log.test.ts @@ -318,12 +318,12 @@ describe('recordAudit', () => { ) }) - it('inserts without actor info when lookup fails', async () => { + it('nulls the actor FK when the lookup throws so the insert cannot FK-violate', async () => { dbChainMockFns.limit.mockRejectedValue(new Error('DB down')) recordAudit({ workspaceId: 'ws-1', - actorId: 'user-1', + actorId: 'admin-api', action: AuditAction.KNOWLEDGE_BASE_CREATED, resourceType: AuditResourceType.KNOWLEDGE_BASE, }) @@ -333,8 +333,8 @@ describe('recordAudit', () => { expect(dbChainMockFns.select).toHaveBeenCalledTimes(1) expect(dbChainMockFns.values).toHaveBeenCalledWith( expect.objectContaining({ - actorId: 'user-1', - actorName: undefined, + actorId: null, + actorName: 'Admin API', actorEmail: undefined, }) ) diff --git a/packages/audit/src/log.ts b/packages/audit/src/log.ts index 11777f6d66e..8ad692a30a5 100644 --- a/packages/audit/src/log.ts +++ b/packages/audit/src/log.ts @@ -87,7 +87,12 @@ async function insertAuditLog(params: AuditLogParams): Promise { actorId = null } } catch (error) { + // The lookup couldn't confirm the user exists, so null the FK to guarantee + // the insert can't violate it (e.g. a system actor like 'admin-api', or a + // since-deleted user). The label still identifies the actor. logger.warn('Failed to resolve actor info', { error, actorId }) + actorName = actorId === 'admin-api' ? 'Admin API' : 'System' + actorId = null } } From c06e50742f1c225fe28d420bd57be4e5b02bb00f Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 16:08:24 -0700 Subject: [PATCH 14/29] revert(audit): drop session/account-lifecycle auth instrumentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the Better Auth login/logout/session-revoke/account-delete/blocked-signin audit hooks from auth.ts — they touch sensitive auth paths and are noisy. Also removes the now-unused taxonomy (USER_LOGIN/_FAILED, USER_SIGNIN_BLOCKED, USER_SIGNUP_BLOCKED, USER_LOGOUT, SESSION_REVOKED, ACCOUNT_DELETED, ACCOUNT_EMAIL_CHANGED actions; SESSION/USER resource types) and the awaitable recordAuditNow helper that only the pre-delete hook used. auth.ts is back to the staging baseline. --- apps/sim/lib/auth/auth.ts | 161 +---------------------- packages/audit/src/index.ts | 2 +- packages/audit/src/log.ts | 14 -- packages/audit/src/types.ts | 12 -- packages/testing/src/mocks/audit.mock.ts | 10 -- 5 files changed, 2 insertions(+), 197 deletions(-) diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 852b4254055..3640723a32f 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -2,7 +2,6 @@ import { createHash } from 'crypto' import { cache } from 'react' import { sso } from '@better-auth/sso' import { stripe } from '@better-auth/stripe' -import { AuditAction, AuditResourceType, recordAudit, recordAuditNow } from '@sim/audit' import { db } from '@sim/db' import * as schema from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -207,7 +206,7 @@ export const auth = betterAuth({ user: { deleteUser: { enabled: false, - beforeDelete: async (deletingUser, request) => { + beforeDelete: async (deletingUser) => { const { isSoleOwnerOfPaidOrganization } = await import( '@/lib/billing/organizations/membership' ) @@ -239,26 +238,6 @@ export const auth = betterAuth({ `Your account owns ${ownedUnresolved.length} workspace${ownedUnresolved.length === 1 ? '' : 's'} with no other admin to take over ownership. Add another admin to ${ownedUnresolved.length === 1 ? 'that workspace' : 'those workspaces'} or delete ${ownedUnresolved.length === 1 ? 'it' : 'them'} before deleting your account.` ) } - - /** - * All ownership/billing blockers passed — the account deletion will - * proceed after this callback returns. Await the write so the row lands - * while the `user` row still exists; a deferred (fire-and-forget) insert - * would race the cascade delete and FK-violate. actorName/actorEmail are - * captured explicitly so the actor stays identifiable after the FK is - * nulled by `onDelete: set null`. - */ - await recordAuditNow({ - actorId: deletingUser.id, - actorName: deletingUser.name, - actorEmail: deletingUser.email, - action: AuditAction.ACCOUNT_DELETED, - resourceType: AuditResourceType.USER, - resourceId: deletingUser.id, - resourceName: deletingUser.name ?? deletingUser.email, - description: `Account deleted for ${deletingUser.email}`, - request, - }) }, }, }, @@ -642,15 +621,6 @@ export const auth = betterAuth({ logger.warn('Blocking session creation for blocked account', { userId: session.userId, }) - recordAudit({ - actorId: session.userId, - actorEmail: sessionUser?.email, - action: AuditAction.USER_SIGNIN_BLOCKED, - resourceType: AuditResourceType.SESSION, - resourceId: session.userId, - description: 'Sign-in blocked by access control policy', - metadata: { reason: 'blocked_email_or_domain', email: sessionUser?.email }, - }) throw new APIError('FORBIDDEN', { message: 'Access restricted. Please contact your administrator.', }) @@ -881,31 +851,6 @@ export const auth = betterAuth({ }, hooks: { before: createAuthMiddleware(async (ctx) => { - /** - * Record a logout while the session is still live. The `/sign-out` route - * deletes the session row before any after-hook runs, so the acting user - * can no longer be resolved afterwards; resolve and audit it here. - */ - if (ctx.path === '/sign-out' && ctx.headers) { - try { - const sessionData = await auth.api.getSession({ headers: ctx.headers }) - if (sessionData?.user?.id) { - recordAudit({ - actorId: sessionData.user.id, - actorName: sessionData.user.name, - actorEmail: sessionData.user.email, - action: AuditAction.USER_LOGOUT, - resourceType: AuditResourceType.SESSION, - resourceId: sessionData.session?.id ?? sessionData.user.id, - description: 'User signed out', - request: { headers: ctx.headers }, - }) - } - } catch (error) { - logger.warn('Failed to record sign-out audit', { error }) - } - } - /** * Restrict the unauthenticated sign-in endpoints to first-party login * providers. Better Auth registers every generic-OAuth integration @@ -950,38 +895,6 @@ export const auth = betterAuth({ const accessControl = await getAccessControlConfig() const requestEmail = ctx.body?.email?.toLowerCase() - /** - * Audit a policy-blocked authentication attempt. The audit row's - * `actorId` is a FK to `user.id`, so it is only written when the email - * maps to an existing user (the common sign-in case). Blocked sign-ups - * for emails with no account are intentionally skipped to avoid an FK - * violation. The action reflects whether the blocked attempt was a - * sign-in or a sign-up so account-lifecycle events aren't mislabeled. - */ - const recordSignInBlocked = async (reason: string) => { - if (!requestEmail) return - try { - const [blockedUser] = await db - .select({ id: schema.user.id }) - .from(schema.user) - .where(eq(sql`lower(${schema.user.email})`, requestEmail)) - .limit(1) - if (!blockedUser) return - recordAudit({ - actorId: blockedUser.id, - actorEmail: requestEmail, - action: isSignUp ? AuditAction.USER_SIGNUP_BLOCKED : AuditAction.USER_SIGNIN_BLOCKED, - resourceType: AuditResourceType.SESSION, - resourceId: blockedUser.id, - description: `${isSignUp ? 'Sign-up' : 'Sign-in'} blocked by access control policy`, - metadata: { reason, email: requestEmail, path: ctx.path }, - request: ctx.headers ? { headers: ctx.headers } : undefined, - }) - } catch (error) { - logger.warn('Failed to record blocked authentication audit', { error }) - } - } - // Banning an existing account is owned by better-auth's admin plugin (a // `session.create.before` hook that blocks banned users at sign-in across // all providers), so it is not re-checked here. @@ -994,7 +907,6 @@ export const auth = betterAuth({ accessControl.allowedLoginEmails.includes(requestEmail) || (!!emailDomain && accessControl.allowedLoginDomains.includes(emailDomain)) if (!isAllowed) { - await recordSignInBlocked('not_in_allowlist') throw new APIError('FORBIDDEN', { message: 'Access restricted. Please contact your administrator.', }) @@ -1004,7 +916,6 @@ export const auth = betterAuth({ // Blocked emails/domains gate both signup and sign-in. OAuth/SSO sign-ins // have no email in the body here; the session.create.before hook covers them. if (isEmailBlockedByAccessControl(requestEmail, accessControl)) { - await recordSignInBlocked('blocked_email_or_domain') throw new APIError('FORBIDDEN', { message: isSignUp ? 'Sign-ups from this email are not allowed.' @@ -1022,7 +933,6 @@ export const auth = betterAuth({ accessControl.blockedEmailMxHosts ) if (!mxCheck.allowed) { - await recordSignInBlocked('mx_validation_failed') throw new APIError('FORBIDDEN', { message: 'Sign-ups from this email domain are not allowed.', }) @@ -1032,75 +942,6 @@ export const auth = betterAuth({ return }), - after: createAuthMiddleware(async (ctx) => { - const request = ctx.headers ? { headers: ctx.headers } : undefined - - /** - * A freshly minted `newSession` marks a successful authentication. It is - * set by every session-establishing flow (email/password, OAuth and SSO - * callbacks, email-OTP). Session refreshes (`/get-session`, - * `/update-session`, `/update-user`) also set it, so the login audit is - * gated on an authentication entry path to avoid auditing a refresh as a - * new login. - */ - const newSession = ctx.context.newSession - // Only genuine sign-in entrypoints count as a login. `/verify-email` is - // deliberately excluded: email verification is an account-lifecycle event - // that can mint or refresh a session without being a new sign-in, so - // treating it as a login would mislabel (or double-count, when the prior - // sign-up already recorded one). - const isLoginPath = - ctx.path.startsWith('/sign-in') || - ctx.path.startsWith('/sign-up') || - ctx.path.startsWith('/callback') || - ctx.path.startsWith('/oauth2/callback') || - ctx.path.startsWith('/sso/callback') || - ctx.path.startsWith('/magic-link') || - ctx.path.startsWith('/email-otp') - if (newSession?.user?.id && isLoginPath) { - recordAudit({ - actorId: newSession.user.id, - actorName: newSession.user.name, - actorEmail: newSession.user.email, - action: AuditAction.USER_LOGIN, - resourceType: AuditResourceType.SESSION, - resourceId: newSession.session?.id ?? newSession.user.id, - description: 'User signed in', - metadata: { - method: ctx.path, - ...(typeof ctx.body?.provider === 'string' ? { provider: ctx.body.provider } : {}), - }, - request, - }) - } - - /** - * Session revocation endpoints run `sensitiveSessionMiddleware`, so the - * acting user is resolved onto `ctx.context.session`. The revoked token is - * deliberately not stored in metadata to avoid leaking a session secret. - */ - if ( - ctx.path === '/revoke-session' || - ctx.path === '/revoke-sessions' || - ctx.path === '/revoke-other-sessions' - ) { - const actor = ctx.context.session?.user - if (actor?.id) { - const isSingle = ctx.path === '/revoke-session' - recordAudit({ - actorId: actor.id, - actorName: actor.name, - actorEmail: actor.email, - action: AuditAction.SESSION_REVOKED, - resourceType: AuditResourceType.SESSION, - resourceId: actor.id, - description: isSingle ? 'Session revoked' : 'Sessions revoked', - metadata: { scope: isSingle ? 'single' : 'all', path: ctx.path }, - request, - }) - } - } - }), }, plugins: [ ...(env.TURNSTILE_SECRET_KEY diff --git a/packages/audit/src/index.ts b/packages/audit/src/index.ts index 09129ed1f18..f3ec73f8c5d 100644 --- a/packages/audit/src/index.ts +++ b/packages/audit/src/index.ts @@ -1,3 +1,3 @@ -export { recordAudit, recordAuditNow } from './log' +export { recordAudit } from './log' export type { AuditActionType, AuditResourceTypeValue } from './types' export { AuditAction, AuditResourceType } from './types' diff --git a/packages/audit/src/log.ts b/packages/audit/src/log.ts index 8ad692a30a5..85e434d7a62 100644 --- a/packages/audit/src/log.ts +++ b/packages/audit/src/log.ts @@ -44,20 +44,6 @@ export function recordAudit(params: AuditLogParams): void { }) } -/** - * Awaitable audit log write for callers that must guarantee the row is persisted - * before they proceed — e.g. inside a pre-delete hook where the referenced - * `user` row is about to be removed and a deferred insert would race the delete - * (FK violation). Never throws; failures are logged. - */ -export async function recordAuditNow(params: AuditLogParams): Promise { - try { - await insertAuditLog(params) - } catch (error) { - logger.error('Failed to record audit log', { error, action: params.action }) - } -} - async function insertAuditLog(params: AuditLogParams): Promise { const ipAddress = params.request ? getClientIp(params.request) : undefined const userAgent = params.request?.headers.get('user-agent') ?? undefined diff --git a/packages/audit/src/types.ts b/packages/audit/src/types.ts index d7dff96fc6d..5471b1d9627 100644 --- a/packages/audit/src/types.ts +++ b/packages/audit/src/types.ts @@ -2,16 +2,6 @@ * All auditable actions in the platform, grouped by resource type. */ export const AuditAction = { - // Account / Authentication - USER_LOGIN: 'user.login', - USER_LOGIN_FAILED: 'user.login_failed', - USER_SIGNIN_BLOCKED: 'user.signin_blocked', - USER_SIGNUP_BLOCKED: 'user.signup_blocked', - USER_LOGOUT: 'user.logout', - SESSION_REVOKED: 'session.revoked', - ACCOUNT_DELETED: 'account.deleted', - ACCOUNT_EMAIL_CHANGED: 'account.email_changed', - // API Keys API_KEY_CREATED: 'api_key.created', API_KEY_UPDATED: 'api_key.updated', @@ -243,11 +233,9 @@ export const AuditResourceType = { PASSWORD: 'password', PERMISSION_GROUP: 'permission_group', SCHEDULE: 'schedule', - SESSION: 'session', SKILL: 'skill', SUBSCRIPTION: 'subscription', TABLE: 'table', - USER: 'user', WEBHOOK: 'webhook', WORKFLOW: 'workflow', WORKSPACE: 'workspace', diff --git a/packages/testing/src/mocks/audit.mock.ts b/packages/testing/src/mocks/audit.mock.ts index 7e81cb59257..ff75ee0c634 100644 --- a/packages/testing/src/mocks/audit.mock.ts +++ b/packages/testing/src/mocks/audit.mock.ts @@ -158,14 +158,6 @@ export const auditMock = { WORKSPACE_FORK_PROMOTED: 'workspace.fork_promoted', WORKSPACE_FORK_ROLLED_BACK: 'workspace.fork_rolled_back', WORKSPACE_EXPORTED: 'workspace.exported', - USER_LOGIN: 'user.login', - USER_LOGIN_FAILED: 'user.login_failed', - USER_SIGNIN_BLOCKED: 'user.signin_blocked', - USER_SIGNUP_BLOCKED: 'user.signup_blocked', - USER_LOGOUT: 'user.logout', - SESSION_REVOKED: 'session.revoked', - ACCOUNT_DELETED: 'account.deleted', - ACCOUNT_EMAIL_CHANGED: 'account.email_changed', CREDIT_ISSUED: 'credit.issued', INVOICE_PAYMENT_SUCCEEDED: 'invoice.payment_succeeded', INVOICE_PAYMENT_FAILED: 'invoice.payment_failed', @@ -205,11 +197,9 @@ export const auditMock = { PASSWORD: 'password', PERMISSION_GROUP: 'permission_group', SCHEDULE: 'schedule', - SESSION: 'session', SKILL: 'skill', SUBSCRIPTION: 'subscription', TABLE: 'table', - USER: 'user', WEBHOOK: 'webhook', WORKFLOW: 'workflow', WORKSPACE: 'workspace', From f61760808ae6418bf47e8be32a15493db51d12e9 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 16:18:21 -0700 Subject: [PATCH 15/29] fix(storage): harden copilot+KB delete accounting against read errors and concurrency Final-audit follow-ups: - deleteCopilotFile: a failed metadata *read* (vs a genuine missing row) now blocks the blob delete too, so a transient read error can't leave an active row un-decremented with the blob gone. - hardDeleteDocuments: drive the per-user decrement from the delete's returning() (the rows this tx actually removed), so two concurrent deletes of the same ids can't both decrement. --- apps/sim/lib/knowledge/documents/service.ts | 37 ++++++++++++------- .../contexts/copilot/copilot-file-manager.ts | 13 +++++-- 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index 6b1b9594294..7ebdd985616 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -2052,30 +2052,41 @@ export async function hardDeleteDocuments( } const existingIds = documentsToDelete.map((doc) => doc.id) + const docInfoById = new Map(documentsToDelete.map((doc) => [doc.id, doc])) - // Sum the billable bytes per owning user. Connector-synced documents are never - // metered at ingest (the sync engine inserts them directly), so they must not - // be decremented here. - const bytesByUser = new Map() + // Resolve subscriptions for every candidate billed owner before the transaction + // (these are reads). Connector-synced documents are never metered at ingest + // (the sync engine inserts them directly), so they are excluded here too. + const candidateUserIds = new Set() for (const doc of documentsToDelete) { if (doc.connectorId || doc.fileSize <= 0) continue const billedUserId = doc.uploadedBy ?? doc.kbUserId - if (!billedUserId) continue - bytesByUser.set(billedUserId, (bytesByUser.get(billedUserId) ?? 0) + doc.fileSize) + if (billedUserId) candidateUserIds.add(billedUserId) } - - // Resolve each owner's subscription before the transaction (these are reads), - // then decrement the counters inside the same transaction that deletes the - // rows, so a failure of either rolls both back — the billed storage can never - // be left inflated once the content is gone. const subByUser = new Map() - for (const billedUserId of bytesByUser.keys()) { + for (const billedUserId of candidateUserIds) { subByUser.set(billedUserId, await getHighestPrioritySubscription(billedUserId)) } + // Decrement inside the same transaction that deletes the rows, driven by the + // rows THIS transaction actually deleted (`returning()`) — so a concurrent + // delete of the same ids removes 0 rows and decrements nothing (no double + // decrement), and a rollback leaves the counter untouched (no inflation). await db.transaction(async (tx) => { await tx.delete(embedding).where(inArray(embedding.documentId, existingIds)) - await tx.delete(document).where(inArray(document.id, existingIds)) + const deletedRows = await tx + .delete(document) + .where(inArray(document.id, existingIds)) + .returning({ id: document.id }) + + const bytesByUser = new Map() + for (const { id } of deletedRows) { + const info = docInfoById.get(id) + if (!info || info.connectorId || info.fileSize <= 0) continue + const billedUserId = info.uploadedBy ?? info.kbUserId + if (!billedUserId) continue + bytesByUser.set(billedUserId, (bytesByUser.get(billedUserId) ?? 0) + info.fileSize) + } for (const [billedUserId, bytes] of bytesByUser) { await decrementStorageUsageInTx(tx, subByUser.get(billedUserId) ?? null, billedUserId, bytes) } diff --git a/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts b/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts index caa7f4863e0..3b0b6e4d991 100644 --- a/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts @@ -249,16 +249,20 @@ export async function generateCopilotDownloadUrl( * @param key File storage key */ export async function deleteCopilotFile(key: string): Promise { + let metadataReadFailed = false const metadata = await getFileMetadataByKey(key, 'copilot').catch((error) => { logger.error('Failed to read copilot file metadata for storage accounting:', error) + metadataReadFailed = true return null }) // Settle storage accounting (atomic metadata soft-delete + quota decrement) - // BEFORE removing the blob. If it fails, the file is left fully intact — blob, - // active metadata, and counter all consistent — so a retry can re-run cleanly, - // rather than orphaning the blob with a permanently inflated counter. - let released = true + // BEFORE removing the blob. If accounting can't be completed, the file is left + // fully intact — blob, active metadata, and counter all consistent — so a retry + // re-runs cleanly rather than orphaning the blob with an inflated counter. A + // failed metadata *read* (vs a genuine missing row) also blocks the blob delete, + // since a row may still exist and would be left un-decremented. + let released = !metadataReadFailed if (metadata) { try { await releaseDeletedFileStorage( @@ -267,6 +271,7 @@ export async function deleteCopilotFile(key: string): Promise { metadata.size, metadata.workspaceId ?? undefined ) + released = true } catch (storageError) { released = false logger.error('Failed to release copilot file storage:', storageError) From f759a0d5cbe585376e842b2cd16d3a4c99222444 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 16:44:09 -0700 Subject: [PATCH 16/29] chore(observability): drop two unused definitions Final-audit cleanup: remove the orphaned knowledge_base_searched PostHog event (never wired; KB search analytics already flow through the OpenTelemetry channel) and the redundant AuditAction.SUBSCRIPTION_UPDATED (subscription/plan changes are audited via ORG_PLAN_CONVERTED). Every remaining new action/event has a real emit site. --- apps/sim/lib/posthog/events.ts | 10 ---------- packages/audit/src/types.ts | 1 - packages/testing/src/mocks/audit.mock.ts | 1 - 3 files changed, 12 deletions(-) diff --git a/apps/sim/lib/posthog/events.ts b/apps/sim/lib/posthog/events.ts index b9481465e50..330424a66ae 100644 --- a/apps/sim/lib/posthog/events.ts +++ b/apps/sim/lib/posthog/events.ts @@ -704,16 +704,6 @@ export interface PostHogEventMap { workspace_id: string } - /** Recurring runtime KB retrieval — the core knowledge-base usage signal. */ - knowledge_base_searched: { - knowledge_base_id: string - workspace_id: string - result_count: number - top_k: number - reranking_enabled: boolean - duration_ms: number - } - /** A stored credential's plaintext secret was deliberately retrieved via the token API. */ credential_used: { credential_type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' diff --git a/packages/audit/src/types.ts b/packages/audit/src/types.ts index 5471b1d9627..cbb93dfef53 100644 --- a/packages/audit/src/types.ts +++ b/packages/audit/src/types.ts @@ -42,7 +42,6 @@ export const AuditAction = { // Subscriptions SUBSCRIPTION_CREATED: 'subscription.created', - SUBSCRIPTION_UPDATED: 'subscription.updated', SUBSCRIPTION_CANCELLED: 'subscription.cancelled', SUBSCRIPTION_TRANSFERRED: 'subscription.transferred', ENTERPRISE_SUBSCRIPTION_PROVISIONED: 'subscription.enterprise_provisioned', diff --git a/packages/testing/src/mocks/audit.mock.ts b/packages/testing/src/mocks/audit.mock.ts index ff75ee0c634..f517ea5b8da 100644 --- a/packages/testing/src/mocks/audit.mock.ts +++ b/packages/testing/src/mocks/audit.mock.ts @@ -165,7 +165,6 @@ export const auditMock = { CHARGE_DISPUTE_OPENED: 'charge.dispute_opened', CHARGE_DISPUTE_CLOSED: 'charge.dispute_closed', SUBSCRIPTION_CREATED: 'subscription.created', - SUBSCRIPTION_UPDATED: 'subscription.updated', SUBSCRIPTION_CANCELLED: 'subscription.cancelled', SUBSCRIPTION_TRANSFERRED: 'subscription.transferred', ENTERPRISE_SUBSCRIPTION_PROVISIONED: 'subscription.enterprise_provisioned', From 139174bb8e19871c842ce5a5bff88b8694264c4a Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 17:00:59 -0700 Subject: [PATCH 17/29] fix(knowledge): key hard-delete result off rows actually deleted Address Cursor review: hardDeleteDocuments returned existingIds.length (the requested count) and cleaned storage for the full pre-tx set, even though the decrement was driven by the rows the transaction actually deleted. Under a concurrent delete that claimed some ids first, that overstated the result and re-touched storage for rows this call didn't delete. Drive the storage cleanup, log, and return value from deletedDocs (the returning() rows) so all four are consistent. --- apps/sim/lib/knowledge/documents/service.ts | 30 +++++++++++---------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index 7ebdd985616..58e7960aa92 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -2052,7 +2052,6 @@ export async function hardDeleteDocuments( } const existingIds = documentsToDelete.map((doc) => doc.id) - const docInfoById = new Map(documentsToDelete.map((doc) => [doc.id, doc])) // Resolve subscriptions for every candidate billed owner before the transaction // (these are reads). Connector-synced documents are never metered at ingest @@ -2068,10 +2067,11 @@ export async function hardDeleteDocuments( subByUser.set(billedUserId, await getHighestPrioritySubscription(billedUserId)) } - // Decrement inside the same transaction that deletes the rows, driven by the - // rows THIS transaction actually deleted (`returning()`) — so a concurrent - // delete of the same ids removes 0 rows and decrements nothing (no double - // decrement), and a rollback leaves the counter untouched (no inflation). + // Everything downstream is keyed off the rows THIS transaction actually deleted + // (`returning()`), not the requested ids — so a concurrent delete that removed + // some ids first doesn't get double-decremented, double-cleaned, or counted + // here, and a rollback leaves the counter untouched. + let deletedDocs: typeof documentsToDelete = [] await db.transaction(async (tx) => { await tx.delete(embedding).where(inArray(embedding.documentId, existingIds)) const deletedRows = await tx @@ -2079,26 +2079,28 @@ export async function hardDeleteDocuments( .where(inArray(document.id, existingIds)) .returning({ id: document.id }) + const deletedIds = new Set(deletedRows.map((row) => row.id)) + deletedDocs = documentsToDelete.filter((doc) => deletedIds.has(doc.id)) + const bytesByUser = new Map() - for (const { id } of deletedRows) { - const info = docInfoById.get(id) - if (!info || info.connectorId || info.fileSize <= 0) continue - const billedUserId = info.uploadedBy ?? info.kbUserId + for (const doc of deletedDocs) { + if (doc.connectorId || doc.fileSize <= 0) continue + const billedUserId = doc.uploadedBy ?? doc.kbUserId if (!billedUserId) continue - bytesByUser.set(billedUserId, (bytesByUser.get(billedUserId) ?? 0) + info.fileSize) + bytesByUser.set(billedUserId, (bytesByUser.get(billedUserId) ?? 0) + doc.fileSize) } for (const [billedUserId, bytes] of bytesByUser) { await decrementStorageUsageInTx(tx, subByUser.get(billedUserId) ?? null, billedUserId, bytes) } }) - await deleteDocumentStorageFiles(documentsToDelete, requestId) + await deleteDocumentStorageFiles(deletedDocs, requestId) - logger.info(`[${requestId}] Hard deleted ${existingIds.length} documents`, { - documentIds: existingIds, + logger.info(`[${requestId}] Hard deleted ${deletedDocs.length} documents`, { + documentIds: deletedDocs.map((doc) => doc.id), }) - return existingIds.length + return deletedDocs.length } export async function deleteDocument( From 50052bac6e7bbedfbe78c4eb7ddcd2452962168e Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 17:09:50 -0700 Subject: [PATCH 18/29] fix(storage): gate copilot quota on all ingest paths; tidy inline comments - Copilot uploads via the generic /api/files/upload route and presigned URLs now run checkStorageQuota before writing (matching uploadCopilotFile), so no copilot ingest path can grow usage past the plan limit. The central increment stays in insertFileMetadata. - Trim/remove redundant inline comments across the diff; keep only concise notes on non-obvious decisions (left pre-existing comments untouched). --- .../api/files/public/[token]/content/route.ts | 3 +-- .../api/files/public/[token]/inline/route.ts | 3 +-- apps/sim/app/api/files/upload/route.ts | 10 ++++++++++ .../api/v1/admin/workflows/export/route.ts | 2 -- apps/sim/lib/billing/webhooks/disputes.ts | 6 ++---- apps/sim/lib/knowledge/documents/service.ts | 14 +++++-------- apps/sim/lib/table/service.ts | 5 ++--- .../contexts/copilot/copilot-file-manager.ts | 20 ++++++++++--------- packages/audit/src/log.ts | 5 ++--- 9 files changed, 34 insertions(+), 34 deletions(-) diff --git a/apps/sim/app/api/files/public/[token]/content/route.ts b/apps/sim/app/api/files/public/[token]/content/route.ts index 1a08f3a474c..87282d86f4d 100644 --- a/apps/sim/app/api/files/public/[token]/content/route.ts +++ b/apps/sim/app/api/files/public/[token]/content/route.ts @@ -77,8 +77,7 @@ export const GET = withRouteHandler( logger.info('Public shared file served', { token, key: file.key, size: buffer.length }) - // Anonymous external access: null actor (owner-as-actor would read as a - // self-download); share owner in metadata, ip/user-agent carry the trail. + // Anonymous access: null actor (owner-as-actor would misread as a self-download). recordAudit({ workspaceId: file.workspaceId ?? null, actorId: null, diff --git a/apps/sim/app/api/files/public/[token]/inline/route.ts b/apps/sim/app/api/files/public/[token]/inline/route.ts index 98a6d0d2822..a777b953ba5 100644 --- a/apps/sim/app/api/files/public/[token]/inline/route.ts +++ b/apps/sim/app/api/files/public/[token]/inline/route.ts @@ -90,8 +90,7 @@ export const GET = withRouteHandler( // Content-truth gate (`sniff`): render only genuine raster image bytes; audit after. const response = await serveInlineImage(image, { sniff: true }) - // Anonymous external access: null actor FK (not the owner), share owner in - // metadata, ip/user-agent carry the trail. + // Anonymous access: null actor (owner-as-actor would misread as a self-download). recordAudit({ workspaceId: doc.workspaceId, actorId: null, diff --git a/apps/sim/app/api/files/upload/route.ts b/apps/sim/app/api/files/upload/route.ts index cb23b6ec851..d34ad675380 100644 --- a/apps/sim/app/api/files/upload/route.ts +++ b/apps/sim/app/api/files/upload/route.ts @@ -10,6 +10,7 @@ import { } from '@/lib/api/contracts/storage-transfer' import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' +import { checkStorageQuota } from '@/lib/billing/storage' import { assertKnownSizeWithinLimit, isPayloadSizeLimitError, @@ -363,6 +364,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { metadata.workspaceId = workspaceId } + // Copilot uploads are metered, so gate against the storage quota before + // writing — the increment happens centrally when the metadata row lands. + if (context === 'copilot') { + const quotaCheck = await checkStorageQuota(session.user.id, buffer.length) + if (!quotaCheck.allowed) { + throw new InvalidRequestError(quotaCheck.error || 'Storage limit exceeded') + } + } + const fileInfo = await storageService.uploadFile({ file: buffer, fileName: storageKey, diff --git a/apps/sim/app/api/v1/admin/workflows/export/route.ts b/apps/sim/app/api/v1/admin/workflows/export/route.ts index b3e2678bc31..f3f90f968cd 100644 --- a/apps/sim/app/api/v1/admin/workflows/export/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/export/route.ts @@ -102,8 +102,6 @@ export const POST = withRouteHandler( logger.info(`Admin API: Exporting ${workflowExports.length} workflows`) - // No workflow resolved (every requested id failed to load) — nothing was - // exfiltrated, so don't record a successful export. const auditExport = () => { if (workflowExports.length === 0) return recordAudit({ diff --git a/apps/sim/lib/billing/webhooks/disputes.ts b/apps/sim/lib/billing/webhooks/disputes.ts index 3bffd3d84db..ed0702c4a4b 100644 --- a/apps/sim/lib/billing/webhooks/disputes.ts +++ b/apps/sim/lib/billing/webhooks/disputes.ts @@ -152,10 +152,8 @@ export async function handleDisputeClosed(event: Stripe.Event): Promise { return } - // Unblock only when we won or the warning closed without a full dispute; a - // 'lost' dispute keeps the customer blocked (they owe us). The close is - // audited in every case so the chargeback trail is complete — `dispute.status` - // in the metadata distinguishes the outcome. + // Unblock only on won/warning_closed; a 'lost' dispute stays blocked. The close + // is audited in every case (dispute.status in metadata distinguishes the outcome). const shouldUnblock = dispute.status === 'won' || dispute.status === 'warning_closed' const users = await db diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index 58e7960aa92..d9194f76f53 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -905,7 +905,6 @@ export async function createDocumentRecords( tx ) - // Bill stored source bytes to the uploader when known, else the KB owner. const billedUserId = uploadedBy ?? kb[0].userId const totalBytes = documents.reduce((sum, docData) => sum + (docData.fileSize || 0), 0) if (totalBytes > 0) { @@ -1360,7 +1359,6 @@ export async function createSingleDocument( tx ) - // Bill stored source bytes to the uploader when known, else the KB owner. const billedUserId = uploadedBy ?? kb[0].userId if (documentData.fileSize > 0) { const quotaCheck = await checkStorageQuota(billedUserId, documentData.fileSize) @@ -2053,9 +2051,8 @@ export async function hardDeleteDocuments( const existingIds = documentsToDelete.map((doc) => doc.id) - // Resolve subscriptions for every candidate billed owner before the transaction - // (these are reads). Connector-synced documents are never metered at ingest - // (the sync engine inserts them directly), so they are excluded here too. + // Resolve owner subscriptions before the transaction (reads). Connector-synced + // documents are never metered at ingest, so they are excluded from billing here. const candidateUserIds = new Set() for (const doc of documentsToDelete) { if (doc.connectorId || doc.fileSize <= 0) continue @@ -2067,10 +2064,9 @@ export async function hardDeleteDocuments( subByUser.set(billedUserId, await getHighestPrioritySubscription(billedUserId)) } - // Everything downstream is keyed off the rows THIS transaction actually deleted - // (`returning()`), not the requested ids — so a concurrent delete that removed - // some ids first doesn't get double-decremented, double-cleaned, or counted - // here, and a rollback leaves the counter untouched. + // Key the decrement, storage cleanup, log, and return value off the rows this + // transaction actually deleted (`returning()`), so a concurrent delete that + // claimed some ids first isn't double-counted here. let deletedDocs: typeof documentsToDelete = [] await db.transaction(async (tx) => { await tx.delete(embedding).where(inArray(embedding.documentId, existingIds)) diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index c2ac0cc1e14..d6fbe91c9cf 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -649,9 +649,8 @@ export async function deleteTable( }) const deleted = result[0] - // Audit only genuine user-initiated deletes — rollback/cleanup callers omit - // `actingUserId`. The caller emits the `table_deleted` PostHog event (where the - // acting user is known), so it is not duplicated here. + // Audit only genuine user deletes — rollback callers omit `actingUserId`. The + // caller emits the `table_deleted` PostHog event, so it is not duplicated here. if (deleted && actingUserId) { recordAudit({ workspaceId: deleted.workspaceId ?? null, diff --git a/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts b/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts index 3b0b6e4d991..d89d991d3af 100644 --- a/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts @@ -95,6 +95,11 @@ export async function generateCopilotUploadUrl( ) } + const quotaCheck = await checkStorageQuota(userId, fileSize) + if (!quotaCheck.allowed) { + throw new Error(quotaCheck.error || 'Storage limit exceeded') + } + const presignedUrlResponse = await generatePresignedUploadUrl({ fileName, contentType, @@ -144,9 +149,8 @@ export async function uploadCopilotFile(options: { userId: options.userId, }) - // Storage is metered centrally when the copilot metadata row is created - // (see insertFileMetadata), so every ingest path stays symmetric — no - // per-path increment here. + // Storage is incremented centrally when the metadata row is created (see + // insertFileMetadata), so there is no per-path increment here. return { id: fileInfo.key, key: fileInfo.key, @@ -256,12 +260,10 @@ export async function deleteCopilotFile(key: string): Promise { return null }) - // Settle storage accounting (atomic metadata soft-delete + quota decrement) - // BEFORE removing the blob. If accounting can't be completed, the file is left - // fully intact — blob, active metadata, and counter all consistent — so a retry - // re-runs cleanly rather than orphaning the blob with an inflated counter. A - // failed metadata *read* (vs a genuine missing row) also blocks the blob delete, - // since a row may still exist and would be left un-decremented. + // Settle accounting (atomic soft-delete + decrement) BEFORE removing the blob, + // and only delete the blob if it succeeded — so any failure (including a failed + // metadata read, which may hide a still-active row) leaves the file fully intact + // and retryable rather than orphaning the blob with an inflated counter. let released = !metadataReadFailed if (metadata) { try { diff --git a/packages/audit/src/log.ts b/packages/audit/src/log.ts index 85e434d7a62..3a9a6ef66d4 100644 --- a/packages/audit/src/log.ts +++ b/packages/audit/src/log.ts @@ -73,9 +73,8 @@ async function insertAuditLog(params: AuditLogParams): Promise { actorId = null } } catch (error) { - // The lookup couldn't confirm the user exists, so null the FK to guarantee - // the insert can't violate it (e.g. a system actor like 'admin-api', or a - // since-deleted user). The label still identifies the actor. + // Couldn't confirm the user exists — null the FK so the insert can't violate + // it (system actor like 'admin-api', or a deleted user); the label remains. logger.warn('Failed to resolve actor info', { error, actorId }) actorName = actorId === 'admin-api' ? 'Admin API' : 'System' actorId = null From 470edf706edb1d437b11252f634781e926cb8079 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 17:20:33 -0700 Subject: [PATCH 19/29] fix(analytics): omit empty workspace_id on workspace-less file downloads Address Greptile P1: the generic key-based /api/files/download route and the no-workspace markdown-export path emitted file_downloaded with workspace_id:'' creating a phantom '' bucket in PostHog. Make workspace_id optional on the event and omit it when there is no workspace (workspace-scoped routes still pass it). --- apps/sim/app/api/files/download/route.ts | 1 - apps/sim/app/api/files/export/[id]/route.ts | 2 +- apps/sim/lib/posthog/events.ts | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/api/files/download/route.ts b/apps/sim/app/api/files/download/route.ts index 400e33c1c2a..793b4a55d44 100644 --- a/apps/sim/app/api/files/download/route.ts +++ b/apps/sim/app/api/files/download/route.ts @@ -98,7 +98,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { request, }) captureServerEvent(userId, 'file_downloaded', { - workspace_id: '', is_bulk: false, file_count: 1, }) diff --git a/apps/sim/app/api/files/export/[id]/route.ts b/apps/sim/app/api/files/export/[id]/route.ts index 445a0bc9919..8a7e4b292e5 100644 --- a/apps/sim/app/api/files/export/[id]/route.ts +++ b/apps/sim/app/api/files/export/[id]/route.ts @@ -97,7 +97,7 @@ export const GET = withRouteHandler( userId, 'file_downloaded', { - workspace_id: record.workspaceId ?? '', + ...(record.workspaceId ? { workspace_id: record.workspaceId } : {}), is_bulk: assetCount > 0, file_count: 1 + assetCount, }, diff --git a/apps/sim/lib/posthog/events.ts b/apps/sim/lib/posthog/events.ts index 330424a66ae..c7481321c1c 100644 --- a/apps/sim/lib/posthog/events.ts +++ b/apps/sim/lib/posthog/events.ts @@ -447,7 +447,7 @@ export interface PostHogEventMap { } file_downloaded: { - workspace_id: string + workspace_id?: string is_bulk: boolean file_count: number } From 0433e520a014bff417e817979e38009cda023483 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 17:31:56 -0700 Subject: [PATCH 20/29] fix(analytics): omit empty workspace_id/workflow_id on copilot_chat_sent Final-sweep nit: copilot_chat_sent sent '' for workspace_id/workflow_id in the agent (workspace-less) branch, same phantom-bucket issue as file_downloaded. Make both properties optional and omit them when absent. --- apps/sim/lib/copilot/chat/post.ts | 4 ++-- apps/sim/lib/posthog/events.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index ca81ebac0bb..e168bae26cf 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -1081,8 +1081,8 @@ export async function handleUnifiedChatPost(req: NextRequest) { authenticatedUserId, 'copilot_chat_sent', { - workflow_id: branch.kind === 'workflow' ? branch.workflowId : '', - workspace_id: workspaceId ?? '', + ...(branch.kind === 'workflow' ? { workflow_id: branch.workflowId } : {}), + ...(workspaceId ? { workspace_id: workspaceId } : {}), has_file_attachments: (body.fileAttachments?.length ?? 0) > 0, has_contexts: normalizedContexts.length > 0, mode: branch.kind === 'workflow' ? branch.mode : 'agent', diff --git a/apps/sim/lib/posthog/events.ts b/apps/sim/lib/posthog/events.ts index c7481321c1c..e35bb8857fc 100644 --- a/apps/sim/lib/posthog/events.ts +++ b/apps/sim/lib/posthog/events.ts @@ -360,8 +360,8 @@ export interface PostHogEventMap { } copilot_chat_sent: { - workflow_id: string - workspace_id: string + workflow_id?: string + workspace_id?: string has_file_attachments: boolean has_contexts: boolean mode: string From fe36c68d4aba4a38c25a8a139f52fbc4287b0b2d Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 17:38:01 -0700 Subject: [PATCH 21/29] fix(analytics): clear stale org PostHog group on personal workspaces Address Cursor review: switching from a team workspace to a personal one left the previous organization group set, so later events kept rolling up under it. When the active workspace has no organizationId, resetGroups() to drop the stale org group, then re-apply the workspace group. --- .../[workspaceId]/providers/workspace-scope-sync.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/providers/workspace-scope-sync.tsx b/apps/sim/app/workspace/[workspaceId]/providers/workspace-scope-sync.tsx index 612b1c1864a..d2b90f81bae 100644 --- a/apps/sim/app/workspace/[workspaceId]/providers/workspace-scope-sync.tsx +++ b/apps/sim/app/workspace/[workspaceId]/providers/workspace-scope-sync.tsx @@ -22,10 +22,16 @@ export function WorkspaceScopeSync() { useEffect(() => { if (!workspaceId) return - posthog?.group('workspace', workspaceId, workspaceName ? { name: workspaceName } : undefined) if (organizationId) { posthog?.group('organization', organizationId) + } else { + // A personal workspace has no org — drop any organization group carried + // over from a previously-active team workspace so later events don't keep + // rolling up under it. resetGroups clears all groups, so the workspace + // group is re-applied immediately below. + posthog?.resetGroups() } + posthog?.group('workspace', workspaceId, workspaceName ? { name: workspaceName } : undefined) }, [posthog, workspaceId, workspaceName, organizationId]) useEffect(() => { From e824f380f11abba205d0ead10af55ce00c5f08b7 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 17:46:11 -0700 Subject: [PATCH 22/29] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20exp?= =?UTF-8?q?ort=20audit=20timing,=20copilot=20delete=20signal,=20group=20re?= =?UTF-8?q?set?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Table export (Cursor MED): audit fires before streaming begins, not after controller.close(), so a mid-stream failure still records the partial export. - deleteCopilotFile (Greptile P1): throw when storage accounting can't be settled instead of silently returning, so callers can detect the file was not deleted. - workspace-scope-sync (Cursor MED): only resetGroups() once workspace metadata is loaded (activeWorkspace present), so a team workspace doesn't transiently lose its org group while organizationId is still null during load. --- .../app/api/table/[tableId]/export/route.ts | 43 ++++++++++--------- .../providers/workspace-scope-sync.tsx | 14 +++--- .../contexts/copilot/copilot-file-manager.ts | 17 +++++--- 3 files changed, 42 insertions(+), 32 deletions(-) diff --git a/apps/sim/app/api/table/[tableId]/export/route.ts b/apps/sim/app/api/table/[tableId]/export/route.ts index df7de7b1e23..55c81a880b2 100644 --- a/apps/sim/app/api/table/[tableId]/export/route.ts +++ b/apps/sim/app/api/table/[tableId]/export/route.ts @@ -56,6 +56,29 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou const safeName = sanitizeFilename(table.name) const filename = `${safeName}.${format}` + // Record the export before streaming begins. Rows leave the server incrementally, + // so a mid-stream failure would still exfiltrate partial data — auditing here + // (after access is granted) guarantees every authorized export is recorded. + recordAudit({ + workspaceId: table.workspaceId ?? null, + actorId: userId, + action: AuditAction.TABLE_EXPORTED, + resourceType: AuditResourceType.TABLE, + resourceId: tableId, + resourceName: table.name, + description: `Exported table "${table.name}" as ${format.toUpperCase()}`, + metadata: { format, rowCount: table.rowCount }, + request, + }) + if (table.workspaceId) { + captureServerEvent( + userId, + 'table_exported', + { table_id: tableId, workspace_id: table.workspaceId }, + { groups: { workspace: table.workspaceId } } + ) + } + const stream = new ReadableStream({ async start(controller) { const encoder = new TextEncoder() @@ -101,26 +124,6 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou format, rowCount: table.rowCount, }) - - recordAudit({ - workspaceId: table.workspaceId ?? null, - actorId: userId, - action: AuditAction.TABLE_EXPORTED, - resourceType: AuditResourceType.TABLE, - resourceId: tableId, - resourceName: table.name, - description: `Exported table "${table.name}" as ${format.toUpperCase()}`, - metadata: { format, rowCount: table.rowCount }, - request, - }) - if (table.workspaceId) { - captureServerEvent( - userId, - 'table_exported', - { table_id: tableId, workspace_id: table.workspaceId }, - { groups: { workspace: table.workspaceId } } - ) - } } catch (err) { logger.error(`[${requestId}] Export failed for table ${tableId}`, err) controller.error(err) diff --git a/apps/sim/app/workspace/[workspaceId]/providers/workspace-scope-sync.tsx b/apps/sim/app/workspace/[workspaceId]/providers/workspace-scope-sync.tsx index d2b90f81bae..3c458f026e4 100644 --- a/apps/sim/app/workspace/[workspaceId]/providers/workspace-scope-sync.tsx +++ b/apps/sim/app/workspace/[workspaceId]/providers/workspace-scope-sync.tsx @@ -24,15 +24,17 @@ export function WorkspaceScopeSync() { if (!workspaceId) return if (organizationId) { posthog?.group('organization', organizationId) - } else { - // A personal workspace has no org — drop any organization group carried - // over from a previously-active team workspace so later events don't keep - // rolling up under it. resetGroups clears all groups, so the workspace - // group is re-applied immediately below. + } else if (activeWorkspace) { + // Metadata is loaded and this workspace genuinely has no org — drop any + // organization group carried over from a previously-active team workspace. + // While metadata is still loading, activeWorkspace is undefined and + // organizationId is transiently null, so resetting here would briefly strip + // a team workspace's org group. resetGroups clears all groups, so the + // workspace group is re-applied immediately below. posthog?.resetGroups() } posthog?.group('workspace', workspaceId, workspaceName ? { name: workspaceName } : undefined) - }, [posthog, workspaceId, workspaceName, organizationId]) + }, [posthog, workspaceId, workspaceName, organizationId, activeWorkspace]) useEffect(() => { if (!workspaceId || hydrationWorkspaceId === workspaceId) { diff --git a/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts b/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts index d89d991d3af..eb937dd6eae 100644 --- a/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts @@ -280,11 +280,16 @@ export async function deleteCopilotFile(key: string): Promise { } } - if (released) { - await deleteFile({ - key, - context: 'copilot', - }) - logger.info(`Successfully deleted copilot file: ${key}`) + // Accounting couldn't be settled (read or release failed). The file is left + // fully intact; throw so the caller knows the deletion did not happen and can + // retry, rather than silently reporting success. + if (!released) { + throw new Error(`Copilot file deletion aborted; storage accounting failed for key: ${key}`) } + + await deleteFile({ + key, + context: 'copilot', + }) + logger.info(`Successfully deleted copilot file: ${key}`) } From 0c4a8432e4db21742dc670915c756551fda85d40 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 18:22:13 -0700 Subject: [PATCH 23/29] refactor(observability): final line-justification cleanup Address final audit flags: - Table import: move the 'columns added' audit OUT of the import transaction into a post-commit auditTableColumnsAdded() helper called by the three tx-owning callers, so a mid-import row-batch rollback no longer logs a false 'added N columns' (matches the PR's success-only discipline). - Omit empty-string analytics dimensions on workflow_lock_toggled (workspace_id) and organization_created (name), consistent with file_downloaded/copilot_chat_sent. - Restore an unrelated capacity-check comment removed incidentally. - Move copilot_chat_sent emit above the traceparent comment so the comment sits with the code it documents. --- apps/sim/app/api/organizations/route.ts | 5 ++- apps/sim/lib/copilot/chat/post.ts | 12 ++--- apps/sim/lib/posthog/events.ts | 4 +- apps/sim/lib/table/import-data.ts | 33 ++++++++++++-- apps/sim/lib/table/service.ts | 44 ++++++++++++------- .../orchestration/workflow-lifecycle.ts | 6 ++- 6 files changed, 74 insertions(+), 30 deletions(-) diff --git a/apps/sim/app/api/organizations/route.ts b/apps/sim/app/api/organizations/route.ts index 81194ab7069..839ff3c7617 100644 --- a/apps/sim/app/api/organizations/route.ts +++ b/apps/sim/app/api/organizations/route.ts @@ -260,7 +260,10 @@ export const POST = withRouteHandler(async (request: Request) => { captureServerEvent( user.id, 'organization_created', - { organization_id: organizationId, name: organizationName ?? '' }, + { + organization_id: organizationId, + ...(organizationName ? { name: organizationName } : {}), + }, { groups: { organization: organizationId } } ) } diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index e168bae26cf..ca5943ae3b7 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -1071,12 +1071,6 @@ export async function handleUnifiedChatPost(req: NextRequest) { }, }) - // Expose the root gen_ai.agent.execute span's trace identity to - // the browser so subsequent HTTP calls (stop, abort, confirm, - // SSE reconnect) can echo it back as `traceparent` — making - // all side-channel work on this request appear as child spans - // of this same trace in Tempo instead of disconnected roots. - // W3C traceparent format: `00---`. captureServerEvent( authenticatedUserId, 'copilot_chat_sent', @@ -1090,6 +1084,12 @@ export async function handleUnifiedChatPost(req: NextRequest) { workspaceId ? { groups: { workspace: workspaceId } } : undefined ) + // Expose the root gen_ai.agent.execute span's trace identity to + // the browser so subsequent HTTP calls (stop, abort, confirm, + // SSE reconnect) can echo it back as `traceparent` — making + // all side-channel work on this request appear as child spans + // of this same trace in Tempo instead of disconnected roots. + // W3C traceparent format: `00---`. const rootCtx = activeOtelRoot.span.spanContext() const rootTraceparent = `00-${rootCtx.traceId}-${rootCtx.spanId}-${ (rootCtx.traceFlags & 0x1) === 0x1 ? '01' : '00' diff --git a/apps/sim/lib/posthog/events.ts b/apps/sim/lib/posthog/events.ts index e35bb8857fc..a3765d44eb0 100644 --- a/apps/sim/lib/posthog/events.ts +++ b/apps/sim/lib/posthog/events.ts @@ -660,7 +660,7 @@ export interface PostHogEventMap { organization_created: { organization_id: string - name: string + name?: string } /** Org membership lifecycle (distinct from workspace-level membership). */ @@ -690,7 +690,7 @@ export interface PostHogEventMap { /** A workflow's edit-lock was toggled on or off. */ workflow_lock_toggled: { workflow_id: string - workspace_id: string + workspace_id?: string locked: boolean } diff --git a/apps/sim/lib/table/import-data.ts b/apps/sim/lib/table/import-data.ts index 43e697d634f..da523fcf7a6 100644 --- a/apps/sim/lib/table/import-data.ts +++ b/apps/sim/lib/table/import-data.ts @@ -14,7 +14,7 @@ import { CSV_MAX_BATCH_SIZE } from '@/lib/table/import' import { nKeysBetween } from '@/lib/table/order-key' import { acquireRowOrderLock } from '@/lib/table/rows/ordering' import { batchInsertRowsWithTx, replaceTableRowsWithTx } from '@/lib/table/rows/service' -import { addTableColumnsWithTx } from '@/lib/table/service' +import { addTableColumnsWithTx, auditTableColumnsAdded } from '@/lib/table/service' import type { ReplaceRowsResult, RowData, @@ -126,7 +126,14 @@ export async function addImportColumns( additions: { name: string; type: string }[], requestId: string ): Promise { - return db.transaction((trx) => addTableColumnsWithTx(trx, table, additions, requestId)) + const updated = await db.transaction((trx) => + addTableColumnsWithTx(trx, table, additions, requestId) + ) + auditTableColumnsAdded( + table, + additions.map((c) => c.name) + ) + return updated } /** Overwrites a table's schema during an import (used when inferring columns from the file). */ @@ -164,7 +171,7 @@ export async function importAppendRows( // the order and deadlocking concurrent inserts on this table. The lock is // re-entrant, so the per-batch acquire below is a no-op. await acquireRowOrderLock(trx, table.id) - working = await addTableColumnsWithTx(trx, table, additions, ctx.requestId, ctx.userId) + working = await addTableColumnsWithTx(trx, table, additions, ctx.requestId) } const inserted: TableRow[] = [] for (let i = 0; i < rows.length; i += CSV_MAX_BATCH_SIZE) { @@ -179,6 +186,15 @@ export async function importAppendRows( } return { inserted, table: working } }) + // Audit post-commit: a mid-import row-batch failure rolls the whole tx back, + // so the columns are only truly added once db.transaction resolves. + if (additions.length > 0) { + auditTableColumnsAdded( + table, + additions.map((c) => c.name), + ctx.userId + ) + } notifyTableRowUsage({ workspaceId: ctx.workspaceId, currentRowCount: table.rowCount, @@ -209,7 +225,7 @@ export async function importReplaceRows( let working = table if (additions.length > 0) { await acquireRowOrderLock(trx, table.id) - working = await addTableColumnsWithTx(trx, table, additions, requestId, data.userId) + working = await addTableColumnsWithTx(trx, table, additions, requestId) } return replaceTableRowsWithTx( trx, @@ -218,6 +234,15 @@ export async function importReplaceRows( requestId ) }) + // Audit post-commit — see importAppendRows: the columns are only truly added + // once the replace transaction resolves. + if (additions.length > 0) { + auditTableColumnsAdded( + table, + additions.map((c) => c.name), + data.userId + ) + } notifyTableRowUsage({ workspaceId: data.workspaceId, currentRowCount: 0, diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index d6fbe91c9cf..0c13607f8b2 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -283,6 +283,8 @@ export async function createTable( ? { id: data.jobId, type: data.jobType ?? 'import', startedAt: now } : null + // Starter rows count against the plan too. Checked before the tx (the lookup is a + // separate pool read) — a new table starts empty, so the footprint is just these. const initialRowCount = data.initialRowCount ?? 0 let rowLimit: number | undefined if (initialRowCount > 0) { @@ -415,8 +417,7 @@ export async function addTableColumnsWithTx( trx: DbTransaction, table: TableDefinition, columns: { id?: string; name: string; type: string; required?: boolean; unique?: boolean }[], - requestId: string, - actingUserId?: string + requestId: string ): Promise { if (columns.length === 0) return table @@ -479,20 +480,6 @@ export async function addTableColumnsWithTx( `[${requestId}] Added ${additions.length} column(s) to table ${table.id}: ${additions.map((c) => c.name).join(', ')}` ) - const columnActorId = actingUserId ?? table.createdBy - if (columnActorId) { - recordAudit({ - workspaceId: table.workspaceId ?? null, - actorId: columnActorId, - action: AuditAction.TABLE_UPDATED, - resourceType: AuditResourceType.TABLE, - resourceId: table.id, - resourceName: table.name, - description: `Added ${additions.length} column(s) to table "${table.name}"`, - metadata: { op: 'add_columns', columns: additions.map((c) => c.name) }, - }) - } - return { ...table, schema: updatedSchema, @@ -500,6 +487,31 @@ export async function addTableColumnsWithTx( } } +/** + * Records the "columns added" audit for a table. The column add shares a + * transaction with the import's row inserts, so the caller MUST emit this only + * AFTER that transaction commits — auditing inside the tx would log a false + * success if a later row batch rolls it back. Skipped when no actor resolves. + */ +export function auditTableColumnsAdded( + table: TableDefinition, + columnNames: string[], + actingUserId?: string +): void { + const actorId = actingUserId ?? table.createdBy + if (!actorId || columnNames.length === 0) return + recordAudit({ + workspaceId: table.workspaceId ?? null, + actorId, + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: table.id, + resourceName: table.name, + description: `Added ${columnNames.length} column(s) to table "${table.name}"`, + metadata: { op: 'add_columns', columns: columnNames }, + }) +} + /** * Renames a table. * diff --git a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts index 245dff21dd4..15917517fd4 100644 --- a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts @@ -357,7 +357,11 @@ export async function performUpdateWorkflow( captureServerEvent( params.userId, 'workflow_lock_toggled', - { workflow_id: params.workflowId, workspace_id: workspaceId ?? '', locked: params.locked }, + { + workflow_id: params.workflowId, + ...(workspaceId ? { workspace_id: workspaceId } : {}), + locked: params.locked, + }, workspaceId ? { groups: { workspace: workspaceId } } : undefined ) } From 1ca8d3d0d5d58b4388c7fd7d842aad437927c1b2 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 18:30:02 -0700 Subject: [PATCH 24/29] fix(table): attribute import column audit to the importing user Address Cursor review: addImportColumns (async createColumns import path) now threads the importing userId into auditTableColumnsAdded instead of falling back to table.createdBy, so column additions are attributed to the actual member who ran the import rather than the table creator. --- apps/sim/lib/table/import-data.ts | 6 ++++-- apps/sim/lib/table/import-runner.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/table/import-data.ts b/apps/sim/lib/table/import-data.ts index da523fcf7a6..dddd980e705 100644 --- a/apps/sim/lib/table/import-data.ts +++ b/apps/sim/lib/table/import-data.ts @@ -124,14 +124,16 @@ export async function deleteAllTableRows(tableId: string): Promise { export async function addImportColumns( table: TableDefinition, additions: { name: string; type: string }[], - requestId: string + requestId: string, + actingUserId?: string ): Promise { const updated = await db.transaction((trx) => addTableColumnsWithTx(trx, table, additions, requestId) ) auditTableColumnsAdded( table, - additions.map((c) => c.name) + additions.map((c) => c.name), + actingUserId ) return updated } diff --git a/apps/sim/lib/table/import-runner.ts b/apps/sim/lib/table/import-runner.ts index 13887dd1c10..91eb44df318 100644 --- a/apps/sim/lib/table/import-runner.ts +++ b/apps/sim/lib/table/import-runner.ts @@ -173,7 +173,7 @@ export async function runTableImport(payload: TableImportPayload): Promise additions.push({ name: columnName, type: inferColumnType(sample.map((r) => r[header])) }) updatedMapping[header] = columnName } - const updated = await addImportColumns(table, additions, requestId) + const updated = await addImportColumns(table, additions, requestId, userId) targetSchema = updated.schema effectiveMapping = updatedMapping } From fccadff1a21ee5b3bfcef1f757c0bd0cf2a5a788 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 18:59:06 -0700 Subject: [PATCH 25/29] feat(observability): drop copilot from storage accounting Per design decision: copilot files are working/conversational artifacts, so gating their materialization on storage quota would fail an agent operation mid-flow when a user is over limit, and metering them would inflate usage enough to block KB/workspace uploads indirectly. Remove copilot quota gates + copilot ingest metering entirely (revert copilot-file-manager, metadata, and the upload route's copilot branch to baseline) and drop the now-unused releaseDeletedFileStorage. KB document metering + quota enforcement (the deliberate, persistent storage path) is unchanged. --- apps/sim/app/api/files/upload/route.ts | 10 ---- apps/sim/lib/billing/storage/index.ts | 1 - apps/sim/lib/billing/storage/tracking.ts | 49 +------------------ .../contexts/copilot/copilot-file-manager.ts | 49 +------------------ apps/sim/lib/uploads/server/metadata.ts | 24 --------- 5 files changed, 3 insertions(+), 130 deletions(-) diff --git a/apps/sim/app/api/files/upload/route.ts b/apps/sim/app/api/files/upload/route.ts index d34ad675380..cb23b6ec851 100644 --- a/apps/sim/app/api/files/upload/route.ts +++ b/apps/sim/app/api/files/upload/route.ts @@ -10,7 +10,6 @@ import { } from '@/lib/api/contracts/storage-transfer' import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { checkStorageQuota } from '@/lib/billing/storage' import { assertKnownSizeWithinLimit, isPayloadSizeLimitError, @@ -364,15 +363,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { metadata.workspaceId = workspaceId } - // Copilot uploads are metered, so gate against the storage quota before - // writing — the increment happens centrally when the metadata row lands. - if (context === 'copilot') { - const quotaCheck = await checkStorageQuota(session.user.id, buffer.length) - if (!quotaCheck.allowed) { - throw new InvalidRequestError(quotaCheck.error || 'Storage limit exceeded') - } - } - const fileInfo = await storageService.uploadFile({ file: buffer, fileName: storageKey, diff --git a/apps/sim/lib/billing/storage/index.ts b/apps/sim/lib/billing/storage/index.ts index 5a7983ead83..9203c24b455 100644 --- a/apps/sim/lib/billing/storage/index.ts +++ b/apps/sim/lib/billing/storage/index.ts @@ -3,5 +3,4 @@ export { decrementStorageUsage, decrementStorageUsageInTx, incrementStorageUsage, - releaseDeletedFileStorage, } from './tracking' diff --git a/apps/sim/lib/billing/storage/tracking.ts b/apps/sim/lib/billing/storage/tracking.ts index fc76c39c922..6311a881846 100644 --- a/apps/sim/lib/billing/storage/tracking.ts +++ b/apps/sim/lib/billing/storage/tracking.ts @@ -5,9 +5,9 @@ */ import { db } from '@sim/db' -import { organization, userStats, workspaceFiles } from '@sim/db/schema' +import { organization, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, isNull, sql } from 'drizzle-orm' +import { eq, sql } from 'drizzle-orm' import { maybeNotifyLimit } from '@/lib/billing/core/limit-notifications' import type { HighestPrioritySubscription } from '@/lib/billing/core/plan' import { getUserStorageLimit, getUserStorageUsage } from '@/lib/billing/storage/limits' @@ -195,48 +195,3 @@ export async function decrementStorageUsageInTx( .where(eq(userStats.userId, userId)) } } - -/** - * Atomically soft-delete a file's metadata row and decrement the owner's storage - * counter in a single transaction. - * - * The soft-delete (`deletedAt` transition) is the idempotency claim: only the - * call that actually flips the row decrements, so a retry that finds the row - * already deleted does not double-count. Because the claim and the decrement - * share one transaction, a failure of either rolls both back — the counter is - * never left permanently inflated and never double-decremented. Best-effort: - * when billing is disabled it just soft-deletes the row. - */ -export async function releaseDeletedFileStorage( - key: string, - userId: string, - bytes: number, - workspaceId?: string -): Promise { - if (!isBillingEnabled || bytes <= 0) { - await db - .update(workspaceFiles) - .set({ deletedAt: new Date() }) - .where(and(eq(workspaceFiles.key, key), isNull(workspaceFiles.deletedAt))) - return - } - - const { getHighestPrioritySubscription } = await import('@/lib/billing/core/subscription') - const sub = await getHighestPrioritySubscription(userId) - - let claimed = false - await db.transaction(async (tx) => { - const claimedRows = await tx - .update(workspaceFiles) - .set({ deletedAt: new Date() }) - .where(and(eq(workspaceFiles.key, key), isNull(workspaceFiles.deletedAt))) - .returning({ id: workspaceFiles.id }) - if (claimedRows.length === 0) return - claimed = true - await decrementStorageUsageInTx(tx, sub, userId, bytes) - }) - - if (claimed && workspaceId) { - void maybeNotifyStorageLimit(userId, workspaceId, sub, true) - } -} diff --git a/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts b/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts index eb937dd6eae..2e9b3fdf93d 100644 --- a/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { checkStorageQuota, releaseDeletedFileStorage } from '@/lib/billing/storage' import { getBaseUrl } from '@/lib/core/utils/urls' import { deleteFile, @@ -8,7 +7,6 @@ import { generatePresignedUploadUrl, uploadFile, } from '@/lib/uploads/core/storage-service' -import { getFileMetadataByKey } from '@/lib/uploads/server/metadata' import type { PresignedUrlResponse } from '@/lib/uploads/shared/types' import { isImageFileType } from '@/lib/uploads/utils/file-utils' @@ -95,11 +93,6 @@ export async function generateCopilotUploadUrl( ) } - const quotaCheck = await checkStorageQuota(userId, fileSize) - if (!quotaCheck.allowed) { - throw new Error(quotaCheck.error || 'Storage limit exceeded') - } - const presignedUrlResponse = await generatePresignedUploadUrl({ fileName, contentType, @@ -123,11 +116,6 @@ export async function uploadCopilotFile(options: { contentType: string userId: string }): Promise { - const quotaCheck = await checkStorageQuota(options.userId, options.buffer.length) - if (!quotaCheck.allowed) { - throw new Error(quotaCheck.error || 'Storage limit exceeded') - } - const fileInfo = await uploadFile({ file: options.buffer, fileName: options.fileName, @@ -149,8 +137,6 @@ export async function uploadCopilotFile(options: { userId: options.userId, }) - // Storage is incremented centrally when the metadata row is created (see - // insertFileMetadata), so there is no per-path increment here. return { id: fileInfo.key, key: fileInfo.key, @@ -253,43 +239,10 @@ export async function generateCopilotDownloadUrl( * @param key File storage key */ export async function deleteCopilotFile(key: string): Promise { - let metadataReadFailed = false - const metadata = await getFileMetadataByKey(key, 'copilot').catch((error) => { - logger.error('Failed to read copilot file metadata for storage accounting:', error) - metadataReadFailed = true - return null - }) - - // Settle accounting (atomic soft-delete + decrement) BEFORE removing the blob, - // and only delete the blob if it succeeded — so any failure (including a failed - // metadata read, which may hide a still-active row) leaves the file fully intact - // and retryable rather than orphaning the blob with an inflated counter. - let released = !metadataReadFailed - if (metadata) { - try { - await releaseDeletedFileStorage( - key, - metadata.userId, - metadata.size, - metadata.workspaceId ?? undefined - ) - released = true - } catch (storageError) { - released = false - logger.error('Failed to release copilot file storage:', storageError) - } - } - - // Accounting couldn't be settled (read or release failed). The file is left - // fully intact; throw so the caller knows the deletion did not happen and can - // retry, rather than silently reporting success. - if (!released) { - throw new Error(`Copilot file deletion aborted; storage accounting failed for key: ${key}`) - } - await deleteFile({ key, context: 'copilot', }) + logger.info(`Successfully deleted copilot file: ${key}`) } diff --git a/apps/sim/lib/uploads/server/metadata.ts b/apps/sim/lib/uploads/server/metadata.ts index a60e5080972..4a3faa58366 100644 --- a/apps/sim/lib/uploads/server/metadata.ts +++ b/apps/sim/lib/uploads/server/metadata.ts @@ -7,28 +7,6 @@ import type { StorageContext } from '../shared/types' const logger = createLogger('FileMetadata') -/** - * Meter a copilot file's bytes against the owner's storage quota when its - * metadata row is first created or restored. Copilot is the one context metered - * here so that every ingest path (tool output, the upload route, presigned - * uploads) is symmetric with the decrement on delete. Other contexts are metered - * by their own managers (workspace, knowledge-base) or intentionally unmetered - * (execution), so they are skipped. Best-effort: never blocks the metadata write. - */ -async function meterCopilotIngest( - context: StorageContext, - userId: string, - size: number -): Promise { - if (context !== 'copilot' || size <= 0) return - try { - const { incrementStorageUsage } = await import('@/lib/billing/storage') - await incrementStorageUsage(userId, size) - } catch (error) { - logger.error('Failed to meter copilot file storage on ingest', { error }) - } -} - export type FileMetadataRecord = typeof workspaceFiles.$inferSelect export interface FileMetadataInsertOptions { @@ -79,7 +57,6 @@ export async function insertFileMetadata( .returning() if (restored) { - await meterCopilotIngest(context, userId, size) return restored } } @@ -115,7 +92,6 @@ export async function insertFileMetadata( }) .returning() - await meterCopilotIngest(context, userId, size) return inserted } catch (error) { const code = (error as { code?: string } | null)?.code From 338fc5e3b1e170ae5cab96522154de27958e3e84 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 19:09:05 -0700 Subject: [PATCH 26/29] fix(analytics): switch workspace + org PostHog groups together Address Cursor review: gate the group-sync effect on workspace metadata being loaded (activeWorkspace present) so the workspace and organization groups always update atomically. Acting during the load window paired the new workspace group with the previous workspace's org group; until metadata loads, events stay consistently attributed to the previous workspace. --- .../providers/workspace-scope-sync.tsx | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/providers/workspace-scope-sync.tsx b/apps/sim/app/workspace/[workspaceId]/providers/workspace-scope-sync.tsx index 3c458f026e4..04dbe87dae9 100644 --- a/apps/sim/app/workspace/[workspaceId]/providers/workspace-scope-sync.tsx +++ b/apps/sim/app/workspace/[workspaceId]/providers/workspace-scope-sync.tsx @@ -21,16 +21,19 @@ export function WorkspaceScopeSync() { const organizationId = activeWorkspace?.organizationId ?? null useEffect(() => { - if (!workspaceId) return + // Wait until this workspace's metadata is loaded (activeWorkspace present) so + // the workspace and organization groups always switch together. Acting during + // the load window — when workspaceId is the new route value but organizationId + // is still transiently null — would either strip a team workspace's org group + // or pair the new workspace group with the previous workspace's org group. + // Until then, events stay consistently attributed to the previous workspace. + if (!workspaceId || !activeWorkspace) return if (organizationId) { posthog?.group('organization', organizationId) - } else if (activeWorkspace) { - // Metadata is loaded and this workspace genuinely has no org — drop any - // organization group carried over from a previously-active team workspace. - // While metadata is still loading, activeWorkspace is undefined and - // organizationId is transiently null, so resetting here would briefly strip - // a team workspace's org group. resetGroups clears all groups, so the - // workspace group is re-applied immediately below. + } else { + // This workspace genuinely has no org — drop any organization group carried + // over from a previously-active team workspace. resetGroups clears all + // groups, so the workspace group is re-applied immediately below. posthog?.resetGroups() } posthog?.group('workspace', workspaceId, workspaceName ? { name: workspaceName } : undefined) From f5f8a00ea062f9243b829bd062ea7dc99c9d8475 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 19:26:37 -0700 Subject: [PATCH 27/29] fix(table): audit async export at authorization, not job completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Cursor review: async exports only emitted TABLE_EXPORTED when the background job reached 'ready', so an authorized export whose job later failed or was abandoned left no audit trail — inconsistent with the sync export route, which audits before streaming. Move the audit + analytics to the async route's authorization point (after the job is claimed/dispatched) and remove it from the runner. Drop the now-unused userId from TableExportPayload. --- .../[tableId]/export-async/route.test.ts | 1 - .../api/table/[tableId]/export-async/route.ts | 26 ++++++++++++++++++- apps/sim/lib/table/export-runner.ts | 26 +------------------ 3 files changed, 26 insertions(+), 27 deletions(-) diff --git a/apps/sim/app/api/table/[tableId]/export-async/route.test.ts b/apps/sim/app/api/table/[tableId]/export-async/route.test.ts index 4a7e6e70c24..5be95a85ee6 100644 --- a/apps/sim/app/api/table/[tableId]/export-async/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/export-async/route.test.ts @@ -90,7 +90,6 @@ describe('POST /api/table/[tableId]/export-async', () => { tableId: 'tbl_1', workspaceId: 'workspace-1', format: 'csv', - userId: 'user-1', }) }) diff --git a/apps/sim/app/api/table/[tableId]/export-async/route.ts b/apps/sim/app/api/table/[tableId]/export-async/route.ts index 31ccd11f581..54c9e28ed4f 100644 --- a/apps/sim/app/api/table/[tableId]/export-async/route.ts +++ b/apps/sim/app/api/table/[tableId]/export-async/route.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' @@ -8,6 +9,7 @@ import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' import { runDetached } from '@/lib/core/utils/background' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' import { runTableExport, type TableExportPayload } from '@/lib/table/export-runner' import { markTableJobRunning, releaseJobClaim } from '@/lib/table/jobs/service' import type { TableExportJobPayload } from '@/lib/table/types' @@ -63,7 +65,6 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro tableId, workspaceId, format, - userId: authResult.userId, } if (isTriggerDevEnabled) { try { @@ -86,6 +87,29 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro runDetached('table-export', () => runTableExport(payload)) } + // Audit at authorization (job start), mirroring the sync export route, so an + // authorized export is recorded even if the background job later fails or is + // abandoned. The actual file egress is captured separately at download time. + recordAudit({ + workspaceId, + actorId: authResult.userId, + action: AuditAction.TABLE_EXPORTED, + resourceType: AuditResourceType.TABLE, + resourceId: tableId, + resourceName: access.table.name, + description: `Exported table "${access.table.name}" as ${format.toUpperCase()}`, + metadata: { format, rowCount: access.table.rowCount, async: true }, + request, + }) + if (access.table.workspaceId) { + captureServerEvent( + authResult.userId, + 'table_exported', + { table_id: tableId, workspace_id: workspaceId }, + { groups: { workspace: workspaceId } } + ) + } + logger.info(`[${requestId}] Async export started`, { tableId, jobId, format }) return NextResponse.json({ success: true, data: { tableId, jobId } }) }) diff --git a/apps/sim/lib/table/export-runner.ts b/apps/sim/lib/table/export-runner.ts index 8d2bfa9f52a..6c72a01ff20 100644 --- a/apps/sim/lib/table/export-runner.ts +++ b/apps/sim/lib/table/export-runner.ts @@ -1,8 +1,6 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { captureServerEvent } from '@/lib/posthog/server' import { buildNameById, getColumnId, rowDataIdToName } from '@/lib/table/column-keys' import { appendTableEvent } from '@/lib/table/events' import { @@ -39,8 +37,6 @@ export interface TableExportPayload { tableId: string workspaceId: string format: 'csv' | 'json' - /** The user who requested the export; attributes the audit/analytics record. */ - userId?: string } /** @@ -53,7 +49,7 @@ export interface TableExportPayload { * scratch and overwrites nothing (fresh key per attempt; failures clean up their partial upload). */ export async function runTableExport(payload: TableExportPayload): Promise { - const { jobId, tableId, workspaceId, format, userId } = payload + const { jobId, tableId, workspaceId, format } = payload const requestId = generateId().slice(0, 8) let handle: MultipartUploadHandle | null = null let uploadedKey: string | null = null @@ -132,26 +128,6 @@ export async function runTableExport(payload: TableExportPayload): Promise progress: exported, }) logger.info(`[${requestId}] Export complete`, { tableId, rows: exported, format }) - - const actorId = userId ?? table.createdBy - if (actorId) { - recordAudit({ - workspaceId, - actorId, - action: AuditAction.TABLE_EXPORTED, - resourceType: AuditResourceType.TABLE, - resourceId: tableId, - resourceName: table.name, - description: `Exported table "${table.name}" as ${format.toUpperCase()}`, - metadata: { format, rowCount: exported, async: true }, - }) - captureServerEvent( - actorId, - 'table_exported', - { table_id: tableId, workspace_id: workspaceId }, - { groups: { workspace: workspaceId } } - ) - } } else { // Canceled at the very end — the file is orphaned; remove it (janitor would otherwise // only catch it via the pruned job's resultKey). From fd73d5e06fb07054837c334fd131146a3d41004f Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 19:41:52 -0700 Subject: [PATCH 28/29] fix(billing): never let payment_failed instrumentation skip user blocking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Greptile P1: the payment_failed audit hoisted an unguarded isSubscriptionOrgScoped DB read (for entity_type) directly before the attempt-count user-blocking block. A transient failure of that read would throw out of the handler and skip blocking. Wrap the whole audit/analytics block in try/catch (best-effort), and let the blocking compute its own isSubscriptionOrgScoped as it did originally — instrumentation can no longer abort payment processing. --- apps/sim/lib/billing/webhooks/invoices.ts | 62 +++++++++++++---------- 1 file changed, 34 insertions(+), 28 deletions(-) diff --git a/apps/sim/lib/billing/webhooks/invoices.ts b/apps/sim/lib/billing/webhooks/invoices.ts index c144af3559b..f72b39b9763 100644 --- a/apps/sim/lib/billing/webhooks/invoices.ts +++ b/apps/sim/lib/billing/webhooks/invoices.ts @@ -992,35 +992,41 @@ export async function handleInvoicePaymentFailed(event: Stripe.Event) { resolutionSource, }) - const failureOrgScoped = await isSubscriptionOrgScoped(sub) - const failureEntityType = failureOrgScoped ? 'organization' : 'user' - const failureActorId = await resolveBillingActorId(failureOrgScoped, sub.referenceId) - - recordAudit({ - actorId: failureActorId, - action: AuditAction.INVOICE_PAYMENT_FAILED, - resourceType: AuditResourceType.BILLING, - resourceId: invoice.id, - description: `Invoice payment of $${failedAmount.toFixed(2)} failed for ${failureEntityType} ${sub.referenceId} (attempt ${attemptCount})`, - metadata: { - entityType: failureEntityType, - referenceId: sub.referenceId, - plan: sub.plan, + // Best-effort instrumentation; its DB reads must never abort the + // user-blocking that follows, so the whole block is guarded. + try { + const failureOrgScoped = await isSubscriptionOrgScoped(sub) + const failureEntityType = failureOrgScoped ? 'organization' : 'user' + const failureActorId = await resolveBillingActorId(failureOrgScoped, sub.referenceId) + + recordAudit({ + actorId: failureActorId, + action: AuditAction.INVOICE_PAYMENT_FAILED, + resourceType: AuditResourceType.BILLING, + resourceId: invoice.id, + description: `Invoice payment of $${failedAmount.toFixed(2)} failed for ${failureEntityType} ${sub.referenceId} (attempt ${attemptCount})`, + metadata: { + entityType: failureEntityType, + referenceId: sub.referenceId, + plan: sub.plan, + amount: failedAmount, + currency: invoice.currency ?? 'usd', + attemptCount, + invoiceType: invoiceType ?? 'subscription', + invoiceId: invoice.id, + }, + }) + captureServerEvent(failureActorId, 'payment_failed', { + plan: sub.plan ?? 'unknown', amount: failedAmount, currency: invoice.currency ?? 'usd', - attemptCount, - invoiceType: invoiceType ?? 'subscription', - invoiceId: invoice.id, - }, - }) - captureServerEvent(failureActorId, 'payment_failed', { - plan: sub.plan ?? 'unknown', - amount: failedAmount, - currency: invoice.currency ?? 'usd', - entity_type: failureEntityType, - reference_id: sub.referenceId, - attempt_count: attemptCount, - }) + entity_type: failureEntityType, + reference_id: sub.referenceId, + attempt_count: attemptCount, + }) + } catch (auditError) { + logger.warn('Failed to record payment_failed instrumentation', { auditError }) + } if (attemptCount >= 1) { logger.error('Payment failure - blocking users', { @@ -1032,7 +1038,7 @@ export async function handleInvoicePaymentFailed(event: Stripe.Event) { stripeSubscriptionId, }) - if (failureOrgScoped) { + if (await isSubscriptionOrgScoped(sub)) { const memberCount = await blockOrgMembers(sub.referenceId, 'payment_failed') logger.info('Blocked org members due to payment failure', { invoiceType: invoiceType ?? 'subscription', From 2afd11437137be8f0a53a30a0c87d077f22e70eb Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 19:41:52 -0700 Subject: [PATCH 29/29] chore(observability): trim verbose inline comments to concise notes --- .../app/api/table/[tableId]/export-async/route.ts | 4 +--- apps/sim/app/api/table/[tableId]/export/route.ts | 4 +--- .../providers/workspace-scope-sync.tsx | 13 ++++--------- apps/sim/lib/knowledge/documents/service.ts | 5 ++--- apps/sim/lib/table/import-data.ts | 6 ++---- 5 files changed, 10 insertions(+), 22 deletions(-) diff --git a/apps/sim/app/api/table/[tableId]/export-async/route.ts b/apps/sim/app/api/table/[tableId]/export-async/route.ts index 54c9e28ed4f..7ad5cdb251d 100644 --- a/apps/sim/app/api/table/[tableId]/export-async/route.ts +++ b/apps/sim/app/api/table/[tableId]/export-async/route.ts @@ -87,9 +87,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro runDetached('table-export', () => runTableExport(payload)) } - // Audit at authorization (job start), mirroring the sync export route, so an - // authorized export is recorded even if the background job later fails or is - // abandoned. The actual file egress is captured separately at download time. + // Audit at authorization (like the sync route) so a failed/abandoned job still records the export. recordAudit({ workspaceId, actorId: authResult.userId, diff --git a/apps/sim/app/api/table/[tableId]/export/route.ts b/apps/sim/app/api/table/[tableId]/export/route.ts index 55c81a880b2..df217ec7d31 100644 --- a/apps/sim/app/api/table/[tableId]/export/route.ts +++ b/apps/sim/app/api/table/[tableId]/export/route.ts @@ -56,9 +56,7 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou const safeName = sanitizeFilename(table.name) const filename = `${safeName}.${format}` - // Record the export before streaming begins. Rows leave the server incrementally, - // so a mid-stream failure would still exfiltrate partial data — auditing here - // (after access is granted) guarantees every authorized export is recorded. + // Audit before streaming: rows leave incrementally, so a mid-stream failure still exfiltrates partial data. recordAudit({ workspaceId: table.workspaceId ?? null, actorId: userId, diff --git a/apps/sim/app/workspace/[workspaceId]/providers/workspace-scope-sync.tsx b/apps/sim/app/workspace/[workspaceId]/providers/workspace-scope-sync.tsx index 04dbe87dae9..7173e394dd2 100644 --- a/apps/sim/app/workspace/[workspaceId]/providers/workspace-scope-sync.tsx +++ b/apps/sim/app/workspace/[workspaceId]/providers/workspace-scope-sync.tsx @@ -21,19 +21,14 @@ export function WorkspaceScopeSync() { const organizationId = activeWorkspace?.organizationId ?? null useEffect(() => { - // Wait until this workspace's metadata is loaded (activeWorkspace present) so - // the workspace and organization groups always switch together. Acting during - // the load window — when workspaceId is the new route value but organizationId - // is still transiently null — would either strip a team workspace's org group - // or pair the new workspace group with the previous workspace's org group. - // Until then, events stay consistently attributed to the previous workspace. + // Wait for metadata so the workspace and org groups switch together; acting + // mid-load (organizationId transiently null) would mismatch or strip them. if (!workspaceId || !activeWorkspace) return if (organizationId) { posthog?.group('organization', organizationId) } else { - // This workspace genuinely has no org — drop any organization group carried - // over from a previously-active team workspace. resetGroups clears all - // groups, so the workspace group is re-applied immediately below. + // No org — clear any stale org group; resetGroups clears all, so the + // workspace group is re-applied immediately below. posthog?.resetGroups() } posthog?.group('workspace', workspaceId, workspaceName ? { name: workspaceName } : undefined) diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index d9194f76f53..4755d6f0a80 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -2064,9 +2064,8 @@ export async function hardDeleteDocuments( subByUser.set(billedUserId, await getHighestPrioritySubscription(billedUserId)) } - // Key the decrement, storage cleanup, log, and return value off the rows this - // transaction actually deleted (`returning()`), so a concurrent delete that - // claimed some ids first isn't double-counted here. + // Key everything off the rows this tx actually deleted (`returning()`) so a + // concurrent delete that claimed some ids first isn't double-counted here. let deletedDocs: typeof documentsToDelete = [] await db.transaction(async (tx) => { await tx.delete(embedding).where(inArray(embedding.documentId, existingIds)) diff --git a/apps/sim/lib/table/import-data.ts b/apps/sim/lib/table/import-data.ts index dddd980e705..01740624e86 100644 --- a/apps/sim/lib/table/import-data.ts +++ b/apps/sim/lib/table/import-data.ts @@ -188,8 +188,7 @@ export async function importAppendRows( } return { inserted, table: working } }) - // Audit post-commit: a mid-import row-batch failure rolls the whole tx back, - // so the columns are only truly added once db.transaction resolves. + // Audit post-commit — a mid-import rollback means the columns weren't added. if (additions.length > 0) { auditTableColumnsAdded( table, @@ -236,8 +235,7 @@ export async function importReplaceRows( requestId ) }) - // Audit post-commit — see importAppendRows: the columns are only truly added - // once the replace transaction resolves. + // Audit post-commit (see importAppendRows). if (additions.length > 0) { auditTableColumnsAdded( table,