+ {connected
+ ? 'The credential is ready to use. You can close this tab and return to the app that started the connection.'
+ : 'The credential could not be connected. Return to the app that started the connection and try again.'}
+
+
+ Open Sim
+
+
+
+ )
+}
diff --git a/apps/sim/lib/api/contracts/oauth-connections.ts b/apps/sim/lib/api/contracts/oauth-connections.ts
index c9c4951efd2..74f7afea2c9 100644
--- a/apps/sim/lib/api/contracts/oauth-connections.ts
+++ b/apps/sim/lib/api/contracts/oauth-connections.ts
@@ -47,12 +47,18 @@ export const trelloTokenBodySchema = z.object({
state: z.string().min(1, 'state is required'),
})
+const oauthCredentialDraftIdSchema = z
+ .string()
+ .min(1, 'draftId is required')
+ .max(255, 'draftId must be at most 255 characters')
+
export const trelloAuthorizeQuerySchema = z.object({
returnUrl: z
.string()
.min(1, 'Return URL cannot be empty')
.max(2048, 'Return URL is too long')
.optional(),
+ draftId: oauthCredentialDraftIdSchema.optional(),
})
const trelloCallbackQuerySchema = z
@@ -129,6 +135,7 @@ export const oauthTokenPostContract = defineRouteContract({
export const shopifyAuthorizeQuerySchema = z.object({
shop: z.string().optional(),
returnUrl: z.string().optional(),
+ draftId: oauthCredentialDraftIdSchema.optional(),
})
export const shopifyCallbackQuerySchema = z.object({
@@ -218,6 +225,7 @@ export const instagramAuthorizeQuerySchema = z.object({
.max(MAX_OAUTH_RETURN_URL_LENGTH, 'Return URL is too long')
.optional(),
workspaceId: workspaceIdSchema.optional(),
+ draftId: oauthCredentialDraftIdSchema.optional(),
})
export const authorizeInstagramContract = defineRouteContract({
@@ -262,12 +270,42 @@ export const instagramCallbackContract = defineRouteContract({
response: { mode: 'redirect' },
})
-export const authorizeOAuth2QuerySchema = z.object({
- providerId: z.string().min(1, 'providerId is required'),
- workspaceId: workspaceIdSchema,
- callbackURL: z.string().min(1).optional(),
- credentialId: z.string().min(1).optional(),
-})
+export const authorizeOAuth2QuerySchema = z
+ .object({
+ draftId: oauthCredentialDraftIdSchema.optional(),
+ providerId: z.string().min(1, 'providerId is required').optional(),
+ workspaceId: workspaceIdSchema.optional(),
+ callbackURL: z.string().min(1).optional(),
+ credentialId: z.string().min(1).optional(),
+ })
+ .superRefine((data, ctx) => {
+ if (data.draftId) {
+ for (const field of ['providerId', 'workspaceId', 'callbackURL', 'credentialId'] as const) {
+ if (data[field] !== undefined) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: [field],
+ message: `${field} cannot be combined with draftId`,
+ })
+ }
+ }
+ return
+ }
+ if (!data.providerId) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['providerId'],
+ message: 'providerId is required',
+ })
+ }
+ if (!data.workspaceId) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['workspaceId'],
+ message: 'workspaceId is required',
+ })
+ }
+ })
export const authorizeOAuth2Contract = defineRouteContract({
method: 'GET',
diff --git a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts
index 5004b64c806..598724c28cd 100644
--- a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts
+++ b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts
@@ -83,6 +83,7 @@ const PAGED_LISTS = [
* - A table's saved views and its dispatchable groups are capped per table.
*/
const FULL_SET_LISTS = [
+ 'GET /api/v2/credentials/providers',
'GET /api/v2/files/folders',
'GET /api/v2/knowledge/[id]/tags',
'GET /api/v2/knowledge/folders',
diff --git a/apps/sim/lib/api/contracts/v2/credentials.ts b/apps/sim/lib/api/contracts/v2/credentials.ts
index 6694a626a3c..04c21e002d3 100644
--- a/apps/sim/lib/api/contracts/v2/credentials.ts
+++ b/apps/sim/lib/api/contracts/v2/credentials.ts
@@ -1,14 +1,20 @@
import { z } from 'zod'
import { workspaceCredentialRoleSchema } from '@/lib/api/contracts/credentials'
-import { workspaceIdSchema } from '@/lib/api/contracts/primitives'
+import { noInputSchema, nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives'
import { defineRouteContract } from '@/lib/api/contracts/types'
import {
v2CursorListResponse,
+ v2DataResponse,
v2PaginationFields,
v2SearchSchema,
v2SortFields,
v2TimestampSchema,
} from '@/lib/api/contracts/v2/shared'
+import {
+ getServiceAccountRequiredFields,
+ SERVICE_ACCOUNT_REQUIRED_FIELDS,
+} from '@/lib/credentials/service-account-fields'
+import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types'
/** Public credentials are authenticated connections, never raw environment secrets. */
export const v2CredentialTypeSchema = z
@@ -44,6 +50,114 @@ export const v2CredentialSchema = z
})
export type V2Credential = z.output
+export const v2CredentialProviderAuthorizationOptionSchema = z
+ .object({
+ providerId: z
+ .string()
+ .min(1, 'providerId cannot be empty')
+ .max(255, 'providerId must be at most 255 characters')
+ .describe('Exact OAuth provider identifier accepted by the connection endpoint.'),
+ label: z
+ .string()
+ .min(1, 'label cannot be empty')
+ .max(255, 'label must be at most 255 characters')
+ .describe('Human-readable authorization-server label.'),
+ })
+ .strict()
+export type V2CredentialProviderAuthorizationOption = z.output<
+ typeof v2CredentialProviderAuthorizationOptionSchema
+>
+
+export const v2CredentialProviderFieldOptionSchema = z
+ .object({
+ value: z.string().min(1).max(255).describe('Submitted option value.'),
+ label: z.string().min(1).max(255).describe('Human-readable option label.'),
+ })
+ .strict()
+
+export const v2CredentialProviderFieldSchema = z
+ .object({
+ id: z.string().min(1).max(255).describe('Exact create-body field name.'),
+ label: z.string().min(1).max(255).describe('Human-readable field label.'),
+ placeholder: z.string().min(1).max(1000).describe('Suggested input placeholder.'),
+ required: z.boolean().describe('Whether the field is required for the selected flow.'),
+ secret: z.boolean().describe('Whether the submitted field is write-only secret material.'),
+ multiline: z.boolean().describe('Whether the field is intended for multi-line input.'),
+ requiredForAuthMethods: z
+ .array(z.string().min(1).max(64))
+ .min(1)
+ .max(10)
+ .optional()
+ .describe('Authentication methods for which this field is required.'),
+ options: z
+ .array(v2CredentialProviderFieldOptionSchema)
+ .min(1)
+ .max(20)
+ .optional()
+ .describe('Fixed values accepted by a selector field.'),
+ hint: z.string().min(1).max(2000).optional().describe('Provider-specific setup guidance.'),
+ })
+ .strict()
+
+const v2CredentialProviderBaseShape = {
+ serviceId: z.string().min(1).max(255).describe('Stable credential-provider identifier.'),
+ name: z.string().min(1).max(255).describe('Credential provider display name.'),
+ description: z.string().min(1).max(1000).describe('Credential provider description.'),
+ providerFamily: z.string().min(1).max(255).describe('Owning provider family identifier.'),
+ available: z
+ .boolean()
+ .describe('Whether this caller can connect the provider in the current deployment.'),
+} as const
+
+export const v2OAuthCredentialProviderSchema = z
+ .object({
+ type: z.literal('oauth').describe('Browser-based OAuth connection method.'),
+ ...v2CredentialProviderBaseShape,
+ supportsReconnect: z
+ .boolean()
+ .describe('Whether existing credentials for this service can be reconnected.'),
+ authorizationOptions: z
+ .array(v2CredentialProviderAuthorizationOptionSchema)
+ .min(1)
+ .max(10)
+ .describe('Authorization servers available for this OAuth service.'),
+ })
+ .strict()
+
+export const v2ServiceAccountCredentialProviderSchema = z
+ .object({
+ type: z.literal('service_account').describe('Direct service-account credential method.'),
+ ...v2CredentialProviderBaseShape,
+ providerId: z
+ .string()
+ .min(1)
+ .max(255)
+ .describe('Exact service-account provider ID accepted by credential creation.'),
+ docsUrl: z.string().url().describe('Setup guide for the provider.'),
+ helpText: z.string().min(1).max(2000).optional().describe('Provider-specific setup guidance.'),
+ requiresClientGeneratedCredentialId: z
+ .boolean()
+ .describe('Whether the caller must generate and submit the credential ID before setup.'),
+ fields: z
+ .array(v2CredentialProviderFieldSchema)
+ .min(1)
+ .max(20)
+ .describe('Create-body fields accepted by this provider. Secret fields are write-only.'),
+ })
+ .strict()
+
+export const v2CredentialProviderSchema = z
+ .discriminatedUnion('type', [
+ v2OAuthCredentialProviderSchema,
+ v2ServiceAccountCredentialProviderSchema,
+ ])
+ .meta({
+ id: 'V2CredentialProvider',
+ title: 'Credential Provider',
+ description: 'An OAuth or service-account connection method available to a workspace.',
+ })
+export type V2CredentialProvider = z.output
+
/** A credential's natural name field is `displayName`, so that is what `search` matches. */
export const v2CredentialSortFields = ['displayName', 'createdAt', 'updatedAt'] as const
export type V2CredentialSortBy = (typeof v2CredentialSortFields)[number]
@@ -66,11 +180,6 @@ export const v2ListCredentialsQuerySchema = z
.strict()
export type V2ListCredentialsQuery = z.output
-/**
- * Lists OAuth and service-account connections, keyset-paginated over the active
- * sort. Credential mutations are intentionally absent. Nothing capped the
- * per-workspace set before pagination, so the response grew without bound.
- */
export const v2ListCredentialsContract = defineRouteContract({
method: 'GET',
path: '/api/v2/credentials',
@@ -80,3 +189,271 @@ export const v2ListCredentialsContract = defineRouteContract({
schema: v2CursorListResponse(v2CredentialSchema),
},
})
+
+export const v2ListCredentialProvidersQuerySchema = z
+ .object({
+ workspaceId: workspaceIdSchema.describe(
+ 'Workspace used to evaluate credential-provider availability and integration policy.'
+ ),
+ })
+ .strict()
+export type V2ListCredentialProvidersQuery = z.output
+
+export const v2ListCredentialProvidersContract = defineRouteContract({
+ method: 'GET',
+ path: '/api/v2/credentials/providers',
+ query: v2ListCredentialProvidersQuerySchema,
+ response: {
+ mode: 'json',
+ schema: v2CursorListResponse(v2CredentialProviderSchema, { paged: false }),
+ },
+})
+
+const v2CreateCredentialConnectionByProviderSchema = z
+ .object({
+ workspaceId: workspaceIdSchema.describe('Workspace that will own the credential.'),
+ providerId: z
+ .string({ error: 'providerId is required' })
+ .trim()
+ .min(1, 'providerId cannot be empty')
+ .max(255, 'providerId must be at most 255 characters')
+ .describe('Exact OAuth provider ID returned by credential-provider discovery.'),
+ displayName: z
+ .string({ error: 'displayName is required' })
+ .trim()
+ .min(1, 'displayName cannot be empty')
+ .max(255, 'displayName must be at most 255 characters')
+ .describe('Name shown for the new credential in Sim.'),
+ })
+ .strict()
+
+const v2CreateCredentialConnectionByCredentialSchema = z
+ .object({
+ workspaceId: workspaceIdSchema.describe('Workspace expected to own the credential.'),
+ credentialId: z
+ .string({ error: 'credentialId is required' })
+ .trim()
+ .min(1, 'credentialId cannot be empty')
+ .max(255, 'credentialId must be at most 255 characters')
+ .describe('Existing OAuth credential to reconnect in place.'),
+ })
+ .strict()
+
+export const v2CreateCredentialConnectionBodySchema = z.union([
+ v2CreateCredentialConnectionByProviderSchema,
+ v2CreateCredentialConnectionByCredentialSchema,
+])
+export type V2CreateCredentialConnectionBody = z.output<
+ typeof v2CreateCredentialConnectionBodySchema
+>
+
+export const v2CredentialConnectionAuthorizationSchema = z
+ .object({
+ authorizationUrl: z
+ .string()
+ .url('authorizationUrl must be an absolute URL')
+ .describe('Short-lived Sim browser URL that starts the OAuth authorization flow.'),
+ expiresAt: v2TimestampSchema.describe('ISO 8601 timestamp when the connection link expires.'),
+ })
+ .meta({
+ id: 'V2CredentialConnectionAuthorization',
+ title: 'Credential Connection Authorization',
+ description: 'A short-lived browser entrypoint for an OAuth connection flow.',
+ })
+export type V2CredentialConnectionAuthorization = z.output<
+ typeof v2CredentialConnectionAuthorizationSchema
+>
+
+export const v2CreateCredentialConnectionResponseSchema = v2DataResponse(
+ v2CredentialConnectionAuthorizationSchema
+)
+export type V2CreateCredentialConnectionResponse = z.output<
+ typeof v2CreateCredentialConnectionResponseSchema
+>
+
+export const v2CreateCredentialConnectionContract = defineRouteContract({
+ method: 'POST',
+ path: '/api/v2/credentials/connections',
+ query: noInputSchema,
+ body: v2CreateCredentialConnectionBodySchema,
+ response: {
+ mode: 'json',
+ schema: v2CreateCredentialConnectionResponseSchema,
+ },
+})
+
+const v2ServiceAccountSecretFieldsShape = {
+ serviceAccountJson: z
+ .string()
+ .min(1)
+ .max(65_536)
+ .optional()
+ .describe('Write-only Google service-account JSON key.')
+ .meta({ writeOnly: true }),
+ apiToken: z
+ .string()
+ .trim()
+ .min(1)
+ .max(8192)
+ .optional()
+ .describe('Write-only provider API token.')
+ .meta({ writeOnly: true }),
+ domain: z.string().trim().min(1).max(2048).optional().describe('Provider account domain.'),
+ signingSecret: z
+ .string()
+ .trim()
+ .min(1)
+ .max(8192)
+ .optional()
+ .describe('Write-only webhook signing secret.')
+ .meta({ writeOnly: true }),
+ botToken: z
+ .string()
+ .trim()
+ .min(1)
+ .max(8192)
+ .optional()
+ .describe('Write-only bot token.')
+ .meta({ writeOnly: true }),
+ clientId: z.string().trim().min(1).max(512).optional().describe('OAuth client identifier.'),
+ clientSecret: z
+ .string()
+ .trim()
+ .min(1)
+ .max(1024)
+ .optional()
+ .describe('Write-only OAuth client secret.')
+ .meta({ writeOnly: true }),
+ certificateId: z
+ .string()
+ .trim()
+ .min(1)
+ .max(512)
+ .optional()
+ .describe('Provider certificate mapping identifier.'),
+ orgId: z.string().trim().min(1).max(255).optional().describe('Provider organization ID.'),
+ dataCenter: z.string().trim().min(1).max(32).optional().describe('Provider data center.'),
+ authMethod: z
+ .string()
+ .trim()
+ .min(1)
+ .max(64)
+ .optional()
+ .describe('Provider authentication method.'),
+ privateKey: z
+ .string()
+ .trim()
+ .min(1)
+ .max(8192)
+ .optional()
+ .describe('Write-only PEM private key.')
+ .meta({ writeOnly: true }),
+ username: z.string().trim().min(1).max(255).optional().describe('Provider run-as username.'),
+} as const
+
+export const v2CreateServiceAccountCredentialBodySchema = z
+ .object({
+ workspaceId: workspaceIdSchema.describe('Workspace that will own the credential.'),
+ type: z.literal('service_account').describe('Service-account credential discriminator.'),
+ providerId: z
+ .string({ error: 'providerId is required' })
+ .trim()
+ .min(1, 'providerId cannot be empty')
+ .max(255, 'providerId must be at most 255 characters')
+ .describe('Exact service-account provider ID returned by provider discovery.'),
+ displayName: z
+ .string()
+ .trim()
+ .min(1, 'displayName cannot be empty')
+ .max(255, 'displayName must be at most 255 characters')
+ .optional()
+ .describe('Optional name; providers may derive one from the verified account identity.'),
+ description: z
+ .string()
+ .trim()
+ .max(500, 'description must be at most 500 characters')
+ .optional()
+ .describe('Optional credential description.'),
+ id: z
+ .string()
+ .uuid('id must be a valid UUID')
+ .optional()
+ .describe('Required only when provider discovery requests a client-generated ID.'),
+ ...v2ServiceAccountSecretFieldsShape,
+ })
+ .strict()
+ .superRefine((body, ctx) => {
+ if (!Object.hasOwn(SERVICE_ACCOUNT_REQUIRED_FIELDS, body.providerId)) {
+ ctx.addIssue({
+ code: 'custom',
+ path: ['providerId'],
+ message: `Unknown service-account provider: ${body.providerId}`,
+ })
+ return
+ }
+ if (body.providerId === SLACK_CUSTOM_BOT_PROVIDER_ID && !body.id) {
+ ctx.addIssue({
+ code: 'custom',
+ path: ['id'],
+ message: `id is required for ${SLACK_CUSTOM_BOT_PROVIDER_ID} credentials`,
+ })
+ }
+ for (const field of getServiceAccountRequiredFields(body.providerId)) {
+ if (!body[field]) {
+ ctx.addIssue({
+ code: 'custom',
+ path: [field],
+ message: `${field} is required for ${body.providerId} credentials`,
+ })
+ }
+ }
+ })
+export type V2CreateServiceAccountCredentialBody = z.input<
+ typeof v2CreateServiceAccountCredentialBodySchema
+>
+
+export const v2CreateServiceAccountCredentialContract = defineRouteContract({
+ method: 'POST',
+ path: '/api/v2/credentials',
+ query: noInputSchema,
+ body: v2CreateServiceAccountCredentialBodySchema,
+ response: {
+ mode: 'json',
+ status: [200, 201],
+ schema: v2DataResponse(v2CredentialSchema),
+ },
+})
+
+export const v2CredentialParamsSchema = z
+ .object({
+ credentialId: nonEmptyIdSchema.max(255).describe('Credential to disconnect.'),
+ })
+ .strict()
+
+export const v2DeleteCredentialQuerySchema = z
+ .object({
+ workspaceId: workspaceIdSchema.describe('Workspace expected to own the credential.'),
+ })
+ .strict()
+
+export const v2CredentialDeleteDataSchema = z
+ .object({
+ id: nonEmptyIdSchema.describe('Disconnected credential identifier.'),
+ deleted: z.literal(true).describe('Whether the credential was disconnected.'),
+ })
+ .meta({
+ id: 'V2CredentialDeleteData',
+ title: 'Delete credential data',
+ description: 'Credential disconnection acknowledgement.',
+ })
+
+export const v2DeleteCredentialContract = defineRouteContract({
+ method: 'DELETE',
+ path: '/api/v2/credentials/[credentialId]',
+ params: v2CredentialParamsSchema,
+ query: v2DeleteCredentialQuerySchema,
+ response: {
+ mode: 'json',
+ schema: v2DataResponse(v2CredentialDeleteDataSchema),
+ },
+})
diff --git a/apps/sim/lib/api/contracts/v2/openapi/resources.ts b/apps/sim/lib/api/contracts/v2/openapi/resources.ts
index a87fa3f3294..7a420995f46 100644
--- a/apps/sim/lib/api/contracts/v2/openapi/resources.ts
+++ b/apps/sim/lib/api/contracts/v2/openapi/resources.ts
@@ -1,4 +1,10 @@
-import { v2ListCredentialsContract } from '@/lib/api/contracts/v2/credentials'
+import {
+ v2CreateCredentialConnectionContract,
+ v2CreateServiceAccountCredentialContract,
+ v2DeleteCredentialContract,
+ v2ListCredentialProvidersContract,
+ v2ListCredentialsContract,
+} from '@/lib/api/contracts/v2/credentials'
import {
v2CreateCustomToolContract,
v2DeleteCustomToolContract,
@@ -166,6 +172,63 @@ const CREDENTIAL_EXAMPLE = {
updatedAt: '2026-06-20T14:02:11.000Z',
} as const
+const CREDENTIAL_PROVIDER_EXAMPLE = {
+ type: 'oauth',
+ serviceId: 'salesforce',
+ name: 'Salesforce',
+ description: 'Connect to Salesforce CRM data and operations.',
+ providerFamily: 'salesforce',
+ available: true,
+ supportsReconnect: true,
+ authorizationOptions: [
+ { providerId: 'salesforce', label: 'Production' },
+ { providerId: 'salesforce-sandbox', label: 'Sandbox' },
+ ],
+} as const
+
+const SERVICE_ACCOUNT_PROVIDER_EXAMPLE = {
+ type: 'service_account',
+ serviceId: 'zoom-service-account',
+ providerId: 'zoom-service-account',
+ name: 'Zoom server-to-server app',
+ description: 'Connect Zoom with a server-to-server app.',
+ providerFamily: 'zoom',
+ available: true,
+ docsUrl: 'https://docs.sim.ai/integrations/zoom-service-account',
+ requiresClientGeneratedCredentialId: false,
+ fields: [
+ {
+ id: 'clientId',
+ label: 'Client ID',
+ placeholder: 'Paste the client ID',
+ required: true,
+ secret: false,
+ multiline: false,
+ },
+ {
+ id: 'clientSecret',
+ label: 'Client secret',
+ placeholder: 'Paste the client secret',
+ required: true,
+ secret: true,
+ multiline: false,
+ },
+ {
+ id: 'orgId',
+ label: 'Account ID',
+ placeholder: 'Paste the account ID',
+ required: true,
+ secret: false,
+ multiline: false,
+ },
+ ],
+} as const
+
+const CREDENTIAL_CONNECTION_EXAMPLE = {
+ authorizationUrl: 'https://www.sim.ai/api/auth/oauth2/authorize?draftId=draft-123',
+ expiresAt: '2026-06-20T14:17:11.000Z',
+} as const
+
const SECRET_EXAMPLE = {
name: 'STRIPE_API_KEY',
scope: 'workspace',
@@ -784,7 +847,7 @@ const declaredRoutes = [
operationId: 'listCredentials',
summary: 'List Credentials',
description:
- 'List OAuth and service-account connections visible to the caller. Secret material is never returned. Credential mutations and single-resource reads are not exposed.',
+ 'List OAuth and service-account connections visible to the caller. Secret material is never returned.',
errors: RESOURCE_ERRORS,
success: { description: 'Credentials visible to the caller.' },
}),
@@ -804,6 +867,135 @@ const declaredRoutes = [
),
}
),
+ defineOpenApiRoute(
+ v2ListCredentialProvidersContract,
+ resourceOperation('Credentials', {
+ operationId: 'listCredentialProviders',
+ summary: 'List Credential Providers',
+ description: `List catalogued OAuth and service-account connection methods and whether each is available to the caller in this workspace and deployment. OAuth authorization options contain the exact provider IDs accepted by the browser connection endpoint; service-account methods list the exact create-body fields and mark secret fields write-only. ${FULL_SET_LIST}`,
+ errors: RESOURCE_ERRORS,
+ success: { description: 'Credential provider catalog with caller-specific availability.' },
+ }),
+ {
+ query: documentedSchema(
+ v2ListCredentialProvidersContract.query,
+ 'ListCredentialProvidersQuery',
+ 'List credential providers query',
+ 'Workspace used to evaluate provider availability.'
+ ),
+ response: documentedSchema(
+ v2ListCredentialProvidersContract.response.schema,
+ 'ListCredentialProvidersResponse',
+ 'List credential providers response',
+ 'OAuth and service-account connection methods.',
+ [
+ {
+ data: [CREDENTIAL_PROVIDER_EXAMPLE, SERVICE_ACCOUNT_PROVIDER_EXAMPLE],
+ nextCursor: null,
+ },
+ ]
+ ),
+ }
+ ),
+ defineOpenApiRoute(
+ v2CreateServiceAccountCredentialContract,
+ resourceOperation('Credentials', {
+ operationId: 'createServiceAccountCredential',
+ summary: 'Create Service-Account Credential',
+ description: `Verify and store one service-account credential. Use provider discovery to select a service-account provider and submit its required fields. Secret fields are write-only and are never returned. A retried source match returns the existing credential with 200; a newly created credential returns 201. ${WORKSPACE_API_KEY_DENIED}`,
+ errors: RESOURCE_CONFLICT_ERRORS,
+ success: {
+ byStatus: {
+ 200: { description: 'An existing credential matched the verified source.' },
+ 201: { description: 'The service-account credential was created.' },
+ },
+ },
+ }),
+ {
+ query: v2CreateServiceAccountCredentialContract.query,
+ body: documentedSchema(
+ v2CreateServiceAccountCredentialContract.body,
+ 'CreateServiceAccountCredentialRequest',
+ 'Create service-account credential request',
+ 'Provider identifier, optional display metadata, and the write-only fields declared by provider discovery.',
+ [
+ {
+ workspaceId: WORKSPACE_ID,
+ type: 'service_account',
+ providerId: 'zoom-service-account',
+ displayName: 'Zoom automation',
+ clientId: 'YOUR_CLIENT_ID',
+ clientSecret: 'YOUR_CLIENT_SECRET',
+ orgId: 'YOUR_ACCOUNT_ID',
+ },
+ ]
+ ),
+ response: documentedSchema(
+ v2CreateServiceAccountCredentialContract.response.schema,
+ 'CreateServiceAccountCredentialResponse',
+ 'Create service-account credential response',
+ 'Verified credential metadata without secret material.',
+ [{ data: CREDENTIAL_EXAMPLE }]
+ ),
+ }
+ ),
+ defineOpenApiRoute(
+ v2CreateCredentialConnectionContract,
+ resourceOperation('Credentials', {
+ operationId: 'createCredentialConnection',
+ summary: 'Create Credential Connection',
+ description: `Create a short-lived browser URL for connecting an OAuth provider or reconnecting an existing OAuth credential. Open the URL in a browser, sign in as the personal API-key owner, complete provider authorization, then refresh the credentials list. ${WORKSPACE_API_KEY_DENIED}`,
+ errors: RESOURCE_CONFLICT_ERRORS,
+ success: { description: 'A short-lived browser authorization URL.' },
+ }),
+ {
+ query: v2CreateCredentialConnectionContract.query,
+ body: documentedSchema(
+ v2CreateCredentialConnectionContract.body,
+ 'CreateCredentialConnectionBody',
+ 'Create credential connection body',
+ 'For a new connection, provide providerId and displayName. For a reconnect, provide only credentialId; the existing display name is preserved.'
+ ),
+ response: documentedSchema(
+ v2CreateCredentialConnectionContract.response.schema,
+ 'CreateCredentialConnectionResponse',
+ 'Create credential connection response',
+ 'Short-lived Sim browser entrypoint and its expiry.',
+ [{ data: CREDENTIAL_CONNECTION_EXAMPLE }]
+ ),
+ }
+ ),
+ defineOpenApiRoute(
+ v2DeleteCredentialContract,
+ resourceOperation('Credentials', {
+ operationId: 'deleteCredential',
+ summary: 'Disconnect Credential',
+ description: `Disconnect an OAuth or service-account credential and clear its stored workflow, deployment, paused-run, knowledge-connector, and webhook references. Credential admin access is required. ${WORKSPACE_API_KEY_DENIED}`,
+ errors: RESOURCE_ERRORS,
+ success: { description: 'The credential was disconnected.' },
+ }),
+ {
+ params: documentedSchema(
+ v2DeleteCredentialContract.params,
+ 'DeleteCredentialParams',
+ 'Disconnect credential path parameters',
+ 'Credential selected for disconnection.'
+ ),
+ query: documentedSchema(
+ v2DeleteCredentialContract.query,
+ 'DeleteCredentialQuery',
+ 'Disconnect credential query',
+ 'Workspace expected to own the credential.'
+ ),
+ response: documentedSchema(
+ v2DeleteCredentialContract.response.schema,
+ 'DeleteCredentialResponse',
+ 'Disconnect credential response',
+ 'Acknowledgement that the credential was disconnected.',
+ [{ data: { id: CREDENTIAL_EXAMPLE.id, deleted: true } }]
+ ),
+ }
+ ),
defineOpenApiRoute(
v2ListSecretsContract,
resourceOperation('Secrets', {
@@ -953,7 +1145,8 @@ export const resourcesOpenApiDocument = defineOpenApiDocument({
},
{
name: 'Credentials',
- description: 'List OAuth and service-account connections without secret material.',
+ description:
+ 'Discover providers, create service-account credentials, connect or reconnect OAuth accounts, disconnect credentials, and list connections without secret material.',
},
{
name: 'Secrets',
diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts
index cdcbcd32903..dc4ba62ca70 100644
--- a/apps/sim/lib/auth/auth.ts
+++ b/apps/sim/lib/auth/auth.ts
@@ -7,7 +7,7 @@ import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { type BetterAuthOptions, betterAuth, type User } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
-import { APIError, createAuthMiddleware, getSessionFromCtx } from 'better-auth/api'
+import { APIError, createAuthMiddleware, getOAuthState, getSessionFromCtx } from 'better-auth/api'
import { nextCookies } from 'better-auth/next-js'
import {
admin,
@@ -97,6 +97,7 @@ import {
import { PlatformEvents } from '@/lib/core/telemetry'
import { getBaseUrl, isLocalhostUrl, parseOriginList } from '@/lib/core/utils/urls'
import { processCredentialDraft } from '@/lib/credentials/draft-processor'
+import { OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM } from '@/lib/credentials/oauth-draft-state'
import { sendEmail } from '@/lib/messaging/email/mailer'
import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
@@ -523,8 +524,19 @@ export const auth = betterAuth({
}
}
+ let credentialDraftId: string | undefined
try {
+ const oauthState = await getOAuthState()
+ const rawCallbackUrl = oauthState?.callbackURL
+ if (rawCallbackUrl !== undefined && typeof rawCallbackUrl !== 'string') {
+ throw new Error('OAuth state callback URL must be a string')
+ }
+ credentialDraftId = rawCallbackUrl
+ ? (new URL(rawCallbackUrl).searchParams.get(OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM) ??
+ undefined)
+ : undefined
await processCredentialDraft({
+ draftId: credentialDraftId,
userId: account.userId,
providerId: account.providerId,
accountId: account.id,
@@ -535,6 +547,7 @@ export const auth = betterAuth({
providerId: account.providerId,
error,
})
+ if (credentialDraftId) throw error
}
try {
diff --git a/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts b/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts
index aa2f4e5e494..067ad94ff10 100644
--- a/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts
+++ b/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts
@@ -181,6 +181,65 @@ describe('defineAuthorizedWorkspaceUseCase', () => {
expect(mocks.events).toEqual(['execute', 'audit', 'afterSuccess'])
})
+ it('runs resource authorization after workspace authorization and before business effects', async () => {
+ mocks.resolvePermission.mockImplementation(async () => {
+ mocks.events.push('workspaceAuthorization')
+ return 'write'
+ })
+ const execute = vi.fn(async () => {
+ mocks.events.push('execute')
+ return { ok: true as const }
+ })
+ const useCase = defineAuthorizedWorkspaceUseCase({
+ operation,
+ resolveContext: async (_args: { principal: SessionPrincipal; input: TestInput }) => {
+ mocks.events.push('canonicalLoad')
+ return canonicalContext
+ },
+ authorizationOptions: {},
+ authorizeResource() {
+ mocks.events.push('resourceAuthorization')
+ },
+ execute,
+ projectAudit: () => ({
+ action: AuditAction.FILE_UPDATED,
+ resourceType: AuditResourceType.FILE,
+ }),
+ afterSuccess() {
+ mocks.events.push('afterSuccess')
+ },
+ })
+
+ await useCase.authorize?.({
+ principal: sessionPrincipal,
+ input: { resourceId: 'resource-1' },
+ })
+
+ expect(mocks.events).toEqual([
+ 'canonicalLoad',
+ 'workspaceAuthorization',
+ 'resourceAuthorization',
+ ])
+ expect(execute).not.toHaveBeenCalled()
+ expect(mocks.recordAudit).not.toHaveBeenCalled()
+
+ mocks.events.length = 0
+ await expect(
+ useCase.execute({
+ principal: sessionPrincipal,
+ input: { resourceId: 'resource-1' },
+ })
+ ).resolves.toEqual({ ok: true })
+ expect(mocks.events).toEqual([
+ 'canonicalLoad',
+ 'workspaceAuthorization',
+ 'resourceAuthorization',
+ 'execute',
+ 'audit',
+ 'afterSuccess',
+ ])
+ })
+
it('supports zero or many semantic audit entries', async () => {
const buildUseCase = (auditCount: number) =>
defineAuthorizedWorkspaceUseCase({
diff --git a/apps/sim/lib/core/application/authorized-workspace-use-case.ts b/apps/sim/lib/core/application/authorized-workspace-use-case.ts
index a3286535edb..0d37830b458 100644
--- a/apps/sim/lib/core/application/authorized-workspace-use-case.ts
+++ b/apps/sim/lib/core/application/authorized-workspace-use-case.ts
@@ -56,6 +56,8 @@ export interface AuthorizedWorkspaceUseCaseDefinition<
| ((
args: AuthorizedWorkspaceUseCaseContext
) => WorkspaceAuthorizationOptions | Promise>)
+ /** Applies current domain-resource policy after workspace authorization. */
+ authorizeResource?(args: AuthorizedWorkspaceUseCaseContext): void | Promise
execute(args: AuthorizedWorkspaceUseCaseContext): Promise
projectAudit?(
args: AuthorizedWorkspaceUseCaseResultContext
@@ -111,7 +113,8 @@ export function defineAuthorizedWorkspaceUseCase<
>(definition: AuthorizedWorkspaceUseCaseDefinition): OperationUseCase {
/**
* Everything that runs before the business transaction: allowed-principal
- * check, canonical load, asserted-scope comparison, current access check.
+ * check, canonical load, asserted-scope comparison, current workspace and
+ * resource access checks.
*
* `execute` and `authorize` share it rather than each spelling it out, so a
* `HEAD` probe cannot answer a different question from the `GET` it stands
@@ -145,6 +148,7 @@ export function defineAuthorizedWorkspaceUseCase<
context,
authorizationOptions
)
+ await definition.authorizeResource?.(executionContext)
return executionContext
}
diff --git a/apps/sim/lib/core/application/forbidden.ts b/apps/sim/lib/core/application/forbidden.ts
index 5ac8b5c55cc..2197f839434 100644
--- a/apps/sim/lib/core/application/forbidden.ts
+++ b/apps/sim/lib/core/application/forbidden.ts
@@ -48,6 +48,8 @@ export const FORBIDDEN_DETAIL_CODES = [
'WORKSPACE_RESOURCE_LIMIT_REACHED',
/** The workspace's organization does not permit public sharing. */
'PUBLIC_SHARING_NOT_ALLOWED',
+ /** The caller can reach the workspace but cannot administer this credential. */
+ 'CREDENTIAL_ADMIN_ACCESS_REQUIRED',
/** The MCP server URL is outside the allowed domains or resolves internally. */
'MCP_SERVER_URL_NOT_ALLOWED',
] as const
@@ -83,6 +85,8 @@ export const FORBIDDEN_DETAIL_CODE_DESCRIPTIONS: Record {
/**
* Runs everything {@link execute} does up to and including resource
* authorization, then stops — allowed-principal check, canonical load,
- * asserted-scope comparison, current access check — but not the business
- * transaction, the audit projection, or the after-success effects.
+ * asserted-scope comparison, current workspace access check, resource access
+ * check — but not the business transaction, the audit projection, or the
+ * after-success effects.
*
* It exists for one caller: a surface that must answer *"would this principal
* be allowed?"* without causing what the answer would cause. `HEAD` on a route
diff --git a/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts b/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts
index 917e87d666a..2fc567d9205 100644
--- a/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts
+++ b/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts
@@ -15,7 +15,7 @@ vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock }))
vi.mock('@sim/db/schema', () => schemaMock)
vi.mock('drizzle-orm', () => drizzleOrmMock)
-import { clearCredentialRefs } from '@/lib/credentials/deletion'
+import { clearCredentialRefs, deleteConnectionCredential } from '@/lib/credentials/deletion'
describe('credential-bound webhook deactivation', () => {
beforeEach(() => {
@@ -39,3 +39,41 @@ describe('credential-bound webhook deactivation', () => {
expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.webhook.provider, 'slack')
})
})
+
+describe('deleteConnectionCredential', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ resetDbChainMock()
+ })
+
+ afterAll(() => {
+ resetDbChainMock()
+ })
+
+ it('deletes exactly one credential within its canonical workspace scope', async () => {
+ dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'credential-1' }])
+
+ const deleted = await deleteConnectionCredential({
+ credentialId: 'credential-1',
+ workspaceId: 'workspace-1',
+ reason: 'user_delete',
+ })
+
+ expect(deleted).toBe(true)
+ expect(dbChainMockFns.delete).toHaveBeenCalledWith(schemaMock.credential)
+ expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.credential.id, 'credential-1')
+ expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.credential.workspaceId, 'workspace-1')
+ })
+
+ it('returns an idempotent no-op if a concurrent disconnect wins the delete', async () => {
+ dbChainMockFns.returning.mockResolvedValueOnce([])
+
+ await expect(
+ deleteConnectionCredential({
+ credentialId: 'credential-1',
+ workspaceId: 'workspace-1',
+ reason: 'user_delete',
+ })
+ ).resolves.toBe(false)
+ })
+})
diff --git a/apps/sim/lib/credentials/application/authorized-credential-use-case.ts b/apps/sim/lib/credentials/application/authorized-credential-use-case.ts
new file mode 100644
index 00000000000..2fe52fb957e
--- /dev/null
+++ b/apps/sim/lib/credentials/application/authorized-credential-use-case.ts
@@ -0,0 +1,64 @@
+import { requirePrincipalSubjectUserId } from '@sim/auth/principal'
+import {
+ type AuthorizedWorkspaceUseCaseDefinition,
+ defineAuthorizedWorkspaceUseCase,
+ ForbiddenOperationError,
+ type WorkspaceAuthorizationContext,
+} from '@/lib/core/application'
+import { OrchestrationError } from '@/lib/core/orchestration/types'
+import { getCredentialActorContext } from '@/lib/credentials/access'
+import type { CredentialAdminOperation } from '@/lib/credentials/application/operations'
+import type { CredentialRow } from '@/lib/credentials/queries'
+
+export interface CredentialAuthorizationContext extends WorkspaceAuthorizationContext {
+ credential: CredentialRow
+}
+
+type AuthorizedCredentialUseCaseDefinition<
+ O extends CredentialAdminOperation,
+ I,
+ C extends CredentialAuthorizationContext,
+ R,
+> = Omit<
+ AuthorizedWorkspaceUseCaseDefinition,
+ 'authorizationOptions' | 'authorizeResource'
+>
+
+export function defineAuthorizedCredentialUseCase<
+ const O extends CredentialAdminOperation,
+ I,
+ C extends CredentialAuthorizationContext,
+ R,
+>(definition: AuthorizedCredentialUseCaseDefinition) {
+ return defineAuthorizedWorkspaceUseCase({
+ ...definition,
+ authorizationOptions: {},
+ async authorizeResource({ principal, context }) {
+ const actor = await getCredentialActorContext(
+ context.credential.id,
+ requirePrincipalSubjectUserId(principal)
+ )
+ if (
+ !actor.credential ||
+ actor.credential.workspaceId !== context.workspaceId ||
+ !actor.hasWorkspaceAccess
+ ) {
+ throw new OrchestrationError('not_found', 'Credential not found')
+ }
+ switch (definition.operation.minimumCredentialRole) {
+ case 'admin':
+ if (!actor.isAdmin) {
+ throw new ForbiddenOperationError(
+ 'CREDENTIAL_ADMIN_ACCESS_REQUIRED',
+ 'Credential admin permission required'
+ )
+ }
+ return
+ default:
+ throw new Error(
+ `Unsupported credential role: ${definition.operation.minimumCredentialRole}`
+ )
+ }
+ },
+ })
+}
diff --git a/apps/sim/lib/credentials/application/connection-target.test.ts b/apps/sim/lib/credentials/application/connection-target.test.ts
new file mode 100644
index 00000000000..143a068961e
--- /dev/null
+++ b/apps/sim/lib/credentials/application/connection-target.test.ts
@@ -0,0 +1,151 @@
+/**
+ * @vitest-environment node
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({
+ listCatalog: vi.fn(),
+ getWorkspaceCredential: vi.fn(),
+ getCredentialActorContext: vi.fn(),
+}))
+
+vi.mock('@/lib/credentials/application/provider-catalog', () => ({
+ listCredentialProviderCatalog: mocks.listCatalog,
+ requireAvailableOAuthCredentialProvider: (
+ catalog: Array<{
+ available: boolean
+ authorizationOptions: Array<{ providerId: string }>
+ }>,
+ providerId: string
+ ) => {
+ const provider = catalog.find((entry) =>
+ entry.authorizationOptions.some((option) => option.providerId === providerId)
+ )
+ if (!provider) throw Object.assign(new Error('Unknown OAuth provider'), { code: 'validation' })
+ if (!provider.available)
+ throw Object.assign(new Error('OAuth provider is unavailable'), { code: 'conflict' })
+ return provider
+ },
+}))
+
+vi.mock('@/lib/credentials/queries', () => ({
+ getWorkspaceCredential: mocks.getWorkspaceCredential,
+}))
+
+vi.mock('@/lib/credentials/access', () => ({
+ getCredentialActorContext: mocks.getCredentialActorContext,
+}))
+
+vi.mock('@/lib/oauth/utils', () => ({
+ credentialProviderMatchesService: (
+ credentialProviderId: string,
+ service: { providerId: string; additionalProviderIds?: readonly string[] }
+ ) =>
+ credentialProviderId === service.providerId ||
+ (service.additionalProviderIds?.includes(credentialProviderId) ?? false),
+}))
+
+import { resolveCredentialConnectionTarget } from '@/lib/credentials/application/connection-target'
+
+const principal = {
+ kind: 'personal_api_key' as const,
+ userId: 'user-1',
+ keyId: 'key-1',
+}
+const context = {
+ workspaceId: 'workspace-1',
+ workspaceOrganizationId: null,
+ allowPersonalApiKeys: true,
+ billedAccountUserId: 'billing-owner-1',
+}
+const salesforceProvider = {
+ type: 'oauth' as const,
+ serviceId: 'salesforce',
+ name: 'Salesforce',
+ description: 'Connect Salesforce.',
+ providerFamily: 'salesforce',
+ available: true,
+ supportsReconnect: true,
+ authorizationOptions: [
+ { providerId: 'salesforce', label: 'Production' },
+ { providerId: 'salesforce-sandbox', label: 'Sandbox' },
+ ],
+}
+const credential = {
+ id: 'credential-1',
+ workspaceId: 'workspace-1',
+ type: 'oauth',
+ providerId: 'salesforce-sandbox',
+ displayName: 'Sandbox CRM',
+}
+
+describe('resolveCredentialConnectionTarget', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.listCatalog.mockResolvedValue([salesforceProvider])
+ mocks.getWorkspaceCredential.mockResolvedValue(credential)
+ mocks.getCredentialActorContext.mockResolvedValue({ credential, isAdmin: true })
+ })
+
+ it('accepts an exact authorization option for a new connection', async () => {
+ const result = await resolveCredentialConnectionTarget({
+ principal,
+ context,
+ providerId: 'salesforce-sandbox',
+ })
+
+ expect(result).toEqual({
+ provider: salesforceProvider,
+ providerId: 'salesforce-sandbox',
+ })
+ expect(mocks.getWorkspaceCredential).not.toHaveBeenCalled()
+ })
+
+ it('loads reconnect credentials through the asserted workspace and requires admin access', async () => {
+ mocks.getCredentialActorContext.mockResolvedValue({ credential, isAdmin: false })
+
+ await expect(
+ resolveCredentialConnectionTarget({
+ principal,
+ context,
+ credentialId: 'credential-1',
+ })
+ ).rejects.toMatchObject({
+ code: 'forbidden',
+ detailCode: 'CREDENTIAL_ADMIN_ACCESS_REQUIRED',
+ })
+
+ expect(mocks.getWorkspaceCredential).toHaveBeenCalledWith({
+ workspaceId: 'workspace-1',
+ credentialId: 'credential-1',
+ })
+ expect(mocks.getCredentialActorContext).toHaveBeenCalledWith('credential-1', 'user-1')
+ })
+
+ it('preserves the credential authorization-server ID on reconnect', async () => {
+ const result = await resolveCredentialConnectionTarget({
+ principal,
+ context,
+ credentialId: 'credential-1',
+ })
+
+ expect(result).toEqual({
+ provider: salesforceProvider,
+ providerId: 'salesforce-sandbox',
+ credentialId: 'credential-1',
+ displayName: 'Sandbox CRM',
+ })
+ })
+
+ it('rejects providers whose custom flow cannot reconnect', async () => {
+ mocks.listCatalog.mockResolvedValue([{ ...salesforceProvider, supportsReconnect: false }])
+
+ await expect(
+ resolveCredentialConnectionTarget({
+ principal,
+ context,
+ credentialId: 'credential-1',
+ })
+ ).rejects.toMatchObject({ code: 'conflict' })
+ })
+})
diff --git a/apps/sim/lib/credentials/application/connection-target.ts b/apps/sim/lib/credentials/application/connection-target.ts
new file mode 100644
index 00000000000..8783d526463
--- /dev/null
+++ b/apps/sim/lib/credentials/application/connection-target.ts
@@ -0,0 +1,96 @@
+import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal'
+import { ForbiddenOperationError } from '@/lib/core/application/forbidden'
+import { OrchestrationError } from '@/lib/core/orchestration/types'
+import { getCredentialActorContext } from '@/lib/credentials/access'
+import {
+ listCredentialProviderCatalog,
+ type OAuthCredentialProviderCatalogEntry,
+ requireAvailableOAuthCredentialProvider,
+} from '@/lib/credentials/application/provider-catalog'
+import { getWorkspaceCredential } from '@/lib/credentials/queries'
+import { credentialProviderMatchesService } from '@/lib/oauth/utils'
+import type { ActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context'
+
+export interface ResolvedCredentialConnectionTarget {
+ provider: OAuthCredentialProviderCatalogEntry
+ providerId: string
+ credentialId?: string
+ displayName?: string
+}
+
+export async function resolveCredentialConnectionTarget(params: {
+ principal: Principal
+ context: ActiveWorkspaceApplicationContext
+ providerId?: string
+ credentialId?: string
+}): Promise {
+ const { principal, context, providerId, credentialId } = params
+ if (Boolean(providerId) === Boolean(credentialId)) {
+ throw new Error('Credential connection requires exactly one target identifier')
+ }
+
+ const catalog = await listCredentialProviderCatalog(principal, context)
+ if (providerId) {
+ return {
+ provider: requireAvailableOAuthCredentialProvider(catalog, providerId),
+ providerId,
+ }
+ }
+
+ if (!credentialId) throw new Error('Credential reconnect target is missing its credential ID')
+ const userId = requirePrincipalSubjectUserId(principal)
+ const targetCredentialId = credentialId
+ const credential = await getWorkspaceCredential({
+ workspaceId: context.workspaceId,
+ credentialId: targetCredentialId,
+ })
+ if (!credential) throw new OrchestrationError('not_found', 'Credential not found')
+ if (credential.type !== 'oauth' || !credential.providerId) {
+ throw new OrchestrationError('validation', 'Only OAuth credentials can be reconnected')
+ }
+ const credentialProviderId = credential.providerId
+
+ const actor = await getCredentialActorContext(targetCredentialId, userId)
+ if (!actor.credential || actor.credential.workspaceId !== context.workspaceId) {
+ throw new OrchestrationError('not_found', 'Credential not found')
+ }
+ if (!actor.isAdmin) {
+ throw new ForbiddenOperationError(
+ 'CREDENTIAL_ADMIN_ACCESS_REQUIRED',
+ 'Admin access on the credential is required to reconnect it'
+ )
+ }
+
+ const provider = catalog.find(
+ (entry): entry is OAuthCredentialProviderCatalogEntry =>
+ entry.type === 'oauth' &&
+ credentialProviderMatchesService(credentialProviderId, {
+ providerId: entry.authorizationOptions[0].providerId,
+ additionalProviderIds: entry.authorizationOptions
+ .slice(1)
+ .map((option) => option.providerId),
+ })
+ )
+ if (!provider) {
+ throw new OrchestrationError('validation', `Unknown OAuth provider: ${credentialProviderId}`)
+ }
+ if (!provider.available) {
+ throw new OrchestrationError(
+ 'conflict',
+ `OAuth provider is unavailable: ${credentialProviderId}`
+ )
+ }
+ if (!provider.supportsReconnect) {
+ throw new OrchestrationError(
+ 'conflict',
+ `OAuth provider does not support reconnecting credentials: ${credentialProviderId}`
+ )
+ }
+
+ return {
+ provider,
+ providerId: credentialProviderId,
+ credentialId: credential.id,
+ displayName: credential.displayName,
+ }
+}
diff --git a/apps/sim/lib/credentials/application/create-credential-connection.test.ts b/apps/sim/lib/credentials/application/create-credential-connection.test.ts
new file mode 100644
index 00000000000..544edd2130b
--- /dev/null
+++ b/apps/sim/lib/credentials/application/create-credential-connection.test.ts
@@ -0,0 +1,130 @@
+/**
+ * @vitest-environment node
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({
+ loadWorkspace: vi.fn(),
+ resolvePermission: vi.fn(),
+ resolveTarget: vi.fn(),
+ createDraft: vi.fn(),
+ getBaseUrl: vi.fn(),
+}))
+
+vi.mock('@/lib/workspaces/application/workspace-context', () => ({
+ loadActiveWorkspaceApplicationContext: mocks.loadWorkspace,
+}))
+
+vi.mock('@sim/platform-authz/workspace', () => ({
+ permissionSatisfies: (permission: string | null, required: string) =>
+ permission === 'admin' || permission === 'write' || permission === required,
+ resolveEffectiveWorkspacePermission: mocks.resolvePermission,
+}))
+
+vi.mock('@/lib/credentials/application/connection-target', () => ({
+ resolveCredentialConnectionTarget: mocks.resolveTarget,
+}))
+
+vi.mock('@/lib/credentials/connect-draft', () => ({
+ createConnectDraft: mocks.createDraft,
+}))
+
+vi.mock('@/lib/core/utils/urls', () => ({
+ getBaseUrl: mocks.getBaseUrl,
+}))
+
+import { createCredentialConnection } from '@/lib/credentials/application/create-credential-connection'
+
+const workspaceContext = {
+ workspaceId: 'workspace-1',
+ workspaceOrganizationId: null,
+ allowPersonalApiKeys: true,
+ billedAccountUserId: 'billing-owner-1',
+}
+const personalPrincipal = {
+ kind: 'personal_api_key' as const,
+ userId: 'user-1',
+ keyId: 'key-1',
+}
+
+describe('createCredentialConnection', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.loadWorkspace.mockResolvedValue(workspaceContext)
+ mocks.resolvePermission.mockResolvedValue('write')
+ mocks.resolveTarget.mockResolvedValue({
+ provider: { serviceId: 'gmail' },
+ providerId: 'google-email',
+ })
+ mocks.createDraft.mockResolvedValue({
+ id: 'draft-1',
+ expiresAt: new Date('2026-08-12T20:15:00.000Z'),
+ })
+ mocks.getBaseUrl.mockReturnValue('https://sim.ai')
+ })
+
+ it('rejects workspace keys before canonical workspace loading', async () => {
+ await expect(
+ createCredentialConnection.execute({
+ principal: {
+ kind: 'workspace_api_key',
+ workspaceId: 'workspace-1',
+ keyId: 'key-1',
+ },
+ input: {
+ workspaceId: 'workspace-1',
+ providerId: 'google-email',
+ displayName: 'Work Gmail',
+ },
+ })
+ ).rejects.toMatchObject({ code: 'forbidden' })
+ expect(mocks.loadWorkspace).not.toHaveBeenCalled()
+ })
+
+ it('creates a user-bound draft and returns only its browser entrypoint', async () => {
+ const result = await createCredentialConnection.execute({
+ principal: personalPrincipal,
+ input: {
+ workspaceId: 'workspace-1',
+ providerId: 'google-email',
+ displayName: 'Work Gmail',
+ },
+ })
+
+ expect(mocks.createDraft).toHaveBeenCalledWith({
+ userId: 'user-1',
+ workspaceId: 'workspace-1',
+ providerId: 'google-email',
+ credentialId: undefined,
+ displayName: 'Work Gmail',
+ displayNameDefinesIntent: true,
+ })
+ expect(result).toEqual({
+ authorizationUrl: 'https://sim.ai/api/auth/oauth2/authorize?draftId=draft-1',
+ expiresAt: new Date('2026-08-12T20:15:00.000Z'),
+ })
+ })
+
+ it("preserves an existing credential's name on reconnect", async () => {
+ mocks.resolveTarget.mockResolvedValue({
+ provider: { serviceId: 'gmail' },
+ providerId: 'google-email',
+ credentialId: 'credential-1',
+ displayName: 'Existing Gmail',
+ })
+
+ await createCredentialConnection.execute({
+ principal: personalPrincipal,
+ input: { workspaceId: 'workspace-1', credentialId: 'credential-1' },
+ })
+
+ expect(mocks.createDraft).toHaveBeenCalledWith({
+ userId: 'user-1',
+ workspaceId: 'workspace-1',
+ providerId: 'google-email',
+ credentialId: 'credential-1',
+ displayName: 'Existing Gmail',
+ displayNameDefinesIntent: false,
+ })
+ })
+})
diff --git a/apps/sim/lib/credentials/application/create-credential-connection.ts b/apps/sim/lib/credentials/application/create-credential-connection.ts
new file mode 100644
index 00000000000..4aaae28a61c
--- /dev/null
+++ b/apps/sim/lib/credentials/application/create-credential-connection.ts
@@ -0,0 +1,54 @@
+import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application'
+import { OrchestrationError } from '@/lib/core/orchestration/types'
+import { getBaseUrl } from '@/lib/core/utils/urls'
+import { resolveCredentialConnectionTarget } from '@/lib/credentials/application/connection-target'
+import { credentialOperations } from '@/lib/credentials/application/operations'
+import { createConnectDraft } from '@/lib/credentials/connect-draft'
+import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context'
+
+export type CreateCredentialConnectionInput = {
+ workspaceId: string
+} & (
+ | { providerId: string; displayName: string; credentialId?: never }
+ | { credentialId: string; providerId?: never; displayName?: never }
+)
+
+export interface CreateCredentialConnectionResult {
+ authorizationUrl: string
+ expiresAt: Date
+}
+
+export const createCredentialConnection = defineAuthorizedWorkspaceUseCase({
+ operation: credentialOperations.createConnection,
+ resolveContext: async ({ input }: { input: CreateCredentialConnectionInput }) => {
+ const context = await loadActiveWorkspaceApplicationContext(input.workspaceId)
+ if (!context) throw new OrchestrationError('not_found', 'Workspace not found')
+ return context
+ },
+ authorizationOptions: {},
+ execute: async ({ principal, input, context }): Promise => {
+ const target = await resolveCredentialConnectionTarget({
+ principal,
+ context,
+ providerId: input.providerId,
+ credentialId: input.credentialId,
+ })
+ const displayName = input.providerId ? input.displayName : target.displayName
+ if (!displayName) throw new Error('Resolved credential connection target has no display name')
+
+ const draft = await createConnectDraft({
+ userId: principal.userId,
+ workspaceId: context.workspaceId,
+ providerId: target.providerId,
+ credentialId: target.credentialId,
+ displayName,
+ displayNameDefinesIntent: input.providerId !== undefined,
+ })
+ const authorizationUrl = new URL('/api/auth/oauth2/authorize', getBaseUrl())
+ authorizationUrl.searchParams.set('draftId', draft.id)
+ return {
+ authorizationUrl: authorizationUrl.toString(),
+ expiresAt: draft.expiresAt,
+ }
+ },
+})
diff --git a/apps/sim/lib/credentials/application/launch-credential-connection.test.ts b/apps/sim/lib/credentials/application/launch-credential-connection.test.ts
new file mode 100644
index 00000000000..d983cdf3d53
--- /dev/null
+++ b/apps/sim/lib/credentials/application/launch-credential-connection.test.ts
@@ -0,0 +1,93 @@
+/**
+ * @vitest-environment node
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({
+ getActiveDraft: vi.fn(),
+ loadWorkspace: vi.fn(),
+ resolvePermission: vi.fn(),
+ resolveTarget: vi.fn(),
+}))
+
+vi.mock('@/lib/credentials/connect-draft', () => ({
+ getActiveConnectDraft: mocks.getActiveDraft,
+}))
+
+vi.mock('@/lib/workspaces/application/workspace-context', () => ({
+ loadActiveWorkspaceApplicationContext: mocks.loadWorkspace,
+}))
+
+vi.mock('@sim/platform-authz/workspace', () => ({
+ permissionSatisfies: (permission: string | null, required: string) =>
+ permission === 'admin' || permission === 'write' || permission === required,
+ resolveEffectiveWorkspacePermission: mocks.resolvePermission,
+}))
+
+vi.mock('@/lib/credentials/application/connection-target', () => ({
+ resolveCredentialConnectionTarget: mocks.resolveTarget,
+}))
+
+import { launchCredentialConnection } from '@/lib/credentials/application/launch-credential-connection'
+
+const principal = {
+ kind: 'session' as const,
+ userId: 'user-1',
+ sessionId: 'session-1',
+}
+const draft = {
+ id: 'draft-1',
+ userId: 'user-1',
+ workspaceId: 'workspace-1',
+ providerId: 'google-email',
+ displayName: "User's Gmail",
+ description: null,
+ credentialId: null,
+ expiresAt: new Date('2026-08-12T20:15:00.000Z'),
+ createdAt: new Date('2026-08-12T20:00:00.000Z'),
+}
+const workspaceContext = {
+ workspaceId: 'workspace-1',
+ workspaceOrganizationId: null,
+ allowPersonalApiKeys: true,
+ billedAccountUserId: 'billing-owner-1',
+}
+
+describe('launchCredentialConnection', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.getActiveDraft.mockResolvedValue(draft)
+ mocks.loadWorkspace.mockResolvedValue(workspaceContext)
+ mocks.resolvePermission.mockResolvedValue('write')
+ mocks.resolveTarget.mockResolvedValue({
+ provider: { serviceId: 'gmail' },
+ providerId: 'google-email',
+ })
+ })
+
+ it('loads the exact draft for the signed-in user and reauthorizes its target', async () => {
+ const result = await launchCredentialConnection.execute({
+ principal,
+ input: { draftId: 'draft-1' },
+ })
+
+ expect(mocks.getActiveDraft).toHaveBeenCalledWith('draft-1', 'user-1')
+ expect(mocks.resolveTarget).toHaveBeenCalledWith({
+ principal,
+ context: { ...workspaceContext, draft },
+ providerId: 'google-email',
+ credentialId: undefined,
+ })
+ expect(result).toEqual({ draft })
+ })
+
+ it('rejects an invalid or expired draft before loading a workspace', async () => {
+ mocks.getActiveDraft.mockResolvedValue(null)
+
+ await expect(
+ launchCredentialConnection.execute({ principal, input: { draftId: 'draft-missing' } })
+ ).rejects.toMatchObject({ code: 'not_found' })
+
+ expect(mocks.loadWorkspace).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/lib/credentials/application/launch-credential-connection.ts b/apps/sim/lib/credentials/application/launch-credential-connection.ts
new file mode 100644
index 00000000000..1a19cafecee
--- /dev/null
+++ b/apps/sim/lib/credentials/application/launch-credential-connection.ts
@@ -0,0 +1,52 @@
+import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application'
+import { OrchestrationError } from '@/lib/core/orchestration/types'
+import { resolveCredentialConnectionTarget } from '@/lib/credentials/application/connection-target'
+import { credentialOperations } from '@/lib/credentials/application/operations'
+import { type ConnectDraft, getActiveConnectDraft } from '@/lib/credentials/connect-draft'
+import {
+ type ActiveWorkspaceApplicationContext,
+ loadActiveWorkspaceApplicationContext,
+} from '@/lib/workspaces/application/workspace-context'
+
+export interface LaunchCredentialConnectionInput {
+ draftId: string
+}
+
+interface LaunchCredentialConnectionContext extends ActiveWorkspaceApplicationContext {
+ draft: ConnectDraft
+}
+
+export interface LaunchCredentialConnectionResult {
+ draft: ConnectDraft
+}
+
+export const launchCredentialConnection = defineAuthorizedWorkspaceUseCase({
+ operation: credentialOperations.launchConnection,
+ resolveContext: async ({
+ principal,
+ input,
+ }: {
+ principal: { kind: 'session'; userId: string; sessionId: string }
+ input: LaunchCredentialConnectionInput
+ }): Promise => {
+ const draft = await getActiveConnectDraft(input.draftId, principal.userId)
+ if (!draft)
+ throw new OrchestrationError('not_found', 'OAuth connection link is invalid or expired')
+ const workspace = await loadActiveWorkspaceApplicationContext(draft.workspaceId)
+ if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found')
+ return { ...workspace, draft }
+ },
+ authorizationOptions: {},
+ execute: async ({ principal, context }): Promise => {
+ const target = await resolveCredentialConnectionTarget({
+ principal,
+ context,
+ providerId: context.draft.credentialId ? undefined : context.draft.providerId,
+ credentialId: context.draft.credentialId ?? undefined,
+ })
+ if (target.providerId !== context.draft.providerId) {
+ throw new OrchestrationError('conflict', 'OAuth connection provider no longer matches')
+ }
+ return { draft: context.draft }
+ },
+})
diff --git a/apps/sim/lib/credentials/application/list-credential-providers.test.ts b/apps/sim/lib/credentials/application/list-credential-providers.test.ts
new file mode 100644
index 00000000000..c7d11d848d9
--- /dev/null
+++ b/apps/sim/lib/credentials/application/list-credential-providers.test.ts
@@ -0,0 +1,68 @@
+/**
+ * @vitest-environment node
+ */
+import type { SessionPrincipal } from '@sim/auth/principal'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({
+ loadWorkspace: vi.fn(),
+ resolvePermission: vi.fn(),
+ listCatalog: vi.fn(),
+}))
+
+vi.mock('@/lib/workspaces/application/workspace-context', () => ({
+ loadActiveWorkspaceApplicationContext: mocks.loadWorkspace,
+}))
+
+vi.mock('@sim/platform-authz/workspace', () => ({
+ permissionSatisfies: (permission: string | null, required: string) =>
+ permission === 'admin' || permission === 'write' || permission === required,
+ resolveEffectiveWorkspacePermission: mocks.resolvePermission,
+}))
+
+vi.mock('@/lib/credentials/application/provider-catalog', () => ({
+ listCredentialProviderCatalog: mocks.listCatalog,
+}))
+
+import { listCredentialProviders } from '@/lib/credentials/application/list-credential-providers'
+
+const workspaceContext = {
+ workspaceId: 'workspace-1',
+ workspaceOrganizationId: null,
+ allowPersonalApiKeys: true,
+ billedAccountUserId: 'billing-owner-1',
+}
+
+describe('listCredentialProviders', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.loadWorkspace.mockResolvedValue(workspaceContext)
+ mocks.resolvePermission.mockResolvedValue('read')
+ mocks.listCatalog.mockResolvedValue([])
+ })
+
+ it('rejects unsupported principals before canonical workspace loading', async () => {
+ const principal: SessionPrincipal = {
+ kind: 'session',
+ userId: 'user-1',
+ sessionId: 'session-1',
+ }
+
+ await expect(
+ listCredentialProviders.execute({ principal, input: { workspaceId: 'workspace-1' } })
+ ).rejects.toMatchObject({ code: 'forbidden' })
+ expect(mocks.loadWorkspace).not.toHaveBeenCalled()
+ })
+
+ it('allows workspace keys to inspect deployment availability', async () => {
+ const principal = {
+ kind: 'workspace_api_key' as const,
+ workspaceId: 'workspace-1',
+ keyId: 'key-1',
+ }
+
+ await listCredentialProviders.execute({ principal, input: { workspaceId: 'workspace-1' } })
+
+ expect(mocks.listCatalog).toHaveBeenCalledWith(principal, workspaceContext)
+ })
+})
diff --git a/apps/sim/lib/credentials/application/list-credential-providers.ts b/apps/sim/lib/credentials/application/list-credential-providers.ts
new file mode 100644
index 00000000000..b24d45ea811
--- /dev/null
+++ b/apps/sim/lib/credentials/application/list-credential-providers.ts
@@ -0,0 +1,29 @@
+import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application'
+import { OrchestrationError } from '@/lib/core/orchestration/types'
+import { credentialOperations } from '@/lib/credentials/application/operations'
+import {
+ type CredentialProviderCatalogEntry,
+ listCredentialProviderCatalog,
+} from '@/lib/credentials/application/provider-catalog'
+import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context'
+
+export interface ListCredentialProvidersInput {
+ workspaceId: string
+}
+
+export interface ListCredentialProvidersResult {
+ providers: CredentialProviderCatalogEntry[]
+}
+
+export const listCredentialProviders = defineAuthorizedWorkspaceUseCase({
+ operation: credentialOperations.listProviders,
+ resolveContext: async ({ input }: { input: ListCredentialProvidersInput }) => {
+ const context = await loadActiveWorkspaceApplicationContext(input.workspaceId)
+ if (!context) throw new OrchestrationError('not_found', 'Workspace not found')
+ return context
+ },
+ authorizationOptions: {},
+ execute: async ({ principal, context }): Promise => ({
+ providers: await listCredentialProviderCatalog(principal, context),
+ }),
+})
diff --git a/apps/sim/lib/credentials/application/operations.test.ts b/apps/sim/lib/credentials/application/operations.test.ts
new file mode 100644
index 00000000000..2fd4091fb7b
--- /dev/null
+++ b/apps/sim/lib/credentials/application/operations.test.ts
@@ -0,0 +1,35 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from 'vitest'
+import { defineWorkspaceOperation } from '@/lib/core/application'
+import {
+ credentialOperations,
+ defineCredentialAdminOperation,
+} from '@/lib/credentials/application/operations'
+
+describe('credential operations', () => {
+ it('declares credential admin as the delete authority and workspace read as reach', () => {
+ expect(credentialOperations.delete).toMatchObject({
+ id: 'credentials.delete',
+ minimumRole: 'read',
+ minimumCredentialRole: 'admin',
+ workspaceApiKey: 'deny',
+ principalKinds: ['personal_api_key'],
+ })
+ expect(Object.isFrozen(credentialOperations.delete)).toBe(true)
+ })
+
+ it('rejects actorless workspace keys for credential admin operations', () => {
+ const workspaceKeyOperation = defineWorkspaceOperation({
+ id: 'credentials.test_admin',
+ minimumRole: 'read',
+ workspaceApiKey: 'allow',
+ principalKinds: ['workspace_api_key'],
+ })
+
+ expect(() => defineCredentialAdminOperation(workspaceKeyOperation)).toThrow(
+ 'Credential admin operation credentials.test_admin requires a human principal'
+ )
+ })
+})
diff --git a/apps/sim/lib/credentials/application/operations.ts b/apps/sim/lib/credentials/application/operations.ts
index 4a3dcde7c11..ec5b7181635 100644
--- a/apps/sim/lib/credentials/application/operations.ts
+++ b/apps/sim/lib/credentials/application/operations.ts
@@ -1,10 +1,56 @@
-import { defineWorkspaceOperation } from '@/lib/core/application'
+import { defineWorkspaceOperation, type WorkspaceOperation } from '@/lib/core/application'
+
+export type CredentialAdminOperation = O & {
+ readonly minimumCredentialRole: 'admin'
+}
+
+/** Adds credential-admin policy to a workspace-scoped operation. */
+export function defineCredentialAdminOperation(
+ operation: O
+): CredentialAdminOperation {
+ if (operation.principalKinds.includes('workspace_api_key')) {
+ throw new Error(`Credential admin operation ${operation.id} requires a human principal`)
+ }
+ return Object.freeze({ ...operation, minimumCredentialRole: 'admin' as const })
+}
export const credentialOperations = {
+ listProviders: defineWorkspaceOperation({
+ id: 'credentials.providers.list',
+ minimumRole: 'read',
+ workspaceApiKey: 'allow',
+ principalKinds: ['personal_api_key', 'workspace_api_key'],
+ }),
listConnections: defineWorkspaceOperation({
id: 'credentials.connections.list',
minimumRole: 'read',
workspaceApiKey: 'allow',
principalKinds: ['personal_api_key', 'workspace_api_key'],
}),
+ createConnection: defineWorkspaceOperation({
+ id: 'credentials.connections.create',
+ minimumRole: 'write',
+ workspaceApiKey: 'deny',
+ principalKinds: ['personal_api_key'],
+ }),
+ createServiceAccount: defineWorkspaceOperation({
+ id: 'credentials.service_accounts.create',
+ minimumRole: 'write',
+ workspaceApiKey: 'deny',
+ principalKinds: ['personal_api_key'],
+ }),
+ delete: defineCredentialAdminOperation(
+ defineWorkspaceOperation({
+ id: 'credentials.delete',
+ minimumRole: 'read',
+ workspaceApiKey: 'deny',
+ principalKinds: ['personal_api_key'],
+ })
+ ),
+ launchConnection: defineWorkspaceOperation({
+ id: 'credentials.connections.launch',
+ minimumRole: 'write',
+ workspaceApiKey: 'deny',
+ principalKinds: ['session'],
+ }),
} as const
diff --git a/apps/sim/lib/credentials/application/presentation.ts b/apps/sim/lib/credentials/application/presentation.ts
new file mode 100644
index 00000000000..efbf73c0d07
--- /dev/null
+++ b/apps/sim/lib/credentials/application/presentation.ts
@@ -0,0 +1,26 @@
+import type { V2Credential } from '@/lib/api/contracts/v2/credentials'
+import type { CredentialRow, VisibleWorkspaceCredential } from '@/lib/credentials/queries'
+
+type PublicCredentialSource =
+ | VisibleWorkspaceCredential
+ | (CredentialRow & { hasServiceAccountKey: boolean; role: 'admin' | 'member' })
+
+/** Serializes connection metadata field by field so encrypted columns can never reach the wire. */
+export function toV2Credential(row: PublicCredentialSource): V2Credential {
+ if (row.type !== 'oauth' && row.type !== 'service_account') {
+ throw new Error(`Secret credential type ${row.type} reached the credentials API`)
+ }
+
+ return {
+ id: row.id,
+ type: row.type,
+ displayName: row.displayName,
+ description: row.description,
+ providerId: row.providerId,
+ accountId: row.accountId,
+ hasServiceAccountKey: row.hasServiceAccountKey,
+ role: row.role,
+ createdAt: row.createdAt.toISOString(),
+ updatedAt: row.updatedAt.toISOString(),
+ }
+}
diff --git a/apps/sim/lib/credentials/application/provider-catalog.test.ts b/apps/sim/lib/credentials/application/provider-catalog.test.ts
new file mode 100644
index 00000000000..f485a80465d
--- /dev/null
+++ b/apps/sim/lib/credentials/application/provider-catalog.test.ts
@@ -0,0 +1,237 @@
+/**
+ * @vitest-environment node
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({
+ getBlockVisibility: vi.fn(),
+ getAllowedIntegrationsFromEnv: vi.fn(),
+ getUserPermissionConfig: vi.fn(),
+ createVisibility: vi.fn(),
+ getAllOAuthServices: vi.fn(),
+ getServiceConfigByServiceId: vi.fn(),
+}))
+
+vi.mock('@/lib/core/config/block-visibility', () => ({
+ getBlockVisibility: mocks.getBlockVisibility,
+}))
+
+vi.mock('@/lib/core/config/env-flags', () => ({
+ getAllowedIntegrationsFromEnv: mocks.getAllowedIntegrationsFromEnv,
+}))
+
+vi.mock('@/ee/access-control/utils/permission-check', () => ({
+ getUserPermissionConfig: mocks.getUserPermissionConfig,
+}))
+
+vi.mock('@/lib/permission-groups/integration-allowlist', () => ({
+ intersectIntegrationAllowlists: (
+ permissionGroup: readonly string[] | null,
+ deployment: readonly string[] | null
+ ) => {
+ if (!permissionGroup) return deployment
+ if (!deployment) return permissionGroup
+ return permissionGroup.filter((type) => deployment.includes(type))
+ },
+}))
+
+vi.mock('@/lib/integrations/credential-visibility.server', () => ({
+ createIntegrationCredentialVisibility: mocks.createVisibility,
+}))
+
+vi.mock('@/lib/oauth/utils', () => ({
+ getAllOAuthServices: mocks.getAllOAuthServices,
+ getServiceConfigByServiceId: mocks.getServiceConfigByServiceId,
+}))
+
+import {
+ listCredentialProviderCatalog,
+ requireAvailableServiceAccountCredentialProvider,
+ type ServiceAccountCredentialProviderCatalogEntry,
+} from '@/lib/credentials/application/provider-catalog'
+
+const personalPrincipal = {
+ kind: 'personal_api_key' as const,
+ userId: 'user-1',
+ keyId: 'key-1',
+}
+const context = {
+ workspaceId: 'workspace-1',
+ workspaceOrganizationId: 'organization-1',
+}
+const services = [
+ {
+ serviceId: 'salesforce',
+ providerId: 'salesforce',
+ additionalProviderIds: ['salesforce-sandbox'],
+ name: 'Salesforce',
+ description: 'Connect Salesforce.',
+ baseProvider: 'salesforce',
+ authType: 'oauth' as const,
+ },
+ {
+ serviceId: 'trello',
+ providerId: 'trello',
+ name: 'Trello',
+ description: 'Connect Trello.',
+ baseProvider: 'trello',
+ authType: 'oauth' as const,
+ },
+ {
+ serviceId: 'claude-platform',
+ providerId: 'claude-platform-service-account',
+ serviceAccountProviderId: 'claude-platform-service-account',
+ name: 'Claude Platform',
+ description: 'Run Claude Platform Managed Agents from your workflows.',
+ baseProvider: 'claude-platform',
+ authType: 'service_account' as const,
+ },
+]
+
+describe('listCredentialProviderCatalog', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.getAllOAuthServices.mockReturnValue(services)
+ mocks.getAllowedIntegrationsFromEnv.mockReturnValue(['salesforce'])
+ mocks.getUserPermissionConfig.mockResolvedValue({
+ allowedIntegrations: ['salesforce', 'trello'],
+ })
+ mocks.getBlockVisibility.mockResolvedValue({
+ revealed: new Set(),
+ disabled: new Set(),
+ previewTagged: new Set(),
+ })
+ mocks.createVisibility.mockReturnValue({
+ isOAuthServiceVisible: (service: { serviceId: string }) => service.serviceId === 'salesforce',
+ isCredentialVisible: ({ providerId }: { providerId: string }) =>
+ providerId === 'claude-platform-service-account',
+ })
+ mocks.getServiceConfigByServiceId.mockImplementation((serviceId: string) => {
+ if (serviceId === 'salesforce') {
+ return {
+ providerIdLabels: {
+ salesforce: 'Production',
+ 'salesforce-sandbox': 'Sandbox',
+ },
+ }
+ }
+ if (serviceId === 'trello') return {}
+ return null
+ })
+ })
+
+ it('projects OAuth services, authorization options, and reconnect capability', async () => {
+ const catalog = await listCredentialProviderCatalog(personalPrincipal, context)
+
+ expect(catalog).toEqual([
+ {
+ type: 'oauth',
+ serviceId: 'salesforce',
+ name: 'Salesforce',
+ description: 'Connect Salesforce.',
+ providerFamily: 'salesforce',
+ available: true,
+ supportsReconnect: true,
+ authorizationOptions: [
+ { providerId: 'salesforce', label: 'Production' },
+ { providerId: 'salesforce-sandbox', label: 'Sandbox' },
+ ],
+ },
+ {
+ type: 'oauth',
+ serviceId: 'trello',
+ name: 'Trello',
+ description: 'Connect Trello.',
+ providerFamily: 'trello',
+ available: false,
+ supportsReconnect: true,
+ authorizationOptions: [{ providerId: 'trello', label: 'Trello' }],
+ },
+ {
+ type: 'service_account',
+ serviceId: 'claude-platform-service-account',
+ providerId: 'claude-platform-service-account',
+ name: 'Claude Platform API key',
+ description: 'Connect Claude Platform with a API key.',
+ providerFamily: 'claude-platform',
+ available: true,
+ docsUrl: 'https://docs.sim.ai/integrations/managed-agent',
+ requiresClientGeneratedCredentialId: false,
+ fields: [
+ {
+ id: 'apiToken',
+ label: 'API key',
+ placeholder: 'sk-ant-...',
+ required: true,
+ secret: true,
+ multiline: false,
+ hint: 'Claude Platform API keys usually start with sk-ant-.',
+ },
+ ],
+ },
+ ])
+ expect(mocks.createVisibility).toHaveBeenCalledWith(
+ expect.objectContaining({ allowedIntegrationTypes: new Set(['salesforce']) })
+ )
+ })
+
+ it('does not borrow a human permission group for workspace API keys', async () => {
+ await listCredentialProviderCatalog(
+ {
+ kind: 'workspace_api_key',
+ workspaceId: 'workspace-1',
+ keyId: 'workspace-key-1',
+ },
+ context
+ )
+
+ expect(mocks.getUserPermissionConfig).not.toHaveBeenCalled()
+ expect(mocks.createVisibility).toHaveBeenCalledWith(
+ expect.objectContaining({ allowedIntegrationTypes: new Set(['salesforce']) })
+ )
+ })
+
+ it('fails fast when a multi-server provider lacks complete labels', async () => {
+ mocks.getServiceConfigByServiceId.mockImplementation((serviceId: string) => {
+ if (serviceId === 'salesforce') {
+ return { providerIdLabels: { salesforce: 'Production' } }
+ }
+ if (serviceId === 'trello') return {}
+ return null
+ })
+
+ await expect(listCredentialProviderCatalog(personalPrincipal, context)).rejects.toThrow(
+ 'OAuth provider salesforce-sandbox is missing its authorization option label'
+ )
+ })
+})
+
+describe('requireAvailableServiceAccountCredentialProvider', () => {
+ const provider: ServiceAccountCredentialProviderCatalogEntry = {
+ type: 'service_account',
+ serviceId: 'zoom-service-account',
+ providerId: 'zoom-service-account',
+ name: 'Zoom server-to-server app',
+ description: 'Connect Zoom with a server-to-server app.',
+ providerFamily: 'zoom',
+ available: true,
+ docsUrl: 'https://docs.sim.ai/integrations/zoom-service-account',
+ requiresClientGeneratedCredentialId: false,
+ fields: [],
+ }
+
+ it('returns an available service-account provider', () => {
+ expect(requireAvailableServiceAccountCredentialProvider([provider], provider.providerId)).toBe(
+ provider
+ )
+ })
+
+ it('rejects a service-account provider hidden by workspace policy', () => {
+ expect(() =>
+ requireAvailableServiceAccountCredentialProvider(
+ [{ ...provider, available: false }],
+ provider.providerId
+ )
+ ).toThrow('Service-account provider is unavailable: zoom-service-account')
+ })
+})
diff --git a/apps/sim/lib/credentials/application/provider-catalog.ts b/apps/sim/lib/credentials/application/provider-catalog.ts
new file mode 100644
index 00000000000..7dcdc507dbf
--- /dev/null
+++ b/apps/sim/lib/credentials/application/provider-catalog.ts
@@ -0,0 +1,356 @@
+import type { Principal } from '@sim/auth/principal'
+import { getBlockVisibility } from '@/lib/core/config/block-visibility'
+import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags'
+import { OrchestrationError } from '@/lib/core/orchestration/types'
+import {
+ CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS,
+ type ClientCredentialAccountField,
+} from '@/lib/credentials/client-credential-accounts/descriptors'
+import {
+ TOKEN_SERVICE_ACCOUNT_DESCRIPTORS,
+ type TokenServiceAccountField,
+} from '@/lib/credentials/token-service-accounts/descriptors'
+import { createIntegrationCredentialVisibility } from '@/lib/integrations/credential-visibility.server'
+import {
+ ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
+ GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID,
+ type OAuthServiceMetadata,
+ SLACK_CUSTOM_BOT_PROVIDER_ID,
+} from '@/lib/oauth/types'
+import { getAllOAuthServices, getServiceConfigByServiceId } from '@/lib/oauth/utils'
+import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist'
+import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
+
+export interface CredentialProviderAuthorizationOption {
+ providerId: string
+ label: string
+}
+
+export interface CredentialProviderFieldOption {
+ value: string
+ label: string
+}
+
+export interface CredentialProviderField {
+ id: string
+ label: string
+ placeholder: string
+ required: boolean
+ secret: boolean
+ multiline: boolean
+ requiredForAuthMethods?: string[]
+ options?: CredentialProviderFieldOption[]
+ hint?: string
+}
+
+interface CredentialProviderCatalogBase {
+ type: 'oauth' | 'service_account'
+ serviceId: string
+ name: string
+ description: string
+ providerFamily: string
+ available: boolean
+}
+
+export interface OAuthCredentialProviderCatalogEntry extends CredentialProviderCatalogBase {
+ type: 'oauth'
+ supportsReconnect: boolean
+ authorizationOptions: CredentialProviderAuthorizationOption[]
+}
+
+export interface ServiceAccountCredentialProviderCatalogEntry
+ extends CredentialProviderCatalogBase {
+ type: 'service_account'
+ providerId: string
+ docsUrl: string
+ helpText?: string
+ requiresClientGeneratedCredentialId: boolean
+ fields: CredentialProviderField[]
+}
+
+export type CredentialProviderCatalogEntry =
+ | OAuthCredentialProviderCatalogEntry
+ | ServiceAccountCredentialProviderCatalogEntry
+
+interface CredentialProviderCatalogContext {
+ workspaceId: string
+ workspaceOrganizationId: string | null
+}
+
+interface ServiceAccountDescriptor {
+ name: string
+ description: string
+ docsUrl: string
+ helpText?: string
+ requiresClientGeneratedCredentialId?: boolean
+ fields: CredentialProviderField[]
+}
+
+const GOOGLE_SERVICE_ACCOUNT_DOCS_URL = 'https://docs.sim.ai/integrations/google-service-account'
+const ATLASSIAN_SERVICE_ACCOUNT_DOCS_URL =
+ 'https://docs.sim.ai/integrations/atlassian-service-account'
+
+function providerField(
+ field: TokenServiceAccountField | ClientCredentialAccountField
+): CredentialProviderField {
+ return {
+ id: field.id,
+ label: field.label,
+ placeholder: field.placeholder,
+ required: !('optional' in field && field.optional),
+ secret: field.secret,
+ multiline: 'multiline' in field && field.multiline === true,
+ ...('requiredForAuthMethods' in field && field.requiredForAuthMethods
+ ? { requiredForAuthMethods: [...field.requiredForAuthMethods] }
+ : {}),
+ ...('options' in field && field.options ? { options: [...field.options] } : {}),
+ ...('hint' in field && field.hint
+ ? { hint: field.hint }
+ : 'hintMessage' in field && field.hintMessage
+ ? { hint: field.hintMessage }
+ : {}),
+ }
+}
+
+function getServiceAccountDescriptor(providerId: string): ServiceAccountDescriptor {
+ if (providerId === GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID) {
+ return {
+ name: 'Google service account',
+ description: 'Connect Google APIs with a service-account JSON key.',
+ docsUrl: GOOGLE_SERVICE_ACCOUNT_DOCS_URL,
+ fields: [
+ {
+ id: 'serviceAccountJson',
+ label: 'JSON key',
+ placeholder: 'Paste the service-account JSON key',
+ required: true,
+ secret: true,
+ multiline: true,
+ },
+ ],
+ }
+ }
+ if (providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) {
+ return {
+ name: 'Atlassian service account',
+ description: 'Connect Jira and Confluence with an Atlassian API token.',
+ docsUrl: ATLASSIAN_SERVICE_ACCOUNT_DOCS_URL,
+ fields: [
+ {
+ id: 'apiToken',
+ label: 'API token',
+ placeholder: 'Paste the API token',
+ required: true,
+ secret: true,
+ multiline: false,
+ },
+ {
+ id: 'domain',
+ label: 'Site domain',
+ placeholder: 'your-team.atlassian.net',
+ required: true,
+ secret: false,
+ multiline: false,
+ },
+ ],
+ }
+ }
+ if (providerId === SLACK_CUSTOM_BOT_PROVIDER_ID) {
+ return {
+ name: 'Slack custom bot',
+ description: 'Connect a reusable Slack app with its signing secret and bot token.',
+ docsUrl: 'https://docs.sim.ai/integrations/slack',
+ requiresClientGeneratedCredentialId: true,
+ fields: [
+ {
+ id: 'signingSecret',
+ label: 'Signing secret',
+ placeholder: 'Paste the signing secret',
+ required: true,
+ secret: true,
+ multiline: false,
+ },
+ {
+ id: 'botToken',
+ label: 'Bot token',
+ placeholder: 'xoxb-...',
+ required: true,
+ secret: true,
+ multiline: false,
+ },
+ ],
+ }
+ }
+
+ const tokenDescriptor = Object.hasOwn(TOKEN_SERVICE_ACCOUNT_DESCRIPTORS, providerId)
+ ? TOKEN_SERVICE_ACCOUNT_DESCRIPTORS[
+ providerId as keyof typeof TOKEN_SERVICE_ACCOUNT_DESCRIPTORS
+ ]
+ : undefined
+ if (tokenDescriptor) {
+ return {
+ name: `${tokenDescriptor.serviceLabel} ${tokenDescriptor.connectNoun}`,
+ description: `Connect ${tokenDescriptor.serviceLabel} with a ${tokenDescriptor.tokenNoun}.`,
+ docsUrl: tokenDescriptor.docsUrl,
+ helpText: tokenDescriptor.helpText,
+ fields: tokenDescriptor.fields.map(providerField),
+ }
+ }
+
+ const clientDescriptor = Object.hasOwn(CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS, providerId)
+ ? CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS[
+ providerId as keyof typeof CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS
+ ]
+ : undefined
+ if (clientDescriptor) {
+ return {
+ name: `${clientDescriptor.serviceLabel} ${clientDescriptor.connectNoun}`,
+ description: `Connect ${clientDescriptor.serviceLabel} with a ${clientDescriptor.connectNoun}.`,
+ docsUrl: clientDescriptor.docsUrl,
+ helpText: clientDescriptor.helpText,
+ fields: clientDescriptor.fields.map(providerField),
+ }
+ }
+
+ throw new Error(`Service-account provider ${providerId} is missing its canonical descriptor`)
+}
+
+function principalUserId(principal: Principal): string | undefined {
+ if (principal.kind === 'session' || principal.kind === 'personal_api_key') {
+ return principal.userId
+ }
+ if (principal.kind === 'delegated') return principal.subjectUserId
+ return undefined
+}
+
+async function allowedIntegrationTypes(
+ principal: Principal,
+ workspaceId: string
+): Promise | null> {
+ const userId = principalUserId(principal)
+ const permissionConfig = userId ? await getUserPermissionConfig(userId, workspaceId) : null
+ const integrations = intersectIntegrationAllowlists(
+ permissionConfig?.allowedIntegrations ?? null,
+ getAllowedIntegrationsFromEnv()
+ )
+ return integrations ? new Set(integrations.map((type) => type.toLowerCase())) : null
+}
+
+export async function listCredentialProviderCatalog(
+ principal: Principal,
+ context: CredentialProviderCatalogContext
+): Promise {
+ const userId = principalUserId(principal)
+ const [allowedIntegrations, blockVisibility] = await Promise.all([
+ allowedIntegrationTypes(principal, context.workspaceId),
+ getBlockVisibility({
+ ...(userId ? { userId } : {}),
+ ...(context.workspaceOrganizationId ? { orgId: context.workspaceOrganizationId } : {}),
+ }),
+ ])
+ const services = getAllOAuthServices()
+ const oauthServices = services.filter((service) => service.authType === 'oauth')
+ const visibility = createIntegrationCredentialVisibility({
+ allowedIntegrationTypes: allowedIntegrations,
+ blockVisibility,
+ oauthServices: services,
+ })
+
+ const oauthEntries: OAuthCredentialProviderCatalogEntry[] = oauthServices.map((service) => {
+ const config = getServiceConfigByServiceId(service.serviceId)
+ if (!config) {
+ throw new Error(`OAuth service ${service.serviceId} is missing its canonical configuration`)
+ }
+ const providerIds = [service.providerId, ...(service.additionalProviderIds ?? [])]
+ if (providerIds.length > 1 && !config.providerIdLabels) {
+ throw new Error(`OAuth service ${service.serviceId} is missing provider option labels`)
+ }
+ const authorizationOptions = providerIds.map((providerId) => {
+ const label = providerIds.length === 1 ? service.name : config.providerIdLabels?.[providerId]
+ if (!label) {
+ throw new Error(`OAuth provider ${providerId} is missing its authorization option label`)
+ }
+ return { providerId, label }
+ })
+
+ return {
+ type: 'oauth',
+ serviceId: service.serviceId,
+ name: service.name,
+ description: service.description,
+ providerFamily: service.baseProvider,
+ available: visibility.isOAuthServiceVisible(service),
+ supportsReconnect: true,
+ authorizationOptions,
+ }
+ })
+
+ const serviceAccountOwners = new Map()
+ for (const service of services) {
+ const serviceAccountProviderId =
+ service.serviceAccountProviderId ??
+ (service.authType === 'service_account' ? service.providerId : undefined)
+ if (serviceAccountProviderId && !serviceAccountOwners.has(serviceAccountProviderId)) {
+ serviceAccountOwners.set(serviceAccountProviderId, service)
+ }
+ }
+
+ const serviceAccountEntries: ServiceAccountCredentialProviderCatalogEntry[] = [
+ ...serviceAccountOwners,
+ ].map(([providerId, owner]) => {
+ const descriptor = getServiceAccountDescriptor(providerId)
+ return {
+ type: 'service_account',
+ serviceId: providerId,
+ providerId,
+ name: descriptor.name,
+ description: descriptor.description,
+ providerFamily: owner.baseProvider,
+ available: visibility.isCredentialVisible({ providerId, type: 'service_account' }),
+ docsUrl: descriptor.docsUrl,
+ ...(descriptor.helpText ? { helpText: descriptor.helpText } : {}),
+ requiresClientGeneratedCredentialId: descriptor.requiresClientGeneratedCredentialId === true,
+ fields: descriptor.fields,
+ }
+ })
+
+ return [...oauthEntries, ...serviceAccountEntries]
+}
+
+export function requireAvailableOAuthCredentialProvider(
+ catalog: readonly CredentialProviderCatalogEntry[],
+ providerId: string
+): OAuthCredentialProviderCatalogEntry {
+ const provider = catalog.find(
+ (entry): entry is OAuthCredentialProviderCatalogEntry =>
+ entry.type === 'oauth' &&
+ entry.authorizationOptions.some((option) => option.providerId === providerId)
+ )
+ if (!provider) {
+ throw new OrchestrationError('validation', `Unknown OAuth provider: ${providerId}`)
+ }
+ if (!provider.available) {
+ throw new OrchestrationError('conflict', `OAuth provider is unavailable: ${providerId}`)
+ }
+ return provider
+}
+
+export function requireAvailableServiceAccountCredentialProvider(
+ catalog: readonly CredentialProviderCatalogEntry[],
+ providerId: string
+): ServiceAccountCredentialProviderCatalogEntry {
+ const provider = catalog.find(
+ (entry): entry is ServiceAccountCredentialProviderCatalogEntry =>
+ entry.type === 'service_account' && entry.providerId === providerId
+ )
+ if (!provider) {
+ throw new OrchestrationError('validation', `Unknown service-account provider: ${providerId}`)
+ }
+ if (!provider.available) {
+ throw new OrchestrationError(
+ 'conflict',
+ `Service-account provider is unavailable: ${providerId}`
+ )
+ }
+ return provider
+}
diff --git a/apps/sim/lib/credentials/application/service-account.test.ts b/apps/sim/lib/credentials/application/service-account.test.ts
new file mode 100644
index 00000000000..f4b00811eda
--- /dev/null
+++ b/apps/sim/lib/credentials/application/service-account.test.ts
@@ -0,0 +1,278 @@
+/**
+ * @vitest-environment node
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { OrchestrationError } from '@/lib/core/orchestration/types'
+
+const mocks = vi.hoisted(() => ({
+ loadWorkspace: vi.fn(),
+ resolvePermission: vi.fn(),
+ create: vi.fn(),
+ listCatalog: vi.fn(),
+ requireProvider: vi.fn(),
+ getCredential: vi.fn(),
+ getActor: vi.fn(),
+ delete: vi.fn(),
+ capture: vi.fn(),
+}))
+
+vi.mock('@/lib/workspaces/application/workspace-context', () => ({
+ loadActiveWorkspaceApplicationContext: mocks.loadWorkspace,
+}))
+vi.mock('@sim/platform-authz/workspace', () => ({
+ permissionSatisfies: (permission: string | null, required: string) =>
+ permission === 'admin' || permission === 'write' || permission === required,
+ resolveEffectiveWorkspacePermission: mocks.resolvePermission,
+}))
+vi.mock('@/lib/credentials/orchestration', () => ({
+ createServiceAccountCredential: mocks.create,
+ deleteConnectionCredential: mocks.delete,
+}))
+vi.mock('@/lib/credentials/application/provider-catalog', () => ({
+ listCredentialProviderCatalog: mocks.listCatalog,
+ requireAvailableServiceAccountCredentialProvider: mocks.requireProvider,
+}))
+vi.mock('@/lib/credentials/queries', () => ({
+ getWorkspaceCredential: mocks.getCredential,
+}))
+vi.mock('@/lib/credentials/access', () => ({
+ getCredentialActorContext: mocks.getActor,
+}))
+vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture }))
+
+import {
+ createServiceAccountCredentialUseCase,
+ deleteCredentialUseCase,
+} from '@/lib/credentials/application/service-account'
+
+const WORKSPACE_ID = 'workspace-1'
+const workspace = {
+ workspaceId: WORKSPACE_ID,
+ workspaceOrganizationId: null,
+ allowPersonalApiKeys: true,
+ billedAccountUserId: 'billing-owner-1',
+}
+const principal = {
+ kind: 'personal_api_key' as const,
+ userId: 'user-1',
+ keyId: 'key-1',
+}
+const credential = {
+ id: 'credential-1',
+ workspaceId: WORKSPACE_ID,
+ type: 'service_account' as const,
+ displayName: 'Zoom account',
+ description: null,
+ providerId: 'zoom-service-account',
+ accountId: null,
+ envKey: null,
+ envOwnerUserId: null,
+ encryptedServiceAccountKey: 'encrypted',
+ createdBy: 'user-1',
+ createdAt: new Date('2026-08-12T20:00:00.000Z'),
+ updatedAt: new Date('2026-08-12T20:00:00.000Z'),
+}
+
+describe('credential service-account application operations', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.loadWorkspace.mockResolvedValue(workspace)
+ mocks.resolvePermission.mockResolvedValue('write')
+ mocks.getCredential.mockResolvedValue(credential)
+ mocks.getActor.mockResolvedValue({
+ credential,
+ member: { role: 'admin' },
+ hasWorkspaceAccess: true,
+ isAdmin: true,
+ })
+ mocks.create.mockResolvedValue({
+ success: true,
+ credential,
+ created: true,
+ auditMetadata: { tenantId: 'tenant-1' },
+ })
+ mocks.listCatalog.mockResolvedValue([{ providerId: 'zoom-service-account' }])
+ mocks.delete.mockResolvedValue(true)
+ mocks.requireProvider.mockReturnValue({
+ type: 'service_account',
+ providerId: 'zoom-service-account',
+ available: true,
+ })
+ })
+
+ it('rejects workspace keys before canonical loading on create', async () => {
+ await expect(
+ createServiceAccountCredentialUseCase.execute({
+ principal: {
+ kind: 'workspace_api_key',
+ workspaceId: WORKSPACE_ID,
+ keyId: 'key-1',
+ },
+ input: {
+ workspaceId: WORKSPACE_ID,
+ type: 'service_account',
+ providerId: 'zoom-service-account',
+ clientId: 'client-id',
+ clientSecret: 'client-secret',
+ orgId: 'account-id',
+ },
+ })
+ ).rejects.toMatchObject({ code: 'forbidden' })
+ expect(mocks.loadWorkspace).not.toHaveBeenCalled()
+ expect(mocks.create).not.toHaveBeenCalled()
+ })
+
+ it('creates through the verified service-account primitive', async () => {
+ const result = await createServiceAccountCredentialUseCase.execute({
+ principal,
+ input: {
+ workspaceId: WORKSPACE_ID,
+ type: 'service_account',
+ providerId: 'zoom-service-account',
+ clientId: 'client-id',
+ clientSecret: 'client-secret',
+ orgId: 'account-id',
+ },
+ })
+
+ expect(result).toMatchObject({
+ credential,
+ created: true,
+ hasServiceAccountKey: true,
+ role: 'admin',
+ })
+ expect(mocks.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ workspaceId: WORKSPACE_ID,
+ userId: 'user-1',
+ providerId: 'zoom-service-account',
+ })
+ )
+ })
+
+ it('rejects service-account providers hidden by workspace policy', async () => {
+ mocks.requireProvider.mockImplementation(() => {
+ throw new OrchestrationError(
+ 'conflict',
+ 'Service-account provider is unavailable: zoom-service-account'
+ )
+ })
+
+ await expect(
+ createServiceAccountCredentialUseCase.execute({
+ principal,
+ input: {
+ workspaceId: WORKSPACE_ID,
+ type: 'service_account',
+ providerId: 'zoom-service-account',
+ clientId: 'client-id',
+ clientSecret: 'client-secret',
+ orgId: 'account-id',
+ },
+ })
+ ).rejects.toMatchObject({ code: 'conflict' })
+ expect(mocks.create).not.toHaveBeenCalled()
+ })
+
+ it('requires credential admin access before disconnecting', async () => {
+ mocks.getActor.mockResolvedValue({
+ credential,
+ member: { role: 'member' },
+ hasWorkspaceAccess: true,
+ isAdmin: false,
+ })
+
+ await expect(
+ deleteCredentialUseCase.execute({
+ principal,
+ input: { workspaceId: WORKSPACE_ID, credentialId: credential.id },
+ })
+ ).rejects.toMatchObject({
+ code: 'forbidden',
+ detailCode: 'CREDENTIAL_ADMIN_ACCESS_REQUIRED',
+ })
+ expect(mocks.delete).not.toHaveBeenCalled()
+ })
+
+ it('rejects workspace keys before canonical loading on disconnect', async () => {
+ await expect(
+ deleteCredentialUseCase.execute({
+ principal: {
+ kind: 'workspace_api_key',
+ workspaceId: WORKSPACE_ID,
+ keyId: 'key-1',
+ },
+ input: { workspaceId: WORKSPACE_ID, credentialId: credential.id },
+ })
+ ).rejects.toMatchObject({
+ code: 'forbidden',
+ detailCode: 'WORKSPACE_KEY_OPERATION_NOT_PERMITTED',
+ })
+ expect(mocks.loadWorkspace).not.toHaveBeenCalled()
+ expect(mocks.getActor).not.toHaveBeenCalled()
+ expect(mocks.delete).not.toHaveBeenCalled()
+ })
+
+ it('allows an explicit credential admin with workspace read access to disconnect', async () => {
+ mocks.resolvePermission.mockResolvedValue('read')
+
+ await expect(
+ deleteCredentialUseCase.execute({
+ principal,
+ input: { workspaceId: WORKSPACE_ID, credentialId: credential.id },
+ })
+ ).resolves.toEqual({ credential, deleted: true })
+ expect(mocks.delete).toHaveBeenCalledOnce()
+ })
+
+ it('applies credential admin policy during authorization-only checks', async () => {
+ await deleteCredentialUseCase.authorize?.({
+ principal,
+ input: { workspaceId: WORKSPACE_ID, credentialId: credential.id },
+ })
+
+ expect(mocks.getActor).toHaveBeenCalledWith(credential.id, principal.userId)
+ expect(mocks.delete).not.toHaveBeenCalled()
+ })
+
+ it('enforces personal-key workspace policy before credential authorization', async () => {
+ mocks.loadWorkspace.mockResolvedValue({ ...workspace, allowPersonalApiKeys: false })
+
+ await expect(
+ deleteCredentialUseCase.execute({
+ principal,
+ input: { workspaceId: WORKSPACE_ID, credentialId: credential.id },
+ })
+ ).rejects.toMatchObject({
+ code: 'forbidden',
+ detailCode: 'PERSONAL_API_KEYS_DISABLED',
+ })
+ expect(mocks.getActor).not.toHaveBeenCalled()
+ expect(mocks.delete).not.toHaveBeenCalled()
+ })
+
+ it('disconnects an administered credential', async () => {
+ const result = await deleteCredentialUseCase.execute({
+ principal,
+ input: { workspaceId: WORKSPACE_ID, credentialId: credential.id },
+ })
+
+ expect(result).toEqual({ credential, deleted: true })
+ expect(mocks.delete).toHaveBeenCalledWith({
+ credentialId: credential.id,
+ workspaceId: WORKSPACE_ID,
+ reason: 'user_delete',
+ })
+ })
+
+ it('treats a concurrent disconnect as an idempotent success', async () => {
+ mocks.delete.mockResolvedValue(false)
+
+ const result = await deleteCredentialUseCase.execute({
+ principal,
+ input: { workspaceId: WORKSPACE_ID, credentialId: credential.id },
+ })
+
+ expect(result).toEqual({ credential, deleted: false })
+ })
+})
diff --git a/apps/sim/lib/credentials/application/service-account.ts b/apps/sim/lib/credentials/application/service-account.ts
new file mode 100644
index 00000000000..d72c1911864
--- /dev/null
+++ b/apps/sim/lib/credentials/application/service-account.ts
@@ -0,0 +1,203 @@
+import { AuditAction, AuditResourceType } from '@sim/audit'
+import type { Principal } from '@sim/auth/principal'
+import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application'
+import { ForbiddenOperationError } from '@/lib/core/application/forbidden'
+import { OrchestrationError } from '@/lib/core/orchestration/types'
+import { HttpError } from '@/lib/core/utils/http-error'
+import { getCredentialActorContext } from '@/lib/credentials/access'
+import {
+ type CredentialAuthorizationContext,
+ defineAuthorizedCredentialUseCase,
+} from '@/lib/credentials/application/authorized-credential-use-case'
+import { credentialOperations } from '@/lib/credentials/application/operations'
+import {
+ listCredentialProviderCatalog,
+ requireAvailableServiceAccountCredentialProvider,
+} from '@/lib/credentials/application/provider-catalog'
+import {
+ type CreateServiceAccountCredentialParams,
+ createServiceAccountCredential,
+ deleteConnectionCredential,
+} from '@/lib/credentials/orchestration'
+import { type CredentialRow, getWorkspaceCredential } from '@/lib/credentials/queries'
+import { captureServerEvent } from '@/lib/posthog/server'
+import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context'
+
+export type CreateServiceAccountInput = Omit<
+ CreateServiceAccountCredentialParams,
+ 'userId' | 'request'
+>
+
+export interface CreateServiceAccountResult {
+ credential: CredentialRow
+ created: boolean
+ hasServiceAccountKey: boolean
+ role: 'admin' | 'member'
+ auditMetadata: Record
+}
+
+class CredentialProviderUnavailableError extends HttpError {
+ readonly statusCode = 503
+
+ constructor() {
+ super('Credential provider is temporarily unavailable')
+ this.name = 'CredentialProviderUnavailableError'
+ }
+}
+
+function principalUserId(principal: Extract): string {
+ return principal.userId
+}
+
+export const createServiceAccountCredentialUseCase = defineAuthorizedWorkspaceUseCase({
+ operation: credentialOperations.createServiceAccount,
+ resolveContext: async ({ input }: { input: CreateServiceAccountInput }) => {
+ const context = await loadActiveWorkspaceApplicationContext(input.workspaceId)
+ if (!context) throw new OrchestrationError('not_found', 'Workspace not found')
+ return context
+ },
+ authorizationOptions: {},
+ async execute({ principal, input, context, request }): Promise {
+ const catalog = await listCredentialProviderCatalog(principal, context)
+ requireAvailableServiceAccountCredentialProvider(catalog, input.providerId)
+ const result = await createServiceAccountCredential({
+ ...input,
+ workspaceId: context.workspaceId,
+ userId: principalUserId(principal),
+ request,
+ })
+ if (!result.success) {
+ if (result.providerUnavailable) throw new CredentialProviderUnavailableError()
+ switch (result.errorCode) {
+ case 'validation':
+ case 'not_found':
+ case 'conflict':
+ throw new OrchestrationError(result.errorCode, result.error ?? 'Credential create failed')
+ case 'forbidden':
+ throw new ForbiddenOperationError(
+ 'INSUFFICIENT_WORKSPACE_ROLE',
+ result.error ?? 'Write permission required'
+ )
+ default:
+ throw new Error('Failed to create service-account credential')
+ }
+ }
+ if (!result.credential) {
+ throw new Error('Credential creation succeeded without a credential')
+ }
+ const actor = await getCredentialActorContext(result.credential.id, principalUserId(principal))
+ if (!actor.credential || (!actor.member && !actor.isAdmin)) {
+ throw new Error('Created credential is not visible to its creator')
+ }
+ return {
+ credential: result.credential,
+ created: result.created === true,
+ hasServiceAccountKey: Boolean(result.credential.encryptedServiceAccountKey),
+ role: actor.isAdmin ? 'admin' : 'member',
+ auditMetadata: result.auditMetadata ?? {},
+ }
+ },
+ projectAudit: ({ result }) =>
+ result.created
+ ? {
+ action: AuditAction.CREDENTIAL_CREATED,
+ resourceType: AuditResourceType.CREDENTIAL,
+ resourceId: result.credential.id,
+ resourceName: result.credential.displayName,
+ description: `Created service_account credential "${result.credential.displayName}"`,
+ metadata: {
+ ...result.auditMetadata,
+ credentialType: result.credential.type,
+ providerId: result.credential.providerId,
+ },
+ }
+ : [],
+ afterSuccess: ({ principal, context, result }) => {
+ if (!result.created) return
+ captureServerEvent(
+ principalUserId(principal),
+ 'credential_connected',
+ {
+ credential_type: 'service_account',
+ provider_id: result.credential.providerId ?? 'service_account',
+ workspace_id: context.workspaceId,
+ },
+ {
+ groups: { workspace: context.workspaceId },
+ setOnce: { first_credential_connected_at: new Date().toISOString() },
+ }
+ )
+ },
+})
+
+interface CredentialApplicationContext extends CredentialAuthorizationContext {
+ billedAccountUserId: string
+}
+
+export interface DeleteCredentialInput {
+ workspaceId: string
+ credentialId: string
+}
+
+export interface DeleteCredentialResult {
+ credential: CredentialRow
+ deleted: boolean
+}
+
+async function resolveCredentialContext(
+ input: DeleteCredentialInput
+): Promise {
+ const workspace = await loadActiveWorkspaceApplicationContext(input.workspaceId)
+ if (!workspace) throw new OrchestrationError('not_found', 'Credential not found')
+ const credential = await getWorkspaceCredential({
+ workspaceId: workspace.workspaceId,
+ credentialId: input.credentialId,
+ })
+ if (!credential || !['oauth', 'service_account'].includes(credential.type)) {
+ throw new OrchestrationError('not_found', 'Credential not found')
+ }
+ return { ...workspace, credential }
+}
+
+export const deleteCredentialUseCase = defineAuthorizedCredentialUseCase({
+ operation: credentialOperations.delete,
+ resolveContext: async ({ input }: { input: DeleteCredentialInput }) =>
+ resolveCredentialContext(input),
+ async execute({ input, context }): Promise {
+ const deleted = await deleteConnectionCredential({
+ credentialId: input.credentialId,
+ workspaceId: context.workspaceId,
+ reason: 'user_delete',
+ })
+ return { credential: context.credential, deleted }
+ },
+ projectAudit: ({ result }) =>
+ result.deleted
+ ? {
+ action: AuditAction.CREDENTIAL_DELETED,
+ resourceType: AuditResourceType.CREDENTIAL,
+ resourceId: result.credential.id,
+ resourceName: result.credential.displayName,
+ description: `Deleted ${result.credential.type} credential "${result.credential.displayName}" (user_delete)`,
+ metadata: {
+ reason: 'user_delete',
+ credentialType: result.credential.type,
+ providerId: result.credential.providerId,
+ accountId: result.credential.accountId,
+ },
+ }
+ : [],
+ afterSuccess: ({ principal, context, result }) => {
+ if (!result.deleted) return
+ captureServerEvent(
+ principalUserId(principal),
+ 'credential_deleted',
+ {
+ credential_type: result.credential.type as 'oauth' | 'service_account',
+ provider_id: result.credential.providerId ?? result.credential.id,
+ workspace_id: context.workspaceId,
+ },
+ { groups: { workspace: context.workspaceId } }
+ )
+ },
+})
diff --git a/apps/sim/lib/credentials/connect-draft.test.ts b/apps/sim/lib/credentials/connect-draft.test.ts
new file mode 100644
index 00000000000..539a7cac8c5
--- /dev/null
+++ b/apps/sim/lib/credentials/connect-draft.test.ts
@@ -0,0 +1,83 @@
+/**
+ * @vitest-environment node
+ */
+import { dbChainMockFns, drizzleOrmMock, resetDbChainMock, schemaMock } from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockGenerateId } = vi.hoisted(() => ({
+ mockGenerateId: vi.fn(),
+}))
+
+vi.mock('@sim/utils/id', () => ({ generateId: mockGenerateId }))
+
+import { createConnectDraft } from '@/lib/credentials/connect-draft'
+
+describe('createConnectDraft', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ resetDbChainMock()
+ mockGenerateId.mockReturnValue('new-draft-id')
+ })
+
+ it('refreshes the expiry without changing an active connection intent', async () => {
+ const expiresAt = new Date('2026-08-13T20:15:00.000Z')
+ dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'active-draft-id', expiresAt }])
+
+ const result = await createConnectDraft({
+ userId: 'user-1',
+ workspaceId: 'workspace-1',
+ providerId: 'google-email',
+ displayName: 'Work Gmail',
+ displayNameDefinesIntent: true,
+ })
+
+ expect(dbChainMockFns.values).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 'new-draft-id' })
+ )
+ const conflict = dbChainMockFns.onConflictDoUpdate.mock.calls[0]?.[0] as
+ | { set?: Record; setWhere?: unknown }
+ | undefined
+ expect(conflict?.set).not.toHaveProperty('id')
+ expect(conflict?.set).not.toHaveProperty('displayName')
+ expect(conflict?.set).not.toHaveProperty('credentialId')
+ expect(conflict?.setWhere).toBeDefined()
+ expect(result).toEqual({ id: 'active-draft-id', expiresAt })
+ })
+
+ it('refreshes a reconnect target when its mutable display name changes', async () => {
+ const expiresAt = new Date('2026-08-13T20:15:00.000Z')
+ dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'active-draft-id', expiresAt }])
+
+ await expect(
+ createConnectDraft({
+ userId: 'user-1',
+ workspaceId: 'workspace-1',
+ providerId: 'google-email',
+ credentialId: 'credential-1',
+ displayName: 'Renamed Gmail',
+ })
+ ).resolves.toEqual({ id: 'active-draft-id', expiresAt })
+
+ expect(drizzleOrmMock.eq).not.toHaveBeenCalledWith(
+ schemaMock.pendingCredentialDraft.displayName,
+ 'Renamed Gmail'
+ )
+ })
+
+ it('fails fast when an active draft has a different connection intent', async () => {
+ dbChainMockFns.returning.mockResolvedValueOnce([])
+
+ await expect(
+ createConnectDraft({
+ userId: 'user-1',
+ workspaceId: 'workspace-1',
+ providerId: 'google-email',
+ credentialId: 'credential-1',
+ displayName: 'Existing Gmail',
+ })
+ ).rejects.toMatchObject({
+ code: 'conflict',
+ message: 'A different OAuth connection flow is already active for this provider',
+ })
+ })
+})
diff --git a/apps/sim/lib/credentials/connect-draft.ts b/apps/sim/lib/credentials/connect-draft.ts
index 2e72f796526..6c2565e3d55 100644
--- a/apps/sim/lib/credentials/connect-draft.ts
+++ b/apps/sim/lib/credentials/connect-draft.ts
@@ -2,13 +2,21 @@ import { db } from '@sim/db'
import { credential, pendingCredentialDraft, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
-import { and, eq, lt } from 'drizzle-orm'
+import { and, eq, gt, isNull, lt } from 'drizzle-orm'
+import { OrchestrationError } from '@/lib/core/orchestration/types'
import { defaultCredentialDisplayName } from '@/lib/credentials/display-name'
import { credentialProviderMatchesService, getAllOAuthServices } from '@/lib/oauth/utils'
const logger = createLogger('OAuthConnectDraft')
const DRAFT_TTL_MS = 15 * 60 * 1000
+export type ConnectDraft = typeof pendingCredentialDraft.$inferSelect
+
+export interface CreatedConnectDraft {
+ id: string
+ expiresAt: Date
+}
+
/**
* Creates the pending credential draft at OAuth click time so custom and
* generic OAuth callbacks can materialize the connected workspace credential.
@@ -21,7 +29,9 @@ export async function createConnectDraft(params: {
credentialId?: string
/** Reconnect only: the credential's actual name, so audit records stay accurate. */
displayName?: string
-}): Promise {
+ /** Whether an explicitly requested name distinguishes this new-connection intent. */
+ displayNameDefinesIntent?: boolean
+}): Promise {
const { userId, workspaceId, providerId, credentialId } = params
let displayName = params.displayName
@@ -32,42 +42,21 @@ export async function createConnectDraft(params: {
const service = getAllOAuthServices().find((s) =>
credentialProviderMatchesService(providerId, s)
)
- const serviceName = service?.name ?? providerId
+ if (!service) throw new Error(`Cannot create OAuth draft for unknown provider ${providerId}`)
+ const serviceName = service.name
- let userName: string | null = null
- try {
- const [row] = await db.select({ name: user.name }).from(user).where(eq(user.id, userId))
- userName = row?.name ?? null
- } catch (error) {
- // Cosmetic only — fall back to the "My {Service}" default
- logger.warn('User name lookup failed for connect draft display name', {
- userId,
- workspaceId,
- providerId,
- error,
- })
- }
+ const [row] = await db.select({ name: user.name }).from(user).where(eq(user.id, userId))
+ if (!row) throw new Error(`Cannot create OAuth draft for missing user ${userId}`)
+ const userName = row.name
// Auto-number against existing workspace credentials so repeat connects for
// the same provider stay distinguishable — same behavior as the connect
- // modal, which computes this client-side. Best effort: on failure the name
- // simply skips deduplication.
- let takenNames: ReadonlySet = new Set()
- try {
- const rows = await db
- .select({ displayName: credential.displayName })
- .from(credential)
- .where(and(eq(credential.workspaceId, workspaceId), eq(credential.type, 'oauth')))
- takenNames = new Set(rows.map((row) => row.displayName.toLowerCase()))
- } catch (error) {
- // Cosmetic only — proceed without collision numbering
- logger.warn('Credential name lookup failed for connect draft deduplication', {
- userId,
- workspaceId,
- providerId,
- error,
- })
- }
+ // modal, which computes this client-side.
+ const rows = await db
+ .select({ displayName: credential.displayName })
+ .from(credential)
+ .where(and(eq(credential.workspaceId, workspaceId), eq(credential.type, 'oauth')))
+ const takenNames = new Set(rows.map((credentialRow) => credentialRow.displayName.toLowerCase()))
displayName = defaultCredentialDisplayName(userName, serviceName, takenNames)
}
@@ -79,10 +68,17 @@ export async function createConnectDraft(params: {
.where(
and(eq(pendingCredentialDraft.userId, userId), lt(pendingCredentialDraft.expiresAt, now))
)
- await db
+ const id = generateId()
+ const sameTarget = credentialId
+ ? eq(pendingCredentialDraft.credentialId, credentialId)
+ : isNull(pendingCredentialDraft.credentialId)
+ const sameIntent = params.displayNameDefinesIntent
+ ? and(sameTarget, eq(pendingCredentialDraft.displayName, displayName))
+ : sameTarget
+ const [draft] = await db
.insert(pendingCredentialDraft)
.values({
- id: generateId(),
+ id,
userId,
workspaceId,
providerId,
@@ -97,11 +93,17 @@ export async function createConnectDraft(params: {
pendingCredentialDraft.providerId,
pendingCredentialDraft.workspaceId,
],
- // credentialId must be written on BOTH paths: a plain connect that reuses a
- // stale reconnect draft row would otherwise silently rebind the old
- // credential instead of creating a new one.
- set: { displayName, credentialId: credentialId ?? null, expiresAt, createdAt: now },
+ set: { expiresAt, createdAt: now },
+ setWhere: sameIntent,
})
+ .returning({ id: pendingCredentialDraft.id, expiresAt: pendingCredentialDraft.expiresAt })
+
+ if (!draft) {
+ throw new OrchestrationError(
+ 'conflict',
+ 'A different OAuth connection flow is already active for this provider'
+ )
+ }
logger.info('Created OAuth connect credential draft', {
userId,
@@ -109,4 +111,23 @@ export async function createConnectDraft(params: {
providerId,
credentialId: credentialId ?? null,
})
+ return draft
+}
+
+export async function getActiveConnectDraft(
+ draftId: string,
+ userId: string
+): Promise {
+ const [draft] = await db
+ .select()
+ .from(pendingCredentialDraft)
+ .where(
+ and(
+ eq(pendingCredentialDraft.id, draftId),
+ eq(pendingCredentialDraft.userId, userId),
+ gt(pendingCredentialDraft.expiresAt, new Date())
+ )
+ )
+ .limit(1)
+ return draft ?? null
}
diff --git a/apps/sim/lib/credentials/deletion.ts b/apps/sim/lib/credentials/deletion.ts
index 618e51b0d1a..f16902ddf04 100644
--- a/apps/sim/lib/credentials/deletion.ts
+++ b/apps/sim/lib/credentials/deletion.ts
@@ -23,6 +23,12 @@ interface DeleteCredentialParams {
request?: NextRequest
}
+export interface DeleteConnectionCredentialParams {
+ credentialId: string
+ workspaceId: string
+ reason: CredentialDeleteReason
+}
+
/**
* Clears all stored references to the credential, deletes the row, and
* records an audit entry. Idempotent when the row no longer exists.
@@ -71,6 +77,30 @@ export async function deleteCredential(params: DeleteCredentialParams): Promise<
logger.info('Deleted credential', { credentialId, workspaceId: row.workspaceId, reason })
}
+/** Clears references and deletes one connection without surface audit attribution. */
+export async function deleteConnectionCredential(
+ params: DeleteConnectionCredentialParams
+): Promise {
+ const { credentialId, workspaceId } = params
+ await clearCredentialRefs(credentialId, workspaceId)
+ const deleted = await db
+ .delete(schema.credential)
+ .where(
+ and(eq(schema.credential.id, credentialId), eq(schema.credential.workspaceId, workspaceId))
+ )
+ .returning({ id: schema.credential.id })
+ if (deleted.length > 1) throw new Error('Credential deletion affected multiple rows')
+
+ if (deleted.length === 1) {
+ logger.info('Deleted credential', {
+ credentialId,
+ workspaceId,
+ reason: params.reason,
+ })
+ }
+ return deleted.length === 1
+}
+
/**
* Clears stored references to a credential across mutable workspace state
* (editor blocks, copilot checkpoints, knowledge connectors) and frozen
diff --git a/apps/sim/lib/credentials/draft-hooks.ts b/apps/sim/lib/credentials/draft-hooks.ts
index e467f56609e..c1aed407dca 100644
--- a/apps/sim/lib/credentials/draft-hooks.ts
+++ b/apps/sim/lib/credentials/draft-hooks.ts
@@ -121,10 +121,7 @@ export async function handleReconnectCredential(params: {
.limit(1)
if (!existingCredential) {
- logger.warn('Credential not found for reconnect, skipping', {
- credentialId: draft.credentialId,
- })
- return
+ throw new Error(`Cannot reconnect missing credential ${draft.credentialId}`)
}
const oldAccountId = existingCredential.accountId
@@ -144,12 +141,9 @@ export async function handleReconnectCredential(params: {
.limit(1)
if (conflicting) {
- logger.warn('New account already used by another credential, skipping reconnect', {
- credentialId: draft.credentialId,
- newAccountId,
- conflictingCredentialId: conflicting.id,
- })
- return
+ throw new Error(
+ `Cannot reconnect credential ${draft.credentialId}: account ${newAccountId} is already used by credential ${conflicting.id}`
+ )
}
}
diff --git a/apps/sim/lib/credentials/draft-processor.test.ts b/apps/sim/lib/credentials/draft-processor.test.ts
new file mode 100644
index 00000000000..9eb88bc1953
--- /dev/null
+++ b/apps/sim/lib/credentials/draft-processor.test.ts
@@ -0,0 +1,105 @@
+/**
+ * @vitest-environment node
+ */
+import {
+ dbChainMockFns,
+ drizzleOrmMock,
+ queueTableRows,
+ resetDbChainMock,
+ schemaMock,
+} from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockHandleCreateCredentialFromDraft, mockHandleReconnectCredential } = vi.hoisted(() => ({
+ mockHandleCreateCredentialFromDraft: vi.fn(),
+ mockHandleReconnectCredential: vi.fn(),
+}))
+
+vi.mock('@/lib/credentials/draft-hooks', () => ({
+ handleCreateCredentialFromDraft: mockHandleCreateCredentialFromDraft,
+ handleReconnectCredential: mockHandleReconnectCredential,
+}))
+
+import { processCredentialDraft } from '@/lib/credentials/draft-processor'
+
+function credentialDraft(id: string, workspaceId: string) {
+ return {
+ id,
+ userId: 'user-1',
+ workspaceId,
+ providerId: 'google-email',
+ displayName: 'Work Gmail',
+ description: null,
+ credentialId: null,
+ expiresAt: new Date('2026-08-14T18:15:00.000Z'),
+ createdAt: new Date('2026-08-14T18:00:00.000Z'),
+ }
+}
+
+describe('processCredentialDraft', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ resetDbChainMock()
+ })
+
+ it('processes only the exact draft bound to the OAuth state', async () => {
+ const draft = credentialDraft('draft-2', 'workspace-2')
+ queueTableRows(schemaMock.pendingCredentialDraft, [draft])
+
+ await processCredentialDraft({
+ draftId: 'draft-2',
+ userId: 'user-1',
+ providerId: 'google-email',
+ accountId: 'account-1',
+ })
+
+ expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.pendingCredentialDraft.id, 'draft-2')
+ expect(mockHandleCreateCredentialFromDraft).toHaveBeenCalledWith({
+ draft,
+ accountId: 'account-1',
+ providerId: 'google-email',
+ userId: 'user-1',
+ now: expect.any(Date),
+ })
+ expect(dbChainMockFns.delete).toHaveBeenCalledWith(schemaMock.pendingCredentialDraft)
+ })
+
+ it('fails closed when a legacy callback has multiple active drafts', async () => {
+ queueTableRows(schemaMock.pendingCredentialDraft, [
+ credentialDraft('draft-1', 'workspace-1'),
+ credentialDraft('draft-2', 'workspace-2'),
+ ])
+
+ await expect(
+ processCredentialDraft({
+ userId: 'user-1',
+ providerId: 'google-email',
+ accountId: 'account-1',
+ })
+ ).rejects.toThrow(
+ 'Cannot process an ambiguous OAuth credential draft for user user-1 and provider google-email'
+ )
+
+ expect(mockHandleCreateCredentialFromDraft).not.toHaveBeenCalled()
+ expect(mockHandleReconnectCredential).not.toHaveBeenCalled()
+ })
+
+ it('fails when an exact draft is missing or expired', async () => {
+ queueTableRows(schemaMock.pendingCredentialDraft, [])
+
+ await expect(
+ processCredentialDraft({
+ draftId: 'draft-missing',
+ userId: 'user-1',
+ providerId: 'google-email',
+ accountId: 'account-1',
+ })
+ ).rejects.toThrow(
+ 'Cannot process missing or expired OAuth credential draft draft-missing for user user-1'
+ )
+
+ expect(mockHandleCreateCredentialFromDraft).not.toHaveBeenCalled()
+ expect(mockHandleReconnectCredential).not.toHaveBeenCalled()
+ expect(dbChainMockFns.delete).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/lib/credentials/draft-processor.ts b/apps/sim/lib/credentials/draft-processor.ts
index b7b9f5cd931..fc3637845f0 100644
--- a/apps/sim/lib/credentials/draft-processor.ts
+++ b/apps/sim/lib/credentials/draft-processor.ts
@@ -1,7 +1,7 @@
import { db } from '@sim/db'
import * as schema from '@sim/db/schema'
import { createLogger } from '@sim/logger'
-import { and, eq, sql } from 'drizzle-orm'
+import { and, desc, eq, sql } from 'drizzle-orm'
import {
handleCreateCredentialFromDraft,
handleReconnectCredential,
@@ -10,32 +10,54 @@ import {
const logger = createLogger('CredentialDraftProcessor')
interface ProcessCredentialDraftParams {
+ draftId?: string
userId: string
providerId: string
accountId: string
}
/**
- * Looks up a pending credential draft for the given user/provider and processes it.
+ * Looks up a pending credential draft and processes it.
+ * Draft-backed OAuth launches pass the exact id. Legacy callers without one are
+ * accepted only when the user/provider pair has a single active draft.
* Creates a new credential or reconnects an existing one depending on the draft state.
* Used by Better Auth's `account.create.after` hook and custom OAuth flows (Shopify, Trello).
*/
export async function processCredentialDraft(params: ProcessCredentialDraftParams): Promise {
- const { userId, providerId, accountId } = params
+ const { draftId, userId, providerId, accountId } = params
- const [draft] = await db
+ const predicates = [
+ eq(schema.pendingCredentialDraft.userId, userId),
+ eq(schema.pendingCredentialDraft.providerId, providerId),
+ sql`${schema.pendingCredentialDraft.expiresAt} > NOW()`,
+ ]
+ if (draftId) {
+ predicates.push(eq(schema.pendingCredentialDraft.id, draftId))
+ }
+
+ const drafts = await db
.select()
.from(schema.pendingCredentialDraft)
- .where(
- and(
- eq(schema.pendingCredentialDraft.userId, userId),
- eq(schema.pendingCredentialDraft.providerId, providerId),
- sql`${schema.pendingCredentialDraft.expiresAt} > NOW()`
- )
+ .where(and(...predicates))
+ .orderBy(desc(schema.pendingCredentialDraft.createdAt))
+ .limit(draftId ? 1 : 2)
+
+ if (!draftId && drafts.length > 1) {
+ throw new Error(
+ `Cannot process an ambiguous OAuth credential draft for user ${userId} and provider ${providerId}`
)
- .limit(1)
+ }
+
+ const [draft] = drafts
- if (!draft) return
+ if (!draft) {
+ if (draftId) {
+ throw new Error(
+ `Cannot process missing or expired OAuth credential draft ${draftId} for user ${userId}`
+ )
+ }
+ return
+ }
const now = new Date()
diff --git a/apps/sim/lib/credentials/oauth-draft-state.ts b/apps/sim/lib/credentials/oauth-draft-state.ts
new file mode 100644
index 00000000000..3dd068a1bed
--- /dev/null
+++ b/apps/sim/lib/credentials/oauth-draft-state.ts
@@ -0,0 +1 @@
+export const OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM = 'credentialDraftId'
diff --git a/apps/sim/lib/credentials/orchestration/credential-create.ts b/apps/sim/lib/credentials/orchestration/credential-create.ts
index 6120e5bec76..ecda5d7d249 100644
--- a/apps/sim/lib/credentials/orchestration/credential-create.ts
+++ b/apps/sim/lib/credentials/orchestration/credential-create.ts
@@ -5,9 +5,9 @@ import { createLogger } from '@sim/logger'
import { getPostgresErrorCode } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { and, eq } from 'drizzle-orm'
-import type { NextRequest } from 'next/server'
import { normalizeCredentialEnvKey } from '@/lib/api/contracts/credentials'
import { acquireOrganizationUserMutationLocks } from '@/lib/billing/organizations/membership'
+import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types'
import { getCredentialActorContext } from '@/lib/credentials/access'
import { AtlassianValidationError } from '@/lib/credentials/atlassian-service-account'
import { getCredentialCreationWorkspaceContext } from '@/lib/credentials/environment'
@@ -82,7 +82,7 @@ export interface PerformCreateCredentialParams {
* secrets exist, so the id must be known up front.
*/
id?: string
- request?: NextRequest
+ request?: OrchestrationRequestContext
}
export interface PerformCreateCredentialResult {
@@ -96,6 +96,8 @@ export interface PerformCreateCredentialResult {
credential?: CredentialRow
/** False when an existing credential matched the source and was returned instead. */
created?: boolean
+ /** Verified provider identity metadata for the application audit projection. */
+ auditMetadata?: Record
}
interface ExistingCredentialSourceParams {
@@ -191,14 +193,17 @@ function failure(
return { success: false, error, errorCode, ...extra }
}
-export async function performCreateCredential(
- params: PerformCreateCredentialParams
+async function createCredentialRecord(
+ params: PerformCreateCredentialParams,
+ options: { authorizeWorkspace: boolean }
): Promise {
const { workspaceId, type, userId } = params
try {
- const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId)
- if (!workspaceAccess.canWrite) {
+ const workspaceAccess = options.authorizeWorkspace
+ ? await checkWorkspaceAccess(workspaceId, userId)
+ : undefined
+ if (workspaceAccess && !workspaceAccess.canWrite) {
return failure('Write permission required', 'forbidden')
}
@@ -334,7 +339,7 @@ export async function performCreateCredential(
}
const access = await getCredentialActorContext(existingCredential.id, userId, {
- workspaceAccess,
+ ...(workspaceAccess ? { workspaceAccess } : {}),
})
if (!access.member && !access.isAdmin) {
@@ -486,37 +491,7 @@ export async function performCreateCredential(
.where(eq(credential.id, credentialId))
.limit(1)
- captureServerEvent(
- userId,
- 'credential_connected',
- { credential_type: type, provider_id: resolvedProviderId ?? type, workspace_id: workspaceId },
- {
- groups: { workspace: workspaceId },
- setOnce: { first_credential_connected_at: new Date().toISOString() },
- }
- )
-
- recordAudit({
- workspaceId,
- actorId: userId,
- actorName: params.actorName ?? undefined,
- actorEmail: params.actorEmail ?? undefined,
- action: AuditAction.CREDENTIAL_CREATED,
- resourceType: AuditResourceType.CREDENTIAL,
- resourceId: credentialId,
- resourceName: resolvedDisplayName,
- description: `Created ${type} credential "${resolvedDisplayName}"`,
- metadata: {
- // Provider metadata spreads first so this path's own keys stay
- // authoritative and can never be shadowed, matching the update path.
- ...extraAuditMetadata,
- credentialType: type,
- providerId: resolvedProviderId,
- },
- request: params.request,
- })
-
- return { success: true, credential: created, created: true }
+ return { success: true, credential: created, created: true, auditMetadata: extraAuditMetadata }
} catch (error: unknown) {
if (error instanceof AtlassianValidationError) {
logger.warn(`Atlassian credential rejected: ${error.code}`, {
@@ -572,6 +547,64 @@ export async function performCreateCredential(
}
}
+export type CreateServiceAccountCredentialParams = Omit<
+ PerformCreateCredentialParams,
+ 'type' | 'actorName' | 'actorEmail'
+> & { providerId: string }
+
+/** Creates and verifies one service-account credential without surface side effects. */
+export function createServiceAccountCredential(
+ params: CreateServiceAccountCredentialParams
+): Promise {
+ return createCredentialRecord(
+ { ...params, type: 'service_account' },
+ { authorizeWorkspace: false }
+ )
+}
+
+/** Preserves the legacy internal surface's analytics and audit behavior. */
+export async function performCreateCredential(
+ params: PerformCreateCredentialParams
+): Promise {
+ const result = await createCredentialRecord(params, { authorizeWorkspace: true })
+ if (!result.success || !result.created) return result
+ if (!result.credential) throw new Error('Credential creation succeeded without a credential')
+
+ captureServerEvent(
+ params.userId,
+ 'credential_connected',
+ {
+ credential_type: result.credential.type,
+ provider_id: result.credential.providerId ?? result.credential.type,
+ workspace_id: result.credential.workspaceId,
+ },
+ {
+ groups: { workspace: result.credential.workspaceId },
+ setOnce: { first_credential_connected_at: new Date().toISOString() },
+ }
+ )
+
+ recordAudit({
+ workspaceId: result.credential.workspaceId,
+ actorId: params.userId,
+ actorName: params.actorName ?? undefined,
+ actorEmail: params.actorEmail ?? undefined,
+ action: AuditAction.CREDENTIAL_CREATED,
+ resourceType: AuditResourceType.CREDENTIAL,
+ resourceId: result.credential.id,
+ resourceName: result.credential.displayName,
+ description: `Created ${result.credential.type} credential "${result.credential.displayName}"`,
+ metadata: {
+ ...result.auditMetadata,
+ credentialType: result.credential.type,
+ providerId: result.credential.providerId,
+ },
+ request: params.request,
+ })
+
+ return result
+}
+
/**
* Provider error codes that mean the upstream service could not be reached,
* rather than that the caller's secret was rejected. Each provider family names
diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts
index f0dabc6795b..31afb8701d0 100644
--- a/apps/sim/lib/credentials/orchestration/index.ts
+++ b/apps/sim/lib/credentials/orchestration/index.ts
@@ -32,7 +32,10 @@ import { captureServerEvent } from '@/lib/posthog/server'
const logger = createLogger('CredentialOrchestration')
+export { deleteConnectionCredential } from '@/lib/credentials/deletion'
export {
+ type CreateServiceAccountCredentialParams,
+ createServiceAccountCredential,
isProviderOutageCode,
type PerformCreateCredentialParams,
type PerformCreateCredentialResult,
diff --git a/apps/sim/lib/integrations/credential-visibility.server.ts b/apps/sim/lib/integrations/credential-visibility.server.ts
index 8bb9720552d..1837aa67571 100644
--- a/apps/sim/lib/integrations/credential-visibility.server.ts
+++ b/apps/sim/lib/integrations/credential-visibility.server.ts
@@ -62,16 +62,21 @@ export function createIntegrationCredentialVisibility({
else ownersByProviderId.set(providerId, [service])
}
- for (const service of oauthOwners) {
- addOwner(oauthOwnersByProviderId, service.providerId, service)
- // A second authorization server for the same service (`salesforce-sandbox`)
- // issues ordinary OAuth credentials, so they own visibility exactly like
- // the primary provider's do.
- for (const extraProviderId of service.additionalProviderIds ?? []) {
- addOwner(oauthOwnersByProviderId, extraProviderId, service)
+ for (const service of oauthServices) {
+ if (service.authType === 'oauth') {
+ addOwner(oauthOwnersByProviderId, service.providerId, service)
+ // A second authorization server for the same service (`salesforce-sandbox`)
+ // issues ordinary OAuth credentials, so they own visibility exactly like
+ // the primary provider's do.
+ for (const extraProviderId of service.additionalProviderIds ?? []) {
+ addOwner(oauthOwnersByProviderId, extraProviderId, service)
+ }
}
- if (service.serviceAccountProviderId) {
- addOwner(serviceAccountOwnersByProviderId, service.serviceAccountProviderId, service)
+ const serviceAccountProviderId =
+ service.serviceAccountProviderId ??
+ (service.authType === 'service_account' ? service.providerId : undefined)
+ if (serviceAccountProviderId) {
+ addOwner(serviceAccountOwnersByProviderId, serviceAccountProviderId, service)
}
}
diff --git a/apps/sim/lib/oauth/shopify-state.test.ts b/apps/sim/lib/oauth/shopify-state.test.ts
new file mode 100644
index 00000000000..28a3a15595f
--- /dev/null
+++ b/apps/sim/lib/oauth/shopify-state.test.ts
@@ -0,0 +1,80 @@
+/**
+ * @vitest-environment node
+ */
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { createShopifyOAuthState, parseShopifyOAuthState } from '@/lib/oauth/shopify-state'
+
+const CLIENT_SECRET = 'shopify-client-secret'
+const USER_ID = 'user-1'
+const SHOP_DOMAIN = 'example.myshopify.com'
+
+function parse(
+ state: string,
+ overrides: { userId?: string; shopDomain?: string; now?: Date } = {}
+) {
+ return parseShopifyOAuthState({
+ state,
+ userId: overrides.userId ?? USER_ID,
+ shopDomain: overrides.shopDomain ?? SHOP_DOMAIN,
+ clientSecret: CLIENT_SECRET,
+ now: overrides.now,
+ })
+}
+
+describe('Shopify OAuth state', () => {
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('keeps overlapping connection drafts bound to their own state', () => {
+ const first = createShopifyOAuthState({
+ userId: USER_ID,
+ shopDomain: SHOP_DOMAIN,
+ draftId: 'draft-1',
+ clientSecret: CLIENT_SECRET,
+ })
+ const second = createShopifyOAuthState({
+ userId: USER_ID,
+ shopDomain: SHOP_DOMAIN,
+ draftId: 'draft-2',
+ clientSecret: CLIENT_SECRET,
+ })
+
+ expect(parse(first)).toEqual({ draftId: 'draft-1' })
+ expect(parse(second)).toEqual({ draftId: 'draft-2' })
+ })
+
+ it('rejects tampered, cross-user, and cross-shop state', () => {
+ const state = createShopifyOAuthState({
+ userId: USER_ID,
+ shopDomain: SHOP_DOMAIN,
+ draftId: 'draft-1',
+ clientSecret: CLIENT_SECRET,
+ })
+ const [payload, signature] = state.split('.')
+
+ expect(() => parse(`${payload}x.${signature}`)).toThrow(
+ 'Shopify OAuth state signature is invalid'
+ )
+ expect(() => parse(state, { userId: 'user-2' })).toThrow(
+ 'Shopify OAuth state belongs to a different user'
+ )
+ expect(() => parse(state, { shopDomain: 'other.myshopify.com' })).toThrow(
+ 'Shopify OAuth state belongs to a different shop'
+ )
+ })
+
+ it('rejects expired state', () => {
+ const issuedAt = new Date('2026-08-14T18:00:00.000Z')
+ vi.spyOn(Date, 'now').mockReturnValue(issuedAt.getTime())
+ const state = createShopifyOAuthState({
+ userId: USER_ID,
+ shopDomain: SHOP_DOMAIN,
+ clientSecret: CLIENT_SECRET,
+ })
+
+ expect(() => parse(state, { now: new Date(issuedAt.getTime() + 10 * 60 * 1000 + 1) })).toThrow(
+ 'Shopify OAuth state is expired'
+ )
+ })
+})
diff --git a/apps/sim/lib/oauth/shopify-state.ts b/apps/sim/lib/oauth/shopify-state.ts
new file mode 100644
index 00000000000..23d5f29d761
--- /dev/null
+++ b/apps/sim/lib/oauth/shopify-state.ts
@@ -0,0 +1,102 @@
+import { safeCompare } from '@sim/security/compare'
+import { hmacSha256Hex } from '@sim/security/hmac'
+import { generateId } from '@sim/utils/id'
+
+const SHOPIFY_OAUTH_STATE_VERSION = 1
+const SHOPIFY_OAUTH_STATE_TTL_MS = 10 * 60 * 1000
+
+interface ShopifyOAuthStatePayload {
+ v: typeof SHOPIFY_OAUTH_STATE_VERSION
+ nonce: string
+ userId: string
+ shopDomain: string
+ draftId?: string
+ issuedAt: number
+}
+
+interface CreateShopifyOAuthStateParams {
+ userId: string
+ shopDomain: string
+ draftId?: string
+ clientSecret: string
+}
+
+interface ParseShopifyOAuthStateParams {
+ state: string
+ userId: string
+ shopDomain: string
+ clientSecret: string
+ now?: Date
+}
+
+function isShopifyOAuthStatePayload(value: unknown): value is ShopifyOAuthStatePayload {
+ if (!value || typeof value !== 'object') return false
+ const payload = value as Record
+ return (
+ payload.v === SHOPIFY_OAUTH_STATE_VERSION &&
+ typeof payload.nonce === 'string' &&
+ payload.nonce.length > 0 &&
+ typeof payload.userId === 'string' &&
+ payload.userId.length > 0 &&
+ typeof payload.shopDomain === 'string' &&
+ payload.shopDomain.length > 0 &&
+ (payload.draftId === undefined ||
+ (typeof payload.draftId === 'string' && payload.draftId.length > 0)) &&
+ typeof payload.issuedAt === 'number' &&
+ Number.isSafeInteger(payload.issuedAt)
+ )
+}
+
+/** Creates a signed, user-bound Shopify state token carrying the exact credential draft. */
+export function createShopifyOAuthState(params: CreateShopifyOAuthStateParams): string {
+ const payload: ShopifyOAuthStatePayload = {
+ v: SHOPIFY_OAUTH_STATE_VERSION,
+ nonce: generateId(),
+ userId: params.userId,
+ shopDomain: params.shopDomain,
+ ...(params.draftId ? { draftId: params.draftId } : {}),
+ issuedAt: Date.now(),
+ }
+ const encoded = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url')
+ const signature = hmacSha256Hex(encoded, params.clientSecret)
+ return `${encoded}.${signature}`
+}
+
+/** Verifies Shopify state integrity, expiry, user ownership, and shop binding. */
+export function parseShopifyOAuthState(params: ParseShopifyOAuthStateParams): {
+ draftId?: string
+} {
+ const [encoded, signature, extra] = params.state.split('.')
+ if (!encoded || !signature || extra !== undefined) {
+ throw new Error('Shopify OAuth state is malformed')
+ }
+
+ const expectedSignature = hmacSha256Hex(encoded, params.clientSecret)
+ if (!safeCompare(signature, expectedSignature)) {
+ throw new Error('Shopify OAuth state signature is invalid')
+ }
+
+ let decoded: unknown
+ try {
+ decoded = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8'))
+ } catch {
+ throw new Error('Shopify OAuth state payload is invalid')
+ }
+ if (!isShopifyOAuthStatePayload(decoded)) {
+ throw new Error('Shopify OAuth state payload is invalid')
+ }
+
+ if (decoded.userId !== params.userId) {
+ throw new Error('Shopify OAuth state belongs to a different user')
+ }
+ if (decoded.shopDomain !== params.shopDomain) {
+ throw new Error('Shopify OAuth state belongs to a different shop')
+ }
+
+ const now = params.now?.getTime() ?? Date.now()
+ if (decoded.issuedAt > now || now - decoded.issuedAt > SHOPIFY_OAUTH_STATE_TTL_MS) {
+ throw new Error('Shopify OAuth state is expired')
+ }
+
+ return decoded.draftId ? { draftId: decoded.draftId } : {}
+}
diff --git a/apps/sim/lib/oauth/shopify.ts b/apps/sim/lib/oauth/shopify.ts
new file mode 100644
index 00000000000..29a7299b874
--- /dev/null
+++ b/apps/sim/lib/oauth/shopify.ts
@@ -0,0 +1,114 @@
+import { db } from '@sim/db'
+import { account } from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
+import { generateId } from '@sim/utils/id'
+import { and, eq } from 'drizzle-orm'
+import { processCredentialDraft } from '@/lib/credentials/draft-processor'
+import { safeAccountInsert } from '@/lib/oauth/credential-service'
+import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants'
+
+const logger = createLogger('ShopifyOAuth')
+
+interface CompleteShopifyOAuthConnectionParams {
+ accessToken: string
+ shopDomain: string
+ scope?: string
+ userId: string
+ draftId?: string
+ signal?: AbortSignal
+}
+
+function getShopifyAccountId(value: unknown): string {
+ if (!value || typeof value !== 'object') {
+ throw new Error('Shopify shop response must be an object')
+ }
+ const shop = (value as { shop?: unknown }).shop
+ if (!shop || typeof shop !== 'object') {
+ throw new Error('Shopify shop response is missing shop data')
+ }
+ const id = (shop as { id?: unknown }).id
+ if ((typeof id !== 'string' && typeof id !== 'number') || String(id).length === 0) {
+ throw new Error('Shopify shop response is missing its account id')
+ }
+ return String(id)
+}
+
+/** Persists a verified Shopify account and completes its exact credential draft. */
+export async function completeShopifyOAuthConnection(
+ params: CompleteShopifyOAuthConnectionParams
+): Promise {
+ const shopResponse = await fetch(
+ `https://${params.shopDomain}/admin/api/${SHOPIFY_API_VERSION}/shop.json`,
+ {
+ headers: {
+ 'X-Shopify-Access-Token': params.accessToken,
+ 'Content-Type': 'application/json',
+ },
+ signal: params.signal,
+ }
+ )
+
+ if (!shopResponse.ok) {
+ const errorText = await shopResponse.text()
+ throw new Error(`Shopify token validation failed (${shopResponse.status}): ${errorText}`)
+ }
+
+ const stableAccountId = getShopifyAccountId(await shopResponse.json())
+ const existing = await db.query.account.findFirst({
+ where: and(
+ eq(account.userId, params.userId),
+ eq(account.providerId, 'shopify'),
+ eq(account.accountId, stableAccountId)
+ ),
+ })
+
+ const now = new Date()
+ const accountData = {
+ accessToken: params.accessToken,
+ accountId: stableAccountId,
+ scope: params.scope ?? '',
+ updatedAt: now,
+ idToken: params.shopDomain,
+ }
+
+ if (existing) {
+ await db.update(account).set(accountData).where(eq(account.id, existing.id))
+ logger.info('Updated existing Shopify account', { accountId: existing.id })
+ } else {
+ await safeAccountInsert(
+ {
+ id: generateId(),
+ userId: params.userId,
+ providerId: 'shopify',
+ accountId: accountData.accountId,
+ accessToken: accountData.accessToken,
+ scope: accountData.scope,
+ idToken: accountData.idToken,
+ createdAt: now,
+ updatedAt: now,
+ },
+ { provider: 'Shopify', identifier: params.shopDomain }
+ )
+ }
+
+ const persisted =
+ existing ??
+ (await db.query.account.findFirst({
+ where: and(
+ eq(account.userId, params.userId),
+ eq(account.providerId, 'shopify'),
+ eq(account.accountId, stableAccountId)
+ ),
+ }))
+
+ if (!persisted) {
+ throw new Error(`Shopify OAuth account ${stableAccountId} was not persisted`)
+ }
+
+ await processCredentialDraft({
+ draftId: params.draftId,
+ userId: params.userId,
+ providerId: 'shopify',
+ accountId: persisted.id,
+ })
+}
diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts
index ba484e5faf6..c91d8b0d3e7 100644
--- a/scripts/check-api-validation-contracts.ts
+++ b/scripts/check-api-validation-contracts.ts
@@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries')
const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors')
const BASELINE = {
- totalRoutes: 1106,
- zodRoutes: 1106,
+ totalRoutes: 1109,
+ zodRoutes: 1109,
nonZodRoutes: 0,
} as const
diff --git a/scripts/openapi/documents.test.ts b/scripts/openapi/documents.test.ts
index 21ebac6ba87..2fb9c71e064 100644
--- a/scripts/openapi/documents.test.ts
+++ b/scripts/openapi/documents.test.ts
@@ -37,7 +37,7 @@ const EXPECTED_OPERATION_COUNTS = new Map([
['apps/docs/openapi-v2-tables.json', 44],
['apps/docs/openapi-v2-knowledge.json', 21],
['apps/docs/openapi-v2-billing.json', 2],
- ['apps/docs/openapi-v2-resources.json', 22],
+ ['apps/docs/openapi-v2-resources.json', 26],
])
function getOperation(spec: JsonObject, path: string, method: string): JsonObject {
@@ -169,7 +169,7 @@ describe('generated OpenAPI documents', () => {
})
}
}
- expect(totalOperations).toBe(135)
+ expect(totalOperations).toBe(139)
})
it('documents mixed workflow execution and resume responses', () => {