()
+ const audienceValue = audienceInput ?? setup.data?.reconnectAudience ?? ''
+ const defaultAudience = setup.data?.available ? setup.data.audience : undefined
+ const typedAudience = audienceValue.trim()
+ const typedCustom = typedAudience && typedAudience !== defaultAudience ? typedAudience : undefined
+ const audienceCheck = typedCustom
+ ? workloadIdentityAudienceSchema(provider).safeParse(typedCustom)
+ : undefined
+ const audienceError =
+ audienceCheck && !audienceCheck.success ? audienceCheck.error.issues[0]?.message : undefined
+ const customAudience = audienceError ? undefined : typedCustom
const storedAuthType = providerAuthType(provider, setup.data?.reconnectAuthType)
// A keyless credential reopens on keyless only where this deployment can still sign.
const reconnectAuthType =
@@ -73,13 +86,33 @@ export function useKeylessSetup({
available: workspaceScoped ? setup.data?.available : false,
/** The stored mode of the credential being reconnected; a reconnect reopens on it. */
reconnectAuthType,
+ /** A reconnected keyless Google credential's domain-wide delegation; a reconnect keeps it. */
+ reconnectDelegation: setup.data?.reconnectDelegation,
+ /** A reconnected Microsoft app credential's national cloud; a reconnect reopens on it. */
+ reconnectCloud: setup.data?.reconnectCloud,
+ /**
+ * Whether the reconnect fields above came back. False while they load, after a failed
+ * lookup, and for an organization credential, whose stored secret this lookup never reads.
+ */
+ reconnectFieldsLoaded: workspaceScoped && setup.isSuccess,
isReconnect: Boolean(credentialId),
subjectChoice,
setSubjectChoice,
/** The new subject to submit, or undefined when keeping the stored one. */
trust: subjectChoice === 'new' ? trust : undefined,
+ /** The audience field's value; empty keeps the default. */
+ audienceInput: audienceValue,
+ setAudienceInput,
+ /** The provider's default audience; undefined for Google, whose default is each credential's provider. */
+ defaultAudience,
+ /** The audience typed into the field, unless it is blank, the default, or invalid. */
+ customAudience,
+ /** Why the typed audience can't be used; setup snippets stay hidden until it can. */
+ audienceError,
+ /** The audience tokens carry: the custom one, else the default; undefined while invalid. */
+ audience: audienceError ? undefined : (customAudience ?? defaultAudience),
/** Ready to submit a keyless configuration. */
- ready: subjectChoice === 'keep' ? Boolean(credentialId) : Boolean(trust),
+ ready: !audienceError && (subjectChoice === 'keep' ? Boolean(credentialId) : Boolean(trust)),
error: setup.isError ? setup.error : null,
}
}
@@ -96,15 +129,25 @@ function providerAuthType
(
return modes.find((mode) => mode === value)
}
-/** The subject fields of a keyless config: a new subject with its proof, or none to keep the stored one. */
-export function keylessSubjectFields(setup: KeylessSetup) {
- return setup.trust ? { subject: setup.trust.subject, subjectProof: setup.trust.subjectProof } : {}
+/**
+ * The trust fields of a keyless config: a new subject with its proof (none keeps the
+ * stored one), and the custom audience when one is set (none means the default).
+ */
+export function keylessConfigFields(setup: KeylessSetup) {
+ return {
+ ...(setup.trust
+ ? { subject: setup.trust.subject, subjectProof: setup.trust.subjectProof }
+ : {}),
+ ...(setup.customAudience ? { audience: setup.customAudience } : {}),
+ }
}
interface KeylessTrustFieldsProps {
setup: KeylessSetup
- /** The credential's own audience, for a provider without a fixed one (Google). */
- audience?: string
+ /** The default audience, for a provider without a fixed one (Google: the credential's own provider). */
+ defaultAudience?: string
+ /** Where the provider lists the audience it accepts, appended to the field's hint. */
+ audienceHint: string
/** Where in the provider's console the values below are pasted. */
pasteHint?: string
}
@@ -112,7 +155,8 @@ interface KeylessTrustFieldsProps {
/** Reconnect choice plus the issuer, audience, and subject the customer configures. */
export function KeylessTrustFields({
setup,
- audience = setup.trust?.audience,
+ defaultAudience = setup.defaultAudience,
+ audienceHint,
pasteHint,
}: KeylessTrustFieldsProps) {
return (
@@ -150,14 +194,6 @@ export function KeylessTrustFields({
copyLabel='Copy issuer'
hint={pasteHint}
/>
- {audience && (
-
- )}
>
)}
+ {(setup.trust || setup.isReconnect) && (
+
+ )}
>
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/microsoft-app-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/microsoft-app-account-modal.tsx
index 52df79fa08b..52d15dfb216 100644
--- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/microsoft-app-account-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/microsoft-app-account-modal.tsx
@@ -11,10 +11,15 @@ import {
SecretInput,
} from '@sim/emcn'
import { resourceScopeFromOwner, resourceScopeKey } from '@/lib/core/resource-scope'
+import {
+ MICROSOFT_CLOUD_IDS,
+ MICROSOFT_CLOUDS,
+ type MicrosoftCloudId,
+} from '@/lib/credentials/microsoft-clouds'
import { MICROSOFT_APP_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types'
import {
KeylessTrustFields,
- keylessSubjectFields,
+ keylessConfigFields,
useKeylessSetup,
useSavedConnectionSubmit,
} from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/keyless-connection'
@@ -43,6 +48,11 @@ const AUTH_TYPE_HINTS: Record = {
const DOCS_URL = 'https://docs.sim.ai/integrations/microsoft-app-credentials'
+const CLOUD_OPTIONS = MICROSOFT_CLOUD_IDS.map((id) => ({
+ value: id,
+ label: MICROSOFT_CLOUDS[id].label,
+}))
+
/** Saved Microsoft app credentials share the standard credential create/reconnect lifecycle. */
export function MicrosoftAppAccountModal(props: MicrosoftAppAccountModalProps) {
if (!props.open) return null
@@ -80,6 +90,7 @@ function MicrosoftAppAccountForm({
onClose: () => onOpenChange(false),
})
const [selectedAuthType, setSelectedAuthType] = useState(null)
+ const [selectedCloud, setSelectedCloud] = useState(null)
const [tenantId, setTenantId] = useState('')
const [clientId, setClientId] = useState('')
const [environmentUrl, setEnvironmentUrl] = useState('')
@@ -97,8 +108,15 @@ function MicrosoftAppAccountForm({
? 'federated'
: 'client_secret'
const authType: MicrosoftAuthType = selectedAuthType ?? defaultAuthType
+ // A reconnect opens on the credential's stored cloud. Until that is known (still loading, a
+ // failed lookup, or an organization credential) the cloud must be chosen, so a rotation never
+ // silently moves a national-cloud credential to the global cloud.
+ const storedCloudKnown = !credentialId || keyless.reconnectFieldsLoaded
+ const cloud: MicrosoftCloudId | undefined =
+ selectedCloud ?? (storedCloudKnown ? (keyless.reconnectCloud ?? 'global') : undefined)
const disabled =
isPending ||
+ !cloud ||
!tenantId.trim() ||
!clientId.trim() ||
(authType === 'client_secret' && !clientSecret.trim()) ||
@@ -110,14 +128,16 @@ function MicrosoftAppAccountForm({
clearError()
}
- const buildConfig = (): Record => {
+ const buildConfig = (appCloud: MicrosoftCloudId): Record => {
+ const isGlobal = appCloud === 'global'
const base = {
authType,
tenantId: tenantId.trim(),
clientId: clientId.trim(),
- ...(environmentUrl.trim() ? { environmentUrl: environmentUrl.trim() } : {}),
+ ...(isGlobal ? {} : { cloud: appCloud }),
+ ...(isGlobal && environmentUrl.trim() ? { environmentUrl: environmentUrl.trim() } : {}),
}
- if (authType === 'federated') return { ...base, ...keylessSubjectFields(keyless) }
+ if (authType === 'federated') return { ...base, ...keylessConfigFields(keyless) }
if (authType === 'certificate') return { ...base, privateKey, certificate }
return { ...base, clientSecret }
}
@@ -154,6 +174,19 @@ function MicrosoftAppAccountForm({
required
hint={AUTH_TYPE_HINTS[authType]}
/>
+ {
+ setSelectedCloud(value as MicrosoftCloudId)
+ clearError()
+ }}
+ options={CLOUD_OPTIONS}
+ placeholder='Choose the app’s cloud'
+ required
+ hint='The Microsoft cloud the app is registered in. Only Azure Data Explorer runs outside the global cloud.'
+ />
-
+ {cloud === 'global' && (
+
+ )}
{authType === 'client_secret' && (
@@ -226,6 +261,7 @@ function MicrosoftAppAccountForm({
)}
@@ -258,7 +294,9 @@ function MicrosoftAppAccountForm({
primaryAction={{
label: isPending ? 'Verifying...' : `${action} credential`,
onClick: () => {
- if (!disabled) void submit({ config: buildConfig(), displayName, description })
+ if (!disabled && cloud) {
+ void submit({ config: buildConfig(cloud), displayName, description })
+ }
},
disabled,
}}
diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/snowflake-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/snowflake-account-modal.tsx
index 7bba14c85c6..3ba3690ecc3 100644
--- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/snowflake-account-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/snowflake-account-modal.tsx
@@ -9,6 +9,7 @@ import {
ChipModalFooter,
ChipModalHeader,
ChipTextarea,
+ cn,
SecretInput,
} from '@sim/emcn'
import { isApiClientError } from '@/lib/api/client/errors'
@@ -25,11 +26,10 @@ import {
import {
type KeylessSetup,
KeylessTrustFields,
- keylessSubjectFields,
+ keylessConfigFields,
useKeylessSetup,
useSavedConnectionSubmit,
} from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/keyless-connection'
-import { TokenServiceAccountModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/token-service-account-modal'
import { withBrandIcon } from '@/blocks/brand-icon'
import {
useCreateScopedCredential,
@@ -49,18 +49,30 @@ interface SnowflakeAccountModalProps {
onCreated?: (credentialId: string) => void
}
-type SnowflakeAuthType = 'workload_identity' | 'programmatic_access_token'
+type SnowflakeAuthType = 'workload_identity' | 'key_pair' | 'programmatic_access_token'
const SNOWFLAKE_DESCRIPTOR = getTokenServiceAccountDescriptor(SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID)
const SNOWFLAKE_DOCS_URL = 'https://docs.sim.ai/integrations/snowflake-service-account'
-const HOST_PATTERN = /^[a-z0-9][a-z0-9.-]*\.snowflakecomputing\.(com|cn)$/i
+const HOST_PATTERN = /^[a-z0-9][a-z0-9_.-]*\.snowflakecomputing\.(com|cn)$/i
const AUTH_TYPE_HINTS: Record = {
workload_identity:
'No secret is stored. Sim signs a short-lived token your Snowflake service user trusts.',
+ key_pair:
+ 'Sim stores the private key and signs a short-lived token with it for each run. Assign the public key to the user in Snowflake first.',
programmatic_access_token: 'Sim sends this token on every request.',
}
+/** Copy for the shared verification codes a key-pair save reports. */
+function keyPairErrorMessage(code: string): string | undefined {
+ switch (code) {
+ case 'invalid_credentials':
+ return 'Snowflake did not accept a token signed with this key. Check the user name, that its RSA_PUBLIC_KEY (or key pair) is this private key’s public key, and that a network policy allows Sim to reach the account.'
+ default:
+ return keylessErrorMessage(code)
+ }
+}
+
/** Copy for the shared verification codes a keyless save reports. */
function keylessErrorMessage(code: string): string | undefined {
switch (code) {
@@ -75,14 +87,19 @@ function keylessErrorMessage(code: string): string | undefined {
}
}
-/** The statement that creates a service user trusting only this credential's subject. */
-function buildCreateUserStatement(issuer: string, subject: string) {
+/**
+ * The statement that creates a service user trusting only this credential's
+ * subject. Snowflake accepts the default `snowflakecomputing.com` audience with
+ * no `OIDC_AUDIENCE_LIST`; a custom audience must be listed there.
+ */
+function buildCreateUserStatement(issuer: string, subject: string, customAudience?: string) {
return [
'CREATE USER SIM_WORKFLOWS',
' WORKLOAD_IDENTITY = (',
' TYPE = OIDC',
` ISSUER = '${issuer}'`,
` SUBJECT = '${subject}'`,
+ ...(customAudience ? [` OIDC_AUDIENCE_LIST = ('${customAudience}')`] : []),
' )',
' TYPE = SERVICE;',
].join('\n')
@@ -96,10 +113,10 @@ function normalizeHostInput(raw: string): string {
}
/**
- * Snowflake connections: a programmatic access token, or keyless workload
- * identity where this deployment can sign tokens. Where it cannot (or for an
- * organization credential, which keyless never binds to), the standard
- * token dialog is shown unchanged.
+ * Snowflake connections: a programmatic access token, a key pair, or keyless
+ * workload identity where this deployment can sign tokens. Where it cannot (or
+ * for an organization credential, which keyless never binds to), keyless is
+ * simply not offered.
*/
export function SnowflakeAccountModal(props: SnowflakeAccountModalProps) {
if (!props.open) return null
@@ -118,9 +135,6 @@ function SnowflakeAccountGate(props: SnowflakeAccountModalProps) {
organizationId: props.organizationId,
credentialId: props.credentialId,
})
- if (keyless.available === false && SNOWFLAKE_DESCRIPTOR) {
- return
- }
return
}
@@ -137,44 +151,49 @@ function SnowflakeAccountForm({
keyless,
}: SnowflakeAccountModalProps & { keyless: KeylessSetup<'snowflake'> }) {
const close = () => onOpenChange(false)
- const keylessSubmit = useSavedConnectionSubmit({
- providerId: SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID,
- workspaceId,
- organizationId,
- credentialId,
- onCreated,
- onClose: close,
- messageForCode: keylessErrorMessage,
- })
const createCredential = useCreateScopedCredential()
const updateCredential = useUpdateScopedCredential()
const [tokenError, setTokenError] = useState(null)
const [selectedAuthType, setSelectedAuthType] = useState(null)
const [host, setHost] = useState('')
const [token, setToken] = useState('')
+ const [user, setUser] = useState('')
+ const [privateKey, setPrivateKey] = useState('')
const [displayName, setDisplayName] = useState(initialDisplayName ?? '')
const [description, setDescription] = useState(initialDescription ?? '')
- // A reconnect opens on the credential's stored mode, so it never switches auth by default;
- // a new connection defaults to keyless wherever this deployment supports it.
- const defaultAuthType: SnowflakeAuthType = credentialId
- ? (keyless.reconnectAuthType ?? 'programmatic_access_token')
+ // A reconnect opens on the credential's stored mode, so it never switches auth by default. An
+ // organization credential's mode is not reported, so its reconnect starts with no mode chosen
+ // rather than a guess. A new connection defaults to keyless wherever this deployment supports it.
+ const defaultAuthType: SnowflakeAuthType | null = credentialId
+ ? (keyless.reconnectAuthType ?? null)
: keyless.available
? 'workload_identity'
: 'programmatic_access_token'
- const authType: SnowflakeAuthType = selectedAuthType ?? defaultAuthType
+ const authType = selectedAuthType ?? defaultAuthType
+ const connectionSubmit = useSavedConnectionSubmit({
+ providerId: SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID,
+ workspaceId,
+ organizationId,
+ credentialId,
+ onCreated,
+ onClose: close,
+ messageForCode: authType === 'key_pair' ? keyPairErrorMessage : keylessErrorMessage,
+ })
const normalizedHost = normalizeHostInput(host)
const isPending =
- keylessSubmit.isPending || createCredential.isPending || updateCredential.isPending
+ connectionSubmit.isPending || createCredential.isPending || updateCredential.isPending
const disabled =
isPending ||
+ !authType ||
!normalizedHost ||
(authType === 'programmatic_access_token' && !token.trim()) ||
+ (authType === 'key_pair' && (!user.trim() || !privateKey.trim())) ||
(authType === 'workload_identity' && !keyless.ready)
- const error = authType === 'workload_identity' ? keylessSubmit.error : tokenError
+ const error = authType === 'programmatic_access_token' ? tokenError : connectionSubmit.error
const clearErrors = () => {
- keylessSubmit.clearError()
+ connectionSubmit.clearError()
setTokenError(null)
}
const edit = (setter: (value: string) => void) => (value: string) => {
@@ -220,11 +239,24 @@ function SnowflakeAccountForm({
void submitToken()
return
}
- void keylessSubmit.submit({
+ if (authType === 'key_pair') {
+ void connectionSubmit.submit({
+ config: {
+ authType: 'key_pair',
+ host: normalizedHost,
+ user: user.trim(),
+ privateKey: privateKey.trim(),
+ },
+ displayName,
+ description,
+ })
+ return
+ }
+ void connectionSubmit.submit({
config: {
authType: 'workload_identity',
host: normalizedHost,
- ...keylessSubjectFields(keyless),
+ ...keylessConfigFields(keyless),
},
displayName,
description,
@@ -232,14 +264,19 @@ function SnowflakeAccountForm({
}
const createUserStatement =
- authType === 'workload_identity' && keyless.trust
- ? buildCreateUserStatement(keyless.trust.issuer, keyless.trust.subject)
+ authType === 'workload_identity' && keyless.trust && !keyless.audienceError
+ ? buildCreateUserStatement(
+ keyless.trust.issuer,
+ keyless.trust.subject,
+ keyless.customAudience
+ )
: null
const action = credentialId ? 'Reconnect' : 'Add'
const authOptions = [
...(keyless.available
? [{ value: 'workload_identity', label: 'Service user (keyless, via Sim OIDC)' }]
: []),
+ { value: 'key_pair', label: 'Key pair (RSA private key)' },
{ value: 'programmatic_access_token', label: 'Programmatic access token' },
]
@@ -252,7 +289,7 @@ function SnowflakeAccountForm({
{
setSelectedAuthType(value as SnowflakeAuthType)
clearErrors()
@@ -260,7 +297,7 @@ function SnowflakeAccountForm({
options={authOptions}
placeholder='Choose how Sim signs in to Snowflake'
required
- hint={AUTH_TYPE_HINTS[authType]}
+ hint={authType ? AUTH_TYPE_HINTS[authType] : undefined}
/>
@@ -292,7 +329,51 @@ function SnowflakeAccountForm({
)}
- {authType === 'workload_identity' && }
+ {authType === 'key_pair' && (
+ <>
+
+
+ {(aria) => (
+ edit(setPrivateKey)(event.target.value)}
+ placeholder='-----BEGIN PRIVATE KEY-----'
+ className={cn(
+ 'min-h-[120px] font-mono',
+ privateKey && '[&:not(:focus)]:[-webkit-text-security:disc]'
+ )}
+ spellCheck={false}
+ autoComplete='off'
+ autoCorrect='off'
+ autoCapitalize='off'
+ data-lpignore='true'
+ data-form-type='other'
+ />
+ )}
+
+ >
+ )}
+
+ {authType === 'workload_identity' && (
+
+ )}
{createUserStatement && (
(null)
+ const [selectedAuthMethod, setSelectedAuthMethod] =
+ useState(null)
/**
* A reconnect cannot pre-select the mode: the stored one lives inside the encrypted secret and
* is never returned to the client, so the admin restates it while retyping the secret. Until
* then neither mode's fields show, so a reconnect never switches modes by default.
*/
const mustRestateAuthMethod = Boolean(clientCredentials && credentialId && !selectedAuthMethod)
- const authMethod: TokenAuthMethod | null =
+ const authMethod: TokenServiceAccountAuthMethod | null =
selectedAuthMethod ??
(mustRestateAuthMethod
? null
@@ -115,10 +118,29 @@ function TokenServiceAccountModalForm({
? CLIENT_CREDENTIALS_AUTH_METHOD
: 'token')
const usesClientCredentials = authMethod === CLIENT_CREDENTIALS_AUTH_METHOD
+ /** A field that belongs to one sign-in mode (ServiceNow's username) shows once that mode is chosen. */
+ const shownField = (id: TokenServiceAccountField['id']) =>
+ descriptor.fields.find(
+ (field) =>
+ field.id === id &&
+ (authMethod
+ ? tokenFieldBelongsToAuthMethod(field, authMethod)
+ : !field.requiredForAuthMethods)
+ )
+ const tokenField = descriptor.fields.find((field) => field.id === 'apiToken')
+ const domainField = shownField('domain')
+ const workspaceField = shownField('providerWorkspaceId')
+ /**
+ * A new connection starts on the domain's default (the region or host it had before the field
+ * existed). A reconnect starts blank, which keeps the stored value: the server carries it
+ * forward, since the stored one is never returned to the client.
+ */
+ const initialDomain = credentialId ? '' : (domainField?.defaultValue ?? '')
+
const [apiToken, setApiToken] = useState('')
const [clientId, setClientId] = useState('')
const [clientSecret, setClientSecret] = useState('')
- const [domain, setDomain] = useState('')
+ const [domain, setDomain] = useState(initialDomain)
const [providerWorkspaceId, setProviderWorkspaceId] = useState('')
const [displayName, setDisplayName] = useState(initialDisplayName ?? '')
const [description, setDescription] = useState(initialDescription ?? '')
@@ -127,19 +149,20 @@ function TokenServiceAccountModalForm({
const createCredential = useCreateScopedCredential()
const updateCredential = useUpdateScopedCredential()
- const tokenField = descriptor.fields.find((field) => field.id === 'apiToken')
- const domainField = descriptor.fields.find((field) => field.id === 'domain')
- const workspaceField = descriptor.fields.find((field) => field.id === 'providerWorkspaceId')
-
const trimmedToken = apiToken.trim()
const normalizedDomain = domainField?.url ? domain.trim() : normalizeDomainInput(domain)
+ const trimmedWorkspaceId = providerWorkspaceId.trim()
const isPending =
createCredential.isPending || updateCredential.isPending || submitPending === true
+ const isFieldRequired = (field: TokenServiceAccountField): boolean =>
+ isTokenServiceAccountFieldRequired(field) ||
+ (field.requiredForDomain !== undefined &&
+ normalizedDomain.toLowerCase() === field.requiredForDomain)
const isDisabled =
!authMethod ||
(usesClientCredentials ? !clientId.trim() || !clientSecret.trim() : !trimmedToken) ||
- (Boolean(domainField) && !normalizedDomain) ||
- (Boolean(workspaceField) && !providerWorkspaceId.trim()) ||
+ Boolean(domainField && isFieldRequired(domainField) && !normalizedDomain) ||
+ Boolean(workspaceField && isFieldRequired(workspaceField) && !trimmedWorkspaceId) ||
isPending
const hintFor = (field: TokenServiceAccountField, value: string): string | undefined => {
@@ -147,6 +170,48 @@ function TokenServiceAccountModalForm({
return field.hintPattern.test(value) ? undefined : field.hintMessage
}
+ const renderConnectionField = (
+ field: TokenServiceAccountField,
+ value: string,
+ setValue: (value: string) => void,
+ formatError?: string
+ ) => {
+ const onChange = (next: string) => {
+ setValue(next)
+ if (error) setError(null)
+ }
+ const placeholder =
+ credentialId && field.defaultValue !== undefined
+ ? `Keep the current ${field.label.toLowerCase()}`
+ : field.placeholder
+ if (field.options) {
+ return (
+
+ )
+ }
+ return (
+
+ )
+ }
+
const handleSubmit = async () => {
setError(null)
if (isDisabled) return
@@ -159,8 +224,10 @@ function TokenServiceAccountModalForm({
clientSecret: clientSecret.trim(),
}
: { apiToken: tokenField?.preserveWhitespace ? apiToken : trimmedToken }),
- ...(workspaceField ? { providerWorkspaceId: providerWorkspaceId.trim() } : {}),
- ...(domainField ? { domain: normalizedDomain } : {}),
+ ...(workspaceField && trimmedWorkspaceId
+ ? { providerWorkspaceId: trimmedWorkspaceId }
+ : {}),
+ ...(domainField && normalizedDomain ? { domain: normalizedDomain } : {}),
}
if (onSubmitCredentials) {
const savedId = await onSubmitCredentials({
@@ -223,7 +290,7 @@ function TokenServiceAccountModalForm({
title='Authentication'
value={authMethod ?? undefined}
onChange={(value) => {
- setSelectedAuthMethod(value as TokenAuthMethod)
+ setSelectedAuthMethod(value as TokenServiceAccountAuthMethod)
if (error) setError(null)
}}
options={[
@@ -258,49 +325,54 @@ function TokenServiceAccountModalForm({
required
hint={hintFor(tokenField, trimmedToken) ?? descriptor.helpText}
>
- {
- setApiToken(value)
- if (error) setError(null)
- }}
- placeholder={tokenField.placeholder}
- name={`${descriptor.providerId}_api_token`}
- autoComplete='new-password'
- autoCorrect='off'
- autoCapitalize='off'
- data-lpignore='true'
- data-form-type='other'
- />
+ {tokenField.multiline ? (
+ (aria) => (
+ {
+ setApiToken(event.target.value)
+ if (error) setError(null)
+ }}
+ placeholder={tokenField.placeholder}
+ className='min-h-[120px] font-mono'
+ // Spell-check and autofill would ship a pasted private key to third parties.
+ spellCheck={false}
+ autoComplete='off'
+ autoCorrect='off'
+ autoCapitalize='off'
+ data-lpignore='true'
+ data-form-type='other'
+ />
+ )
+ ) : (
+ {
+ setApiToken(value)
+ if (error) setError(null)
+ }}
+ placeholder={tokenField.placeholder}
+ name={`${descriptor.providerId}_api_token`}
+ autoComplete='new-password'
+ autoCorrect='off'
+ autoCapitalize='off'
+ data-lpignore='true'
+ data-form-type='other'
+ />
+ )}
)}
- {workspaceField && (
-
- )}
- {domainField && (
- {
- setDomain(value)
- if (error) setError(null)
- }}
- placeholder={domainField.placeholder}
- autoComplete='off'
- required
- error={hintFor(domainField, normalizedDomain)}
- />
- )}
+ {workspaceField &&
+ renderConnectionField(workspaceField, providerWorkspaceId, setProviderWorkspaceId)}
+ {domainField &&
+ renderConnectionField(
+ domainField,
+ domain,
+ setDomain,
+ hintFor(domainField, normalizedDomain)
+ )}
)}
+ {credential.expiresAt && (
+
+
+ {formatDateTime(new Date(credential.expiresAt), 'UTC')} · {expiryLabel}
+
+
+ )}
{
+ root.render()
+ })
+}
+
+describe('useSavedTokenExpiryLabel', () => {
+ beforeEach(() => {
+ vi.useFakeTimers({ now: NOW })
+ container = document.createElement('div')
+ root = createRoot(container)
+ })
+
+ afterEach(() => {
+ act(() => root.unmount())
+ vi.useRealTimers()
+ })
+
+ it('reads expired once the token expires while the page stays open', () => {
+ mount(new Date(NOW + 60_000).toISOString())
+ expect(label).toBe('expires in 1 day')
+ act(() => {
+ vi.advanceTimersByTime(60_000)
+ })
+ expect(label).toBe('expired')
+ })
+
+ it('counts the days down while the page stays open', () => {
+ mount(new Date(NOW + 2 * DAY_MS).toISOString())
+ expect(label).toBe('expires in 2 days')
+ act(() => {
+ vi.advanceTimersByTime(DAY_MS + 1)
+ })
+ expect(label).toBe('expires in 1 day')
+ })
+
+ it('times a reconnected token from the moment it arrives, not from when the page opened', () => {
+ mount(new Date(NOW + 3 * DAY_MS).toISOString())
+ act(() => {
+ vi.advanceTimersByTime(6 * 60 * 60 * 1000)
+ })
+ mount(new Date(Date.now() + 60 * 60 * 1000).toISOString())
+ expect(label).toBe('expires in 1 day')
+ act(() => {
+ vi.advanceTimersByTime(60 * 60 * 1000)
+ })
+ expect(label).toBe('expired')
+ })
+
+ it('counts a token that gains an expiry after the page has been open for days from now', () => {
+ mount(undefined)
+ act(() => {
+ vi.advanceTimersByTime(3 * DAY_MS)
+ })
+ mount(new Date(Date.now() + 60 * 60 * 1000).toISOString())
+ expect(label).toBe('expires in 1 day')
+ })
+
+ it('has no caption for a token that states no expiry', () => {
+ mount(undefined)
+ expect(label).toBeUndefined()
+ expect(vi.getTimerCount()).toBe(0)
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/hooks/use-saved-token-expiry-label.ts b/apps/sim/app/workspace/[workspaceId]/integrations/hooks/use-saved-token-expiry-label.ts
new file mode 100644
index 00000000000..cc4e40d9f23
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/integrations/hooks/use-saved-token-expiry-label.ts
@@ -0,0 +1,33 @@
+import { useEffect, useState } from 'react'
+import {
+ msUntilSavedTokenExpiryLabelChanges,
+ savedTokenExpiryLabel,
+} from '@/lib/credentials/expiry-label'
+
+/**
+ * The caption for a saved token's expiry, kept current while the page stays open: it re-reads the
+ * clock when the expiry changes (a reconnect), when the day count drops, and when the token
+ * expires. `undefined` when the token states no readable expiry.
+ */
+export function useSavedTokenExpiryLabel(expiresAt: string | undefined): string | undefined {
+ const expiresAtMs = expiresAt ? new Date(expiresAt).getTime() : Number.NaN
+ const [clock, setClock] = useState(() => ({ expiresAtMs, now: Date.now() }))
+ if (!Object.is(clock.expiresAtMs, expiresAtMs)) {
+ setClock({ expiresAtMs, now: Date.now() })
+ }
+ const { now } = clock
+
+ useEffect(() => {
+ const untilChange = msUntilSavedTokenExpiryLabelChanges(new Date(expiresAtMs), now)
+ if (untilChange === null) return
+ const timer = setTimeout(
+ () => setClock((current) => ({ ...current, now: Date.now() })),
+ untilChange
+ )
+ return () => clearTimeout(timer)
+ }, [expiresAtMs, now])
+
+ return Number.isFinite(expiresAtMs)
+ ? savedTokenExpiryLabel(new Date(expiresAtMs), now)
+ : undefined
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx
index 7817b234ae4..e979b82658c 100644
--- a/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx
@@ -26,6 +26,7 @@ import { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/components/
import { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/components/integration-section'
import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
import { ShowcaseWithExplore } from '@/app/workspace/[workspaceId]/integrations/components/showcase-with-explore'
+import { useSavedTokenExpiryLabel } from '@/app/workspace/[workspaceId]/integrations/hooks/use-saved-token-expiry-label'
import { useScrollRestoration } from '@/app/workspace/[workspaceId]/integrations/hooks/use-scroll-restoration'
import {
ALL_CATEGORY,
@@ -142,15 +143,32 @@ interface ConnectedItemProps {
name: string
description: string
icon: ComponentType<{ className?: string }>
+ /** ISO time the saved token stops working, when its vendor stated one. */
+ expiresAt?: string
}
-function ConnectedItem({ href, blockType, name, description, icon: Icon }: ConnectedItemProps) {
+function ConnectedItem({
+ href,
+ blockType,
+ name,
+ description,
+ icon: Icon,
+ expiresAt,
+}: ConnectedItemProps) {
+ const expiryLabel = useSavedTokenExpiryLabel(expiresAt)
return (
}
title={name}
description={description}
+ badge={
+ expiryLabel ? (
+
+ {expiryLabel}
+
+ ) : undefined
+ }
href={href}
clickLabel={`Open ${name}`}
navigable
@@ -439,6 +457,7 @@ function IntegrationsContent() {
name={item.name}
description={item.description}
icon={item.icon}
+ expiresAt={item.credential.expiresAt}
/>
))}
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx
index b14b5b9aac3..5e57cb57234 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx
@@ -106,6 +106,7 @@ import {
kbDocumentSortParams,
} from '@/app/workspace/[workspaceId]/knowledge/[id]/search-params'
import { canDeleteKnowledgeBase } from '@/app/workspace/[workspaceId]/knowledge/permissions'
+import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { BrandIcon } from '@/blocks/brand-icon'
@@ -420,6 +421,7 @@ export function KnowledgeBase({
const [showDocumentTagsModal, setShowDocumentTagsModal] = useState(false)
const [documentForTagsId, setDocumentForTagsId] = useState(null)
const showAddConnectorModal = addConnectorType != null
+ const savedCredentialSetupEnabled = useFeatureFlag('knowledge-connector-saved-credentials')
const updateAddConnectorParam = useCallback(
(value: string | null) => {
void setAddConnectorType(value, { history: 'replace', scroll: false })
@@ -1472,6 +1474,7 @@ export function KnowledgeBase({
knowledgeBaseId={id}
initialConnectorType={addConnectorType || undefined}
isSearchIndex={knowledgeBase?.isSearchIndex}
+ savedCredentialSetupEnabled={savedCredentialSetupEnabled}
/>
)}
@@ -1513,6 +1516,7 @@ export function KnowledgeBase({
isLoading={isLoadingConnectors}
canEdit={userPermissions.canEdit}
className='mt-0'
+ savedCredentialSetupEnabled={savedCredentialSetupEnabled}
/>
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx
index 9cc992c2cf4..2950343d65a 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx
@@ -63,7 +63,11 @@ import {
} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
import { withBrandIcon } from '@/blocks/brand-icon'
-import { getConnectorApiKeyConfig, isConnectorCredentialTypeAllowed } from '@/connectors/auth'
+import {
+ getConnectorApiKeyConfig,
+ isConnectorCredentialTypeAllowed,
+ isConnectorFieldForAuth,
+} from '@/connectors/auth'
import { GitHubInstallationConnectionField } from '@/connectors/github/installation-connection-field'
import {
GitLabPermissionTabs,
@@ -109,6 +113,8 @@ interface AddConnectorModalProps {
initialSyncIntervalMinutes?: number
onCreated?: (connectorType: string, connector: ConnectorData) => void
setupDraftKey?: string
+ /** Offers a key connector's saved-credential alternative, such as a saved AWS credential for S3. */
+ savedCredentialSetupEnabled?: boolean
}
type Step = 'select-type' | 'configure'
@@ -125,6 +131,7 @@ export function AddConnectorModal({
lockedAccessMode,
initialSyncIntervalMinutes = 1440,
onCreated,
+ savedCredentialSetupEnabled = false,
setupDraftKey,
scope: explicitScope,
}: AddConnectorModalProps) {
@@ -213,8 +220,14 @@ export function AddConnectorModal({
const showGitLabPermissions = selectedType === 'gitlab' && access.accessMode === 'admin'
const isMembersMode = access.accessMode === 'members'
const apiKeyConfig = connectorConfig ? getConnectorApiKeyConfig(connectorConfig.auth) : undefined
+ const savedCredentialConfig =
+ savedCredentialSetupEnabled && connectorConfig?.auth.mode === 'apiKey'
+ ? connectorConfig.auth.savedCredential
+ : undefined
const isApiKeyMode =
- connectorConfig?.auth.mode === 'apiKey' || Boolean(apiKeyConfig && !isMembersMode && useApiKey)
+ connectorConfig?.auth.mode === 'apiKey'
+ ? !savedCredentialConfig || useApiKey
+ : Boolean(apiKeyConfig && !isMembersMode && useApiKey)
const {
integrationAvailability,
oauthServiceAvailability,
@@ -249,10 +262,13 @@ export function AddConnectorModal({
/** True when the connector declares its key optional (public sources need none). */
const isApiKeyOptional =
connectorConfig?.auth.mode === 'apiKey' && connectorConfig.auth.optional === true
- const connectorProviderId =
+ const credentialServiceId =
connectorConfig?.auth.mode === 'oauth'
- ? (getProviderIdFromServiceId(connectorConfig.auth.provider) as OAuthProvider)
- : null
+ ? connectorConfig.auth.provider
+ : savedCredentialConfig?.provider
+ const connectorProviderId = credentialServiceId
+ ? (getProviderIdFromServiceId(credentialServiceId) as OAuthProvider)
+ : null
const serviceAccountProviderId = connectorProviderId
? getServiceAccountProviderForProviderId(connectorProviderId)
@@ -263,7 +279,7 @@ export function AddConnectorModal({
!isConnectorCredentialTypeAllowed(connectorConfig.auth, access.accessMode, 'oauth')
const serviceAccountTarget = useServiceAccountConnectTarget({
serviceAccountProviderId:
- isSearchIndex || requiresServiceAccount
+ isSearchIndex || requiresServiceAccount || savedCredentialConfig
? asServiceAccountProviderId(serviceAccountProviderId)
: undefined,
serviceName: connectorConfig?.name,
@@ -303,7 +319,7 @@ export function AddConnectorModal({
isConnectorCredentialTypeAllowed(connectorConfig.auth, access.accessMode, credential.type)
)
const canConnectOAuth =
- connectorConfig &&
+ connectorConfig?.auth.mode === 'oauth' &&
isConnectorCredentialTypeAllowed(connectorConfig.auth, access.accessMode, 'oauth')
const effectiveCredentialId =
selectedCredentialId && credentials.some((credential) => credential.id === selectedCredentialId)
@@ -367,6 +383,7 @@ export function AddConnectorModal({
)
const isFieldVisible = (field: ConnectorConfigField) =>
isConfigFieldVisible(field) &&
+ isConnectorFieldForAuth(field, isApiKeyMode) &&
(connectorConfig?.auth.mode !== 'oauth' ||
connectorConfig.auth.serviceAccountSubjectFieldId !== field.id ||
indexingCredential?.type === 'service_account')
@@ -541,6 +558,13 @@ export function AddConnectorModal({
)
)
+ /** Key-only settings typed before switching to a saved credential are never sent with it. */
+ const keyOnlyFieldIds = new Set(
+ (connectorConfig?.configFields ?? [])
+ .filter((field) => !isConnectorFieldForAuth(field, isApiKeyMode))
+ .map((field) => field.canonicalParamId ?? field.id)
+ )
+
const handleSubmit = () => {
if (!selectedType || !canSubmit) return
@@ -548,7 +572,7 @@ export function AddConnectorModal({
const resolvedConfig: Record = {}
for (const [key, value] of Object.entries(resolveSourceConfig())) {
- if (hiddenCapFieldIds.has(key)) continue
+ if (hiddenCapFieldIds.has(key) || keyOnlyFieldIds.has(key)) continue
if (Array.isArray(value)) {
if (value.length > 0) resolvedConfig[key] = value
} else if (typeof value === 'string') {
@@ -714,23 +738,28 @@ export function AddConnectorModal({
{!slackSetupRequired && (
<>
- {connectorConfig.auth.mode === 'oauth' && apiKeyConfig && !isMembersMode && (
-
- {
- setUseApiKey(value === 'apiKey')
- setApiKeyValue('')
- setSelectedCredentialId(null)
- }}
- />
-
- )}
+ {apiKeyConfig &&
+ (savedCredentialConfig ||
+ (connectorConfig.auth.mode === 'oauth' && !isMembersMode)) && (
+
+ {
+ setUseApiKey(value === 'apiKey')
+ setApiKeyValue('')
+ setSelectedCredentialId(null)
+ }}
+ />
+
+ )}
{isApiKeyMode ? (
{credentialsError && rawCredentials.length === 0 ? (
@@ -801,7 +830,9 @@ export function AddConnectorModal({
placeholder={
canConnectOAuth
? `Select ${connectorConfig.name} account`
- : 'Select a service account'
+ : savedCredentialConfig
+ ? 'Select a credential'
+ : 'Select a service account'
}
isLoading={credentialsLoading || isIntegrationAvailabilityLoading}
disabled={isCreating || !isIntegrationAvailabilityReady}
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx
index 5eec846a5e9..b659418ae9e 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx
@@ -31,6 +31,7 @@ interface ConnectorsSectionProps {
isLoading: boolean
canEdit: boolean
className?: string
+ savedCredentialSetupEnabled?: boolean
}
const STATUS_CONFIG = {
@@ -51,6 +52,7 @@ export function ConnectorsSection({
isLoading,
canEdit,
className,
+ savedCredentialSetupEnabled = false,
}: ConnectorsSectionProps) {
const scope = explicitScope ?? resourceScopeFromOwner({ workspaceId })
const [editingConnector, setEditingConnector] = useState(null)
@@ -84,6 +86,7 @@ export function ConnectorsSection({
knowledgeBaseId={knowledgeBaseId}
isSearchIndex={isSearchIndex}
connector={editingConnector}
+ savedCredentialSetupEnabled={savedCredentialSetupEnabled}
/>
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-settings-fields.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-settings-fields.tsx
index 3ea7a10d590..ea9b30290ca 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-settings-fields.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-settings-fields.tsx
@@ -44,7 +44,7 @@ import type {
ConfigFieldValue,
} from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields'
import { SettingsQueryErrorState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
-import { isConnectorCredentialTypeAllowed } from '@/connectors/auth'
+import { getConnectorCredentialService, isConnectorCredentialTypeAllowed } from '@/connectors/auth'
import { GitHubInstallationConnectionField } from '@/connectors/github/installation-connection-field'
import {
GitLabPermissionTabs,
@@ -162,10 +162,14 @@ export function ConnectorSettingsFields({
const connectorConfig = liveSearchSourceMeta(originalConfig, Boolean(liveSearch), {
githubInstallation: usesGitHubInstallation && scope.kind === 'organization',
})
- const providerId =
- connectorConfig?.auth.mode === 'oauth'
- ? (getProviderIdFromServiceId(connectorConfig.auth.provider) as OAuthProvider)
- : null
+ const credentialServiceId = connectorConfig
+ ? getConnectorCredentialService(connectorConfig.auth)
+ : undefined
+ const providerId = credentialServiceId
+ ? (getProviderIdFromServiceId(credentialServiceId) as OAuthProvider)
+ : null
+ const savedCredentialConfig =
+ connectorConfig?.auth.mode === 'apiKey' ? connectorConfig.auth.savedCredential : undefined
const syncsPerMember = access.accessMode === 'members'
const isGitHubInstallationSource =
usesGitHubInstallation &&
@@ -182,7 +186,7 @@ export function ConnectorSettingsFields({
: undefined
const serviceAccountTarget = useServiceAccountConnectTarget({
serviceAccountProviderId:
- isSearchIndex || requiresServiceAccount
+ isSearchIndex || requiresServiceAccount || savedCredentialConfig
? asServiceAccountProviderId(serviceAccountProviderId)
: undefined,
serviceName: connectorConfig?.name,
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx
index 4c6b9471546..045814e9628 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx
@@ -26,6 +26,7 @@ interface EditConnectorModalProps {
knowledgeBaseId: string
isSearchIndex?: boolean
connector: ConnectorData
+ savedCredentialSetupEnabled?: boolean
}
export function EditConnectorModal({
@@ -35,6 +36,7 @@ export function EditConnectorModal({
isSearchIndex = false,
connector,
scope,
+ savedCredentialSetupEnabled = false,
}: EditConnectorModalProps) {
const [activeTab, setActiveTab] = useState('settings')
const [documentFilter, setDocumentFilter] = useState('active')
@@ -44,6 +46,7 @@ export function EditConnectorModal({
isSearchIndex,
connector,
onSaved: () => onOpenChange(false),
+ savedCredentialSetupEnabled,
})
return (
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/use-connector-settings-form.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/use-connector-settings-form.ts
index 300366d0e7a..8cad515af54 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/use-connector-settings-form.ts
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/use-connector-settings-form.ts
@@ -6,6 +6,7 @@ import { isEqual } from 'es-toolkit'
import type { UpdateConnectorBody } from '@/lib/api/contracts/knowledge/connectors'
import type { ResourceScope } from '@/lib/core/resource-scope'
import { isContentEngineAccessMode } from '@/lib/knowledge/connectors/access-modes'
+import { CREDENTIAL_REMOVED_SYNC_ERROR } from '@/lib/knowledge/connectors/sync-limits'
import { getConnectorAccessAvailability } from '@/lib/sim-search/connectors'
import { liveSearchSourceMeta } from '@/lib/sim-search/live/source-settings'
import { readSourceSelectionLabels, SOURCE_LABELS_KEY } from '@/lib/sim-search/source-identity'
@@ -20,9 +21,10 @@ import {
useConnectorConfigFields,
} from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields'
import { useConnectorScope } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-scope'
-import { isConnectorCredentialTypeAllowed } from '@/connectors/auth'
+import { isConnectorCredentialTypeAllowed, isConnectorFieldForAuth } from '@/connectors/auth'
import { useGitLabPermissionForm } from '@/connectors/gitlab/permission-config/use-permission-form'
import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
+import type { ConnectorConfigField } from '@/connectors/types'
import {
type ConnectorData,
isConnectorSyncingOrPending,
@@ -125,6 +127,8 @@ interface UseConnectorSettingsFormOptions {
connector: ConnectorData
onSaved: (connector: ConnectorData) => void
syncing?: boolean
+ /** Whether a key connector's saved credential may be replaced; the server refuses it while setup is off. */
+ savedCredentialSetupEnabled?: boolean
}
/** Editable connector settings shared by the source page and knowledge-base modal. */
@@ -135,6 +139,7 @@ export function useConnectorSettingsForm({
connector,
onSaved,
syncing = isConnectorSyncingOrPending(connector),
+ savedCredentialSetupEnabled = false,
}: UseConnectorSettingsFormOptions) {
const liveSearch = isSearchIndex
const connectorConfig = liveSearchSourceMeta(
@@ -232,6 +237,18 @@ export function useConnectorSettingsForm({
initialSelectionLabels,
})
+ /**
+ * A key connector that syncs with its saved credential, or did until the credential was
+ * removed: its credential stays replaceable here while saved-credential setup is enabled,
+ * and its key-only settings do not apply.
+ */
+ const usesSavedCredential =
+ connectorConfig?.auth.mode === 'apiKey' &&
+ Boolean(connectorConfig.auth.savedCredential) &&
+ (Boolean(connector.credentialId) || connector.lastSyncError === CREDENTIAL_REMOVED_SYNC_ERROR)
+ const isAuthFieldVisible = (field: ConnectorConfigField) =>
+ isFieldVisible(field) && isConnectorFieldForAuth(field, !usesSavedCredential)
+
const { scope, canAdmin, memberAccessAvailable, mirroredAccessAvailable, hasMaxAccess } =
useConnectorScope(explicitScope)
const { mutate: updateConnector, isPending: isSavingSettings } = useUpdateConnector()
@@ -280,10 +297,11 @@ export function useConnectorSettingsForm({
accessDirty && !accessModeChanged && isContentEngineAccessMode(access.accessMode)
/** Exposes credential selection for mode changes and administrator credential recovery. */
const needsWorkspaceCredential =
- connectorConfig?.auth.mode === 'oauth' &&
- isContentEngineAccessMode(access.accessMode) &&
- (persistedAccess.accessMode === 'members' ||
- !isConnectorCredentialTypeAllowed(connectorConfig.auth, access.accessMode, 'oauth'))
+ (usesSavedCredential && savedCredentialSetupEnabled) ||
+ (connectorConfig?.auth.mode === 'oauth' &&
+ isContentEngineAccessMode(access.accessMode) &&
+ (persistedAccess.accessMode === 'members' ||
+ !isConnectorCredentialTypeAllowed(connectorConfig.auth, access.accessMode, 'oauth')))
const missingAdminField =
accessDirty && access.accessMode === 'admin'
? connectorConfig?.configFields.find((field) => {
@@ -316,7 +334,7 @@ export function useConnectorSettingsForm({
const settingsComplete = connectorConfig?.configFields.every(
(field) =>
!isConnectorFieldRequired(field, connectorConfig, persistedAccess.accessMode) ||
- !isFieldVisible(field) ||
+ !isAuthFieldVisible(field) ||
hiddenCapFieldIds.has(field.id) ||
isFieldPopulated(field)
)
@@ -526,7 +544,7 @@ export function useConnectorSettingsForm({
canonicalModes,
onToggleCanonicalMode: toggleCanonicalMode,
onFieldChange: handleFieldChange,
- isFieldVisible: (field) => isFieldVisible(field) && !hiddenCapFieldIds.has(field.id),
+ isFieldVisible: (field) => isAuthFieldVisible(field) && !hiddenCapFieldIds.has(field.id),
syncInterval,
setSyncInterval,
hasMaxAccess,
diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx
index fb9ac90703f..016a072e5e2 100644
--- a/apps/sim/app/workspace/[workspaceId]/layout.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx
@@ -7,12 +7,12 @@ import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces'
import { getSession } from '@/lib/auth'
import { getActiveOrganizationId } from '@/lib/auth/session-response'
import { isChangelogEnabled } from '@/lib/changelog/feature-flag'
-import { isComputerUseAvailable } from '@/lib/computer-use/availability.server'
import { isDashboardsEnabled } from '@/lib/dashboards/feature-flag'
import {
hasDesktopBackgroundExecutor,
isDesktopBackgroundExecutorAvailable,
} from '@/lib/desktop/executor/availability'
+import { isConnectorSavedCredentialSetupEnabled } from '@/lib/knowledge/connectors/saved-credential-rollout'
import { isMothershipModelSelectorEnabled, isPlanModeEnabled } from '@/lib/mothership/feature-flags'
import { resolveOrganizationEntryPath } from '@/lib/navigation/resolve-app-entry'
import { isProjectsEnabled } from '@/lib/projects/rollout.server'
@@ -134,9 +134,9 @@ async function WorkspaceContent({
workflowTestsEnabled,
changelogEnabled,
desktopExecutorRegistered,
- computerUseEnabled,
projectsEnabled,
simLinkOrganizationId,
+ connectorSavedCredentialsEnabled,
] = await Promise.all([
cookies(),
orgSettings,
@@ -154,9 +154,9 @@ async function WorkspaceContent({
isWorkflowTestsEnabled(hostContext.hostOrganizationId),
isChangelogEnabled(hostContext.hostOrganizationId),
hasDesktopBackgroundExecutor(session.user.id),
- isComputerUseAvailable(hostContext.hostOrganizationId),
isProjectsEnabled(),
resolveSimLinkOrganization(principal),
+ isConnectorSavedCredentialSetupEnabled(),
prefetchWorkspaceAccess(queryClient, workspaceId, principal),
prefetchWorkspaceForkAvailability(queryClient, workspaceId, principal, hostContext),
])
@@ -170,9 +170,9 @@ async function WorkspaceContent({
'workflow-tests': workflowTestsEnabled,
changelog: changelogEnabled,
'mothership-model-selector': modelSelectorEnabled,
- 'mothership-computer-use': computerUseEnabled,
'mothership-plan-mode': planModeEnabled,
projects: projectsEnabled,
+ 'knowledge-connector-saved-credentials': connectorSavedCredentialsEnabled,
}}
>
diff --git a/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx b/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx
index f33fd8f230f..02ba4adb864 100644
--- a/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx
@@ -8,9 +8,9 @@ export interface WorkspaceFeatureFlags {
changelog: boolean
'mothership-model-selector': boolean
'mothership-plan-mode': boolean
- 'mothership-computer-use'?: boolean
'mothership-memory-spaces'?: boolean
projects?: boolean
+ 'knowledge-connector-saved-credentials'?: boolean
}
const FeatureFlagsContext = createContext(null)
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.test.tsx
index 1e22dc60339..6c03a168096 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.test.tsx
@@ -8,9 +8,10 @@ import type {
import { toast } from '@sim/emcn'
import { createDeferred } from '@sim/testing/helpers/deferred'
import { libDesktopMock, libDesktopMockFns } from '@sim/testing/mocks/lib-desktop.mock'
+import { nextNavigationMockFns } from '@sim/testing/mocks/next-navigation.mock'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
-import { FeatureFlagsProvider } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
+import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields'
import { ComputerUseSettings } from '@/app/workspace/[workspaceId]/settings/components/desktop/computer-use'
const native = vi.hoisted(() => ({
@@ -20,6 +21,20 @@ const native = vi.hoisted(() => ({
cancel: vi.fn<() => Promise>(),
onActivity: vi.fn<(listener: (activity: ComputerUseActivity | null) => void) => () => void>(),
}))
+const permission = vi.hoisted(() => ({
+ config: null as Record | null,
+ ready: true,
+}))
+vi.mock('@/ee/access-control/hooks/permission-groups', () => ({
+ useUserPermissionConfig: () =>
+ permission.ready
+ ? { isSuccess: true, data: { config: permission.config } }
+ : { isSuccess: false, data: undefined },
+}))
+vi.mock(
+ 'next/navigation',
+ async () => (await import('@sim/testing/mocks/next-navigation.mock')).nextNavigationMock
+)
vi.mock('@/lib/desktop', () => libDesktopMock)
const initialPermissions = [
@@ -29,12 +44,13 @@ const initialPermissions = [
let permissions: ComputerUseAppPermission[]
let activity: ComputerUseActivity | null
let listeners: Set<(activity: ComputerUseActivity | null) => void>
-let enabled: boolean
let root: Root
let container: HTMLDivElement
beforeEach(() => {
- enabled = true
+ permission.config = null
+ permission.ready = true
+ nextNavigationMockFns.mockUseParams.mockReturnValue({ workspaceId: 'ws-1' })
permissions = [...initialPermissions]
activity = null
listeners = new Set()
@@ -69,52 +85,25 @@ afterEach(async () => {
})
function renderSettings() {
- root.render(
-
-
-
- )
+ root.render()
}
-it('keeps settings dormant while rollout is off, including focus and activity changes', async () => {
- enabled = false
- await act(async () => renderSettings())
- await act(async () => window.dispatchEvent(new Event('focus')))
- expect(native.getStatus).not.toHaveBeenCalled()
- expect(native.listAppPermissions).not.toHaveBeenCalled()
- expect(container.textContent).toBe('')
+it.each(['disableComputerUse', 'hideCopilot'])(
+ 'withholds the device controls when the permission group sets %s',
+ async (key) => {
+ permission.config = { ...DEFAULT_PERMISSION_GROUP_CONFIG, [key]: true }
+ await act(async () => renderSettings())
+ expect(appRow('com.example.First')).toBeUndefined()
+ expect(container.querySelector('[role="switch"]')).toBeNull()
+ }
+)
- enabled = true
+it('withholds the device controls until the permission read succeeds', async () => {
+ permission.ready = false
await act(async () => renderSettings())
- expect(native.getStatus).toHaveBeenCalled()
- expect(appRow('com.example.First')).toBeDefined()
- await act(async () => {
- activity = { toolCallId: 'in-flight', scopeId: 'chat', action: 'click', startedAt: Date.now() }
- for (const listener of listeners) listener(activity)
- })
- enabled = false
- await act(async () => renderSettings())
- native.getStatus.mockClear()
- native.listAppPermissions.mockClear()
- await act(async () => {
- window.dispatchEvent(new Event('focus'))
- for (const listener of listeners) listener(activity)
- })
- expect(native.getStatus).not.toHaveBeenCalled()
+ expect(appRow('com.example.First')).toBeUndefined()
+ expect(container.querySelector('[role="switch"]')).toBeNull()
expect(native.listAppPermissions).not.toHaveBeenCalled()
- const stop = container.querySelector('button')
- expect(stop?.textContent).toContain('Stop')
- await act(async () => stop?.click())
- expect(native.cancel).toHaveBeenCalledOnce()
})
function appRow(bundleId: string) {
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.tsx
index 4d38a5241ec..af632d25f50 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.tsx
@@ -3,17 +3,26 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { ComputerUseAppPermission } from '@sim/desktop-bridge'
import { Chip, ChipSwitch, Label, toast } from '@sim/emcn'
+import { useParams } from 'next/navigation'
import { ComputerUseActivity } from '@/components/computer-use/activity'
import { getDesktopBridge } from '@/lib/desktop'
-import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
+import { CAPABILITY_RULES } from '@/lib/permission-groups/capabilities'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
+import { useUserPermissionConfig } from '@/ee/access-control/hooks/permission-groups'
import { useComputerUseStatus } from '@/hooks/use-computer-use-status'
+/** The activity stays visible so an in-flight action can be stopped; the device controls need the permission group's consent. */
export function ComputerUseSettings() {
+ const { workspaceId } = useParams<{ workspaceId: string }>()
+ const permission = useUserPermissionConfig(workspaceId)
+ const allowed =
+ permission.isSuccess &&
+ (permission.data.config === null ||
+ !CAPABILITY_RULES['computer_use.use'].deniedBy(permission.data.config))
return (
<>
-
+ {allowed && }
>
)
}
@@ -21,7 +30,6 @@ export function ComputerUseSettings() {
function ComputerUseSettingsControls() {
const requestVersion = useRef(0)
const bridge = getDesktopBridge()?.computerUse
- const enabled = useFeatureFlag('mothership-computer-use')
const { status, setStatus, refresh, error } = useComputerUseStatus()
const [apps, setApps] = useState([])
const [pending, setPending] = useState(false)
@@ -36,13 +44,12 @@ function ComputerUseSettingsControls() {
}
}, [bridge])
useEffect(() => {
- if (!enabled) return
void refreshApps()
return () => {
requestVersion.current += 1
}
- }, [enabled, refreshApps, status?.activeAction])
- if (!bridge || !enabled || status?.supported === false) return null
+ }, [refreshApps, status?.activeAction])
+ if (!bridge || status?.supported === false) return null
const update = async (action: () => Promise) => {
setPending(true)
try {
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.test.tsx
index 5d4ee1e2bf5..c847090771d 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.test.tsx
@@ -8,6 +8,16 @@ import { nextNavigationMock } from '@sim/testing/mocks/next-navigation.mock'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
+const permission = vi.hoisted(() => ({
+ config: null as Record | null,
+ ready: true,
+}))
+vi.mock('@/ee/access-control/hooks/permission-groups', () => ({
+ useUserPermissionConfig: () =>
+ permission.ready
+ ? { isSuccess: true, data: { config: permission.config } }
+ : { isSuccess: false, data: undefined },
+}))
vi.mock('@sim/emcn', () => ({
...emcnMock,
Label: ({ children, htmlFor }: { children?: ReactNode; htmlFor?: string }) => (
@@ -66,7 +76,6 @@ async function render(device: { deviceId: string } | null) {
changelog: false,
'mothership-model-selector': false,
'mothership-plan-mode': false,
- 'mothership-computer-use': false,
}}
>
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx
index 3061fb6958f..72f647676b6 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx
@@ -28,9 +28,12 @@ import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflow
import { BrandIcon } from '@/blocks/brand-icon'
import type { SubBlockConfig } from '@/blocks/types'
import { useWorkspaceCredential, useWorkspaceCredentials } from '@/hooks/queries/credentials'
+import { useIntegrationAvailability } from '@/hooks/queries/integration-availability'
import { useOAuthCredentials } from '@/hooks/queries/oauth/credentials'
import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers'
+import { useIntegrationAccess } from '@/hooks/use-integration-access'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
+import { useWorkflowStore } from '@/stores/workflows/workflow/store'
interface CredentialSelectorProps {
blockId: string
@@ -57,6 +60,7 @@ export function CredentialSelector({
const [editingValue, setEditingValue] = useState('')
const [isEditing, setIsEditing] = useState(false)
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
+ const blockType = useWorkflowStore((state) => state.blocks[blockId]?.type)
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
const label = subBlock.placeholder || 'Select credential'
@@ -100,6 +104,20 @@ export function CredentialSelector({
const credentialKind = subBlock.credentialKind
const isMergedKinds = credentialKind === 'any'
+ const { getAccess } = useIntegrationAccess(workspaceId, {
+ enabled: !isAllCredentials && credentialKind !== 'service-account',
+ })
+ const { data: integrationAvailability, isSuccess: isAvailabilityReady } =
+ useIntegrationAvailability({
+ enabled: !isAllCredentials && credentialKind !== 'service-account',
+ })
+ const oauthConnectAvailable =
+ Boolean(blockType && getAccess(blockType) === 'available') &&
+ isAvailabilityReady &&
+ (integrationAvailability?.oauthServiceAvailability.some(
+ ({ providerId, available }) => providerId === effectiveProviderId && available
+ ) ??
+ false)
const credentials = useMemo(() => {
// A service-account picker lists only the reusable service-account
@@ -233,8 +251,9 @@ export function CredentialSelector({
setShowSetupModal(true)
return
}
+ if (!oauthConnectAvailable) return
setShowConnectModal(true)
- }, [credentialKind])
+ }, [credentialKind, oauthConnectAvailable])
const getProviderIcon = useCallback((providerName: OAuthProvider) => {
const { baseProvider } = parseProvider(providerName)
@@ -278,7 +297,9 @@ export function CredentialSelector({
// Suppress the setup action when the service-account flow is preview-gated
// for this viewer (a custom Slack bot needs slack_v2) — existing accounts
// above stay selectable.
- if (credentialKind !== 'service-account' || !serviceAccountConnectHidden) {
+ if (
+ credentialKind === 'service-account' ? !serviceAccountConnectHidden : oauthConnectAvailable
+ ) {
options.push({
label:
credentialKind === 'service-account'
@@ -302,6 +323,7 @@ export function CredentialSelector({
credentialKind,
subBlock.credentialLabels,
serviceAccountConnectHidden,
+ oauthConnectAvailable,
serviceAccountTarget,
provider,
getProviderIcon,
@@ -323,11 +345,15 @@ export function CredentialSelector({
section: labels?.oauthGroup ?? `${getProviderName(provider)} accounts`,
items: [
...credentials.filter((c) => c.type !== 'service_account').map(toOption),
- {
- label: labels?.oauthConnect ?? `Connect ${getProviderName(provider)} account`,
- value: '__connect_account__',
- iconElement: ,
- },
+ ...(oauthConnectAvailable
+ ? [
+ {
+ label: labels?.oauthConnect ?? `Connect ${getProviderName(provider)} account`,
+ value: '__connect_account__',
+ iconElement: ,
+ },
+ ]
+ : []),
],
},
{
@@ -355,6 +381,7 @@ export function CredentialSelector({
subBlock.credentialLabels,
credentials,
serviceAccountConnectHidden,
+ oauthConnectAvailable,
serviceAccountTarget,
provider,
getProviderIcon,
@@ -407,10 +434,6 @@ export function CredentialSelector({
const handleComboboxChange = useCallback(
(value: string) => {
if (value === '__connect_account__') {
- if (isMergedKinds) {
- setShowConnectModal(true)
- return
- }
handleAddCredential()
return
}
@@ -432,14 +455,7 @@ export function CredentialSelector({
setIsEditing(true)
setEditingValue(value)
},
- [
- isAllCredentials,
- isMergedKinds,
- allWorkspaceCredentials,
- credentials,
- handleAddCredential,
- handleSelect,
- ]
+ [isAllCredentials, allWorkspaceCredentials, credentials, handleAddCredential, handleSelect]
)
return (
@@ -470,31 +486,32 @@ export function CredentialSelector({
{dataversePolicy.message}
- {!dataversePolicy.hasInvalidEnvironment && (
- {
- if (dataversePolicy.requiresSeparateCredential) {
- setShowConnectModal(true)
- return
- }
- writeOAuthReturnContext({
- origin: 'workflow',
- workflowId: activeWorkflowId || '',
- displayName: selectedCredential?.name ?? getProviderName(provider),
- providerId: effectiveProviderId,
- preCount: credentials.filter((c) => c.type !== 'service_account').length,
- workspaceId,
- reconnect: true,
- requestedAt: Date.now(),
- })
- setShowOAuthModal(true)
- }}
- >
- {dataversePolicy.actionLabel}
-
- )}
+ {!dataversePolicy.hasInvalidEnvironment &&
+ (!dataversePolicy.requiresSeparateCredential || oauthConnectAvailable) && (
+ {
+ if (dataversePolicy.requiresSeparateCredential) {
+ setShowConnectModal(true)
+ return
+ }
+ writeOAuthReturnContext({
+ origin: 'workflow',
+ workflowId: activeWorkflowId || '',
+ displayName: selectedCredential?.name ?? getProviderName(provider),
+ providerId: effectiveProviderId,
+ preCount: credentials.filter((c) => c.type !== 'service_account').length,
+ workspaceId,
+ reconnect: true,
+ requestedAt: Date.now(),
+ })
+ setShowOAuthModal(true)
+ }}
+ >
+ {dataversePolicy.actionLabel}
+
+ )}
)}
diff --git a/apps/sim/blocks/blocks/agent.ts b/apps/sim/blocks/blocks/agent.ts
index 39d2c1ce012..ba0ddfd509f 100644
--- a/apps/sim/blocks/blocks/agent.ts
+++ b/apps/sim/blocks/blocks/agent.ts
@@ -4,6 +4,7 @@ import { getModelFallbackSubBlock, MODEL_FALLBACK_INPUTS } from '@/blocks/model-
import type { BlockConfig } from '@/blocks/types'
import { AuthMode, IntegrationType } from '@/blocks/types'
import {
+ BEDROCK_SAVED_CREDENTIAL_INPUTS,
getAgentModelOptions,
getModelCapabilityCondition,
getProviderCredentialSubBlocks,
@@ -136,7 +137,7 @@ Return ONLY the JSON array.`,
options: getAgentModelOptions,
commandSearchable: true,
},
- ...getProviderCredentialSubBlocks(),
+ ...getProviderCredentialSubBlocks({ bedrockSavedCredential: true }),
{
id: 'evaluationState',
title: 'State',
@@ -559,6 +560,7 @@ Return ONLY the JSON array.`,
bedrockAccessKeyId: { type: 'string', description: 'AWS Access Key ID for Bedrock' },
bedrockSecretKey: { type: 'string', description: 'AWS Secret Access Key for Bedrock' },
bedrockRegion: { type: 'string', description: 'AWS region for Bedrock' },
+ ...BEDROCK_SAVED_CREDENTIAL_INPUTS,
responseFormat: {
type: 'json',
description: 'JSON response format schema',
diff --git a/apps/sim/blocks/blocks/amplitude.ts b/apps/sim/blocks/blocks/amplitude.ts
index f2ce1a2f318..0adc3b1ce57 100644
--- a/apps/sim/blocks/blocks/amplitude.ts
+++ b/apps/sim/blocks/blocks/amplitude.ts
@@ -1,5 +1,6 @@
import { AmplitudeIcon } from '@/components/icons'
import { AuthMode, type BlockConfig, type BlockMeta, IntegrationType } from '@/blocks/types'
+import { withSavedCredential } from '@/blocks/utils'
/*
* Amplitude identifies a person by user id or device id — either one alone is
@@ -12,6 +13,8 @@ const PROFILE_IDENTITY_FIELD = ['profileUserId', 'profileDeviceId'] as const
export const AmplitudeBlock: BlockConfig = {
type: 'amplitude',
+ hideFromToolbar: true,
+ sunset: { status: 'legacy', replacedBy: 'amplitude_v2' },
name: 'Amplitude',
description: 'Track events and query analytics from Amplitude',
longDescription:
@@ -1231,6 +1234,29 @@ export const AmplitudeBlock: BlockConfig = {
},
}
+/**
+ * Every operation authenticates with the project credential: the ingestion operations send its
+ * API key, and the analytics and User Profile operations send both keys, so the picker shows for
+ * all of them.
+ */
+export const AmplitudeV2Block: BlockConfig = {
+ ...AmplitudeBlock,
+ type: 'amplitude_v2',
+ authMode: AuthMode.OAuth,
+ hideFromToolbar: false,
+ sunset: undefined,
+ ...withSavedCredential(
+ AmplitudeBlock,
+ {
+ id: 'credential',
+ type: 'oauth-input',
+ serviceId: 'amplitude',
+ title: 'Amplitude Credential',
+ },
+ { condition: null, required: true }
+ ),
+}
+
export const AmplitudeBlockMeta = {
tags: ['data-analytics', 'marketing'],
url: 'https://amplitude.com',
diff --git a/apps/sim/blocks/blocks/apollo.ts b/apps/sim/blocks/blocks/apollo.ts
index df3cc143894..aba5a2b5a8a 100644
--- a/apps/sim/blocks/blocks/apollo.ts
+++ b/apps/sim/blocks/blocks/apollo.ts
@@ -1600,5 +1600,6 @@ export const ApolloV2Block: BlockConfig = {
type: 'oauth-input',
serviceId: 'apollo',
title: 'Apollo Credential',
+ credentialKind: 'any',
}),
}
diff --git a/apps/sim/blocks/blocks/athena.ts b/apps/sim/blocks/blocks/athena.ts
index 923de16a681..eb99711a3ca 100644
--- a/apps/sim/blocks/blocks/athena.ts
+++ b/apps/sim/blocks/blocks/athena.ts
@@ -341,6 +341,48 @@ Return ONLY a JSON array of strings — no explanations, no markdown code blocks
},
mode: 'advanced',
},
+ {
+ id: 'encryptionOption',
+ title: 'Result Encryption',
+ type: 'dropdown',
+ options: [
+ { label: 'Workgroup default', id: '' },
+ { label: 'SSE-S3', id: 'SSE_S3' },
+ { label: 'SSE-KMS', id: 'SSE_KMS' },
+ { label: 'CSE-KMS', id: 'CSE_KMS' },
+ ],
+ value: () => '',
+ condition: { field: 'operation', value: 'start_query' },
+ mode: 'advanced',
+ },
+ {
+ id: 'kmsKey',
+ title: 'KMS Key',
+ type: 'short-input',
+ placeholder: 'arn:aws:kms:us-east-1:123456789012:key/...',
+ condition: {
+ field: 'operation',
+ value: 'start_query',
+ and: { field: 'encryptionOption', value: ['SSE_KMS', 'CSE_KMS'] },
+ },
+ mode: 'advanced',
+ },
+ {
+ id: 'expectedBucketOwner',
+ title: 'Expected Bucket Owner',
+ type: 'short-input',
+ placeholder: '123456789012',
+ condition: { field: 'operation', value: 'start_query' },
+ mode: 'advanced',
+ },
+ {
+ id: 'clientRequestToken',
+ title: 'Client Request Token',
+ type: 'short-input',
+ placeholder: 'Unique 32-128 character idempotency token',
+ condition: { field: 'operation', value: 'start_query' },
+ mode: 'advanced',
+ },
{
id: 'queryExecutionId',
title: 'Query Execution ID',
@@ -670,6 +712,12 @@ Return ONLY a JSON array of strings — no explanations, no markdown code blocks
...(resultReuseEnabled !== undefined && { resultReuseEnabled }),
...(resultReuseEnabled &&
resultReuseMaxAgeInMinutes !== undefined && { resultReuseMaxAgeInMinutes }),
+ ...(rest.encryptionOption && { encryptionOption: rest.encryptionOption }),
+ ...(rest.encryptionOption &&
+ rest.encryptionOption !== 'SSE_S3' &&
+ rest.kmsKey && { kmsKey: rest.kmsKey }),
+ ...(rest.expectedBucketOwner && { expectedBucketOwner: rest.expectedBucketOwner }),
+ ...(rest.clientRequestToken && { clientRequestToken: rest.clientRequestToken }),
}
case 'get_query_execution':
@@ -1014,6 +1062,19 @@ Return ONLY a JSON array of strings — no explanations, no markdown code blocks
description: 'Comma-separated prepared statement names (up to 256)',
},
tableName: { type: 'string', description: 'Table name to get metadata for' },
+ encryptionOption: {
+ type: 'string',
+ description: 'Query result encryption (SSE_S3, SSE_KMS, CSE_KMS)',
+ },
+ kmsKey: { type: 'string', description: 'KMS key for SSE_KMS or CSE_KMS result encryption' },
+ expectedBucketOwner: {
+ type: 'string',
+ description: 'Account ID expected to own the output location bucket',
+ },
+ clientRequestToken: {
+ type: 'string',
+ description: 'Idempotency token for starting a query (32-128 characters)',
+ },
},
outputs: {
queryExecutionId: {
@@ -1081,9 +1142,38 @@ Return ONLY a JSON array of strings — no explanations, no markdown code blocks
type: 'string',
description: 'S3 location of query results',
},
+ servicePreProcessingTimeInMillis: {
+ type: 'number',
+ description: 'Time Athena spent preprocessing the query, in ms',
+ },
+ serviceProcessingTimeInMillis: {
+ type: 'number',
+ description: 'Time Athena spent publishing results after execution, in ms',
+ },
+ dataManifestLocation: {
+ type: 'string',
+ description: 'S3 location of the manifest of files written by the query',
+ },
+ reusedPreviousResult: {
+ type: 'boolean',
+ description: 'Whether the query reused a previous result',
+ },
+ athenaError: {
+ type: 'json',
+ description:
+ 'Failure details for a FAILED query (errorCategory, errorType, retryable, errorMessage)',
+ },
+ executionParameters: {
+ type: 'array',
+ description: 'Values supplied for the query placeholders',
+ },
+ substatementType: {
+ type: 'string',
+ description: 'Kind of statement that was run (e.g., SELECT, INSERT)',
+ },
columns: {
type: 'array',
- description: 'Column metadata (name and type)',
+ description: 'Column metadata (name, type, label, precision, scale, nullable, caseSensitive)',
},
rows: {
type: 'array',
@@ -1212,7 +1302,7 @@ Return ONLY a JSON array of strings — no explanations, no markdown code blocks
},
engineVersion: {
type: 'json',
- description: 'Workgroup engine version (selected and effective)',
+ description: 'Engine version of the workgroup or query execution (selected and effective)',
},
identityCenterApplicationArn: {
type: 'string',
diff --git a/apps/sim/blocks/blocks/aws-stale-inputs.test.ts b/apps/sim/blocks/blocks/aws-stale-inputs.test.ts
new file mode 100644
index 00000000000..c4fac1d3568
--- /dev/null
+++ b/apps/sim/blocks/blocks/aws-stale-inputs.test.ts
@@ -0,0 +1,107 @@
+/**
+ * The executor hands a tool `{ ...rawInputs, ...blockParams }`, so a subBlock value the
+ * block mapper leaves unassigned reaches the tool raw. These cases keep stored values
+ * from another operation, and empty table rows, out of requests.
+ */
+import { describe, expect, it } from 'vitest'
+import { awsCloudformationCreateChangeSetContract } from '@/lib/api/contracts/tools/aws/cloudformation-create-change-set'
+import { awsDynamodbScanContract } from '@/lib/api/contracts/tools/aws/dynamodb-scan'
+import { awsIamCreateUserContract } from '@/lib/api/contracts/tools/aws/iam-create-user'
+import { awsIamListPoliciesContract } from '@/lib/api/contracts/tools/aws/iam-list-policies'
+import { awsSesSendTemplatedEmailContract } from '@/lib/api/contracts/tools/aws/ses-send-templated-email'
+import { CloudFormationBlock } from '@/blocks/blocks/cloudformation'
+import { DynamoDBBlock } from '@/blocks/blocks/dynamodb'
+import { IAMBlock } from '@/blocks/blocks/iam'
+import { SESBlock } from '@/blocks/blocks/ses'
+import type { BlockConfig } from '@/blocks/types'
+import { createChangeSetTool } from '@/tools/cloudformation/create_change_set'
+import { scanTool } from '@/tools/dynamodb/scan'
+import { createUserTool } from '@/tools/iam/create_user'
+import { listPoliciesTool } from '@/tools/iam/list_policies'
+import { sendTemplatedEmailTool } from '@/tools/ses/send_templated_email'
+
+const AWS = { region: 'us-east-1', accessKeyId: 'AKIAEXAMPLE', secretAccessKey: 'secret' }
+
+function executorParams(block: BlockConfig, inputs: Record) {
+ const mapped = block.tools.config?.params?.(inputs) ?? {}
+ return { ...inputs, ...mapped } as Record
+}
+
+describe('AWS blocks keep stale subBlock values out of tool input', () => {
+ it('scan ignores the get consistentRead and query indexName values', () => {
+ const params = executorParams(DynamoDBBlock, {
+ ...AWS,
+ operation: 'scan',
+ tableName: 'orders',
+ consistentRead: 'false',
+ indexName: 'by-customer',
+ })
+ const body = scanTool.operation.input(params as never) as Record
+ expect(body.indexName).toBeUndefined()
+ expect(awsDynamodbScanContract.body?.safeParse(body).success).toBe(true)
+ })
+
+ it('templated send ignores the send_email Reply-To', () => {
+ const params = executorParams(SESBlock, {
+ ...AWS,
+ operation: 'send_templated_email',
+ fromAddress: 'from@example.com',
+ toAddresses: 'to@example.com',
+ templateName: 'welcome',
+ templateData: '{}',
+ replyToAddresses: 'stale@example.com',
+ })
+ const body = sendTemplatedEmailTool.operation.input(params as never) as Record
+ expect(body.replyToAddresses).toBeUndefined()
+ expect(awsSesSendTemplatedEmailContract.body?.safeParse(body).success).toBe(true)
+ })
+
+ it('create change set ignores the stack tags', () => {
+ const params = executorParams(CloudFormationBlock, {
+ awsRegion: 'us-east-1',
+ awsAccessKeyId: 'AKIAEXAMPLE',
+ awsSecretAccessKey: 'secret',
+ operation: 'create_change_set',
+ stackName: 'app',
+ changeSetName: 'cs-1',
+ templateBody: '{}',
+ tags: '[{"key":"team","value":"old"}]',
+ })
+ const body = createChangeSetTool.operation.input(params as never) as Record
+ expect(body.tags).toBeUndefined()
+ expect(awsCloudformationCreateChangeSetContract.body?.safeParse(body).success).toBe(true)
+ })
+
+ it('create user accepts an untouched Tags table', () => {
+ const params = executorParams(IAMBlock, {
+ ...AWS,
+ operation: 'create_user',
+ userName: 'alice',
+ tags: [{ id: 'row-1', cells: { key: '', value: '' } }],
+ })
+ const body = createUserTool.operation.input(params as never)
+ expect(awsIamCreateUserContract.body?.safeParse(body).success).toBe(true)
+ })
+
+ it('list policies with the All usage filter sends no filter', () => {
+ const params = executorParams(IAMBlock, {
+ ...AWS,
+ operation: 'list_policies',
+ policyUsageFilter: 'all',
+ })
+ const body = listPoliciesTool.operation.input(params as never) as Record
+ expect(body.policyUsageFilter).toBeUndefined()
+ expect(awsIamListPoliciesContract.body?.safeParse(body).success).toBe(true)
+ })
+
+ it('create user rejects a JSON-array tags string with no valid entries', () => {
+ expect(() =>
+ executorParams(IAMBlock, {
+ ...AWS,
+ operation: 'create_user',
+ userName: 'alice',
+ tags: '[{"name":"team","val":"core"}]',
+ })
+ ).toThrow('Tags must be a list of {"key", "value"} entries or a JSON object')
+ })
+})
diff --git a/apps/sim/blocks/blocks/azure_data_explorer.ts b/apps/sim/blocks/blocks/azure_data_explorer.ts
index cf7afe9e2a4..8327179942c 100644
--- a/apps/sim/blocks/blocks/azure_data_explorer.ts
+++ b/apps/sim/blocks/blocks/azure_data_explorer.ts
@@ -1,6 +1,7 @@
import { AzureDataExplorerIcon } from '@/components/icons'
import type { BlockConfig, BlockMeta } from '@/blocks/types'
import { AuthMode, IntegrationType } from '@/blocks/types'
+import { withSavedCredential } from '@/blocks/utils'
/** Every operation except the cluster-level database listing runs in a database. */
const DATABASE_SCOPED_OPERATIONS = [
@@ -109,6 +110,8 @@ function toBoolean(value: unknown): true | undefined {
export const AzureDataExplorerBlock: BlockConfig = {
type: 'azure_data_explorer',
+ hideFromToolbar: true,
+ sunset: { status: 'legacy', replacedBy: 'azure_data_explorer_v2' },
name: 'Azure Data Explorer',
description: 'Query and manage Azure Data Explorer (Kusto) clusters with KQL',
longDescription:
@@ -537,6 +540,11 @@ export const AzureDataExplorerBlock: BlockConfig = {
rowCount: { type: 'number', description: 'Rows carried in this result, after the row cap' },
totalRowCount: { type: 'number', description: 'Rows Kusto returned, before the row cap' },
truncated: { type: 'boolean', description: 'Whether rows were dropped to stay within the cap' },
+ additionalResults: {
+ type: 'json',
+ description:
+ 'Further result tables when a query batch or fork returns more than one (Run Query); each has tableName, columns, rows, records, rowCount, totalRowCount, truncated',
+ },
databases: { type: 'json', description: 'Database names (List Databases)' },
tables: { type: 'json', description: 'Table names (List Tables)' },
extentIds: { type: 'json', description: 'Extent IDs created (Ingest Rows Inline)' },
@@ -654,3 +662,24 @@ export const AzureDataExplorerBlockMeta = {
},
],
} as const satisfies BlockMeta
+
+/**
+ * Runs as a saved Microsoft app credential (client secret, certificate, or keyless federated
+ * credential) instead of an inline client secret. The cluster stays a block field; the token is
+ * minted for it in the credential's own cloud.
+ */
+export const AzureDataExplorerV2Block: BlockConfig = {
+ ...AzureDataExplorerBlock,
+ type: 'azure_data_explorer_v2',
+ authMode: AuthMode.OAuth,
+ hideFromToolbar: false,
+ sunset: undefined,
+ longDescription:
+ 'Run Kusto Query Language queries against Azure Data Explorer and Fabric Eventhouse clusters, discover databases, tables, and schemas, push small batches of rows inline, and run management commands. Runs as a Microsoft app credential: an app registration signing in with a client secret, a certificate, or a keyless federated credential, in the global cloud or a national cloud, so no interactive sign-in is needed.',
+ ...withSavedCredential(AzureDataExplorerBlock, {
+ id: 'credential',
+ type: 'oauth-input',
+ serviceId: 'azure-data-explorer',
+ title: 'Microsoft App Credential',
+ }),
+}
diff --git a/apps/sim/blocks/blocks/azure_devops.ts b/apps/sim/blocks/blocks/azure_devops.ts
index 00e429090a7..6ef93a310c0 100644
--- a/apps/sim/blocks/blocks/azure_devops.ts
+++ b/apps/sim/blocks/blocks/azure_devops.ts
@@ -13,6 +13,15 @@ function normalizeDate(input: unknown): string | undefined {
return /^\d{4}-\d{2}-\d{2}$/.test(value) ? `${value}T00:00:00Z` : value
}
+/**
+ * Dedicated Max Results value for an operation, falling back to the shared List Builds `top`
+ * that these tools read before they had their own field, so saved workflows keep their page size.
+ */
+function resolveTop(dedicated: unknown, shared: unknown): number | undefined {
+ const value = dedicated || shared
+ return value ? Number(value) : undefined
+}
+
export const AzureDevOpsBlock: BlockConfig = {
type: 'azure_devops',
hideFromToolbar: true,
@@ -198,6 +207,145 @@ export const AzureDevOpsBlock: BlockConfig = {
condition: { field: 'operation', value: 'azure_devops_list_builds' },
mode: 'advanced',
},
+ {
+ id: 'pipelinesTop',
+ title: 'Max Results',
+ type: 'short-input',
+ placeholder: 'Server default when empty',
+ condition: { field: 'operation', value: 'azure_devops_list_pipelines' },
+ mode: 'advanced',
+ },
+ {
+ id: 'queryTop',
+ title: 'Max Results',
+ type: 'short-input',
+ placeholder: 'Server default when empty',
+ condition: { field: 'operation', value: 'azure_devops_query_work_items' },
+ mode: 'advanced',
+ },
+ {
+ id: 'commentsTop',
+ title: 'Max Results',
+ type: 'short-input',
+ placeholder: 'Server default when empty',
+ condition: { field: 'operation', value: 'azure_devops_get_comments' },
+ mode: 'advanced',
+ },
+ {
+ id: 'definitionIds',
+ title: 'Pipeline Definition IDs',
+ type: 'short-input',
+ placeholder: 'Comma-separated, e.g. 12,34',
+ condition: { field: 'operation', value: 'azure_devops_list_builds' },
+ mode: 'advanced',
+ },
+ {
+ id: 'statusFilter',
+ title: 'Filter by Status',
+ type: 'dropdown',
+ options: [
+ { label: 'Any', id: '' },
+ { label: 'In Progress', id: 'inProgress' },
+ { label: 'Completed', id: 'completed' },
+ { label: 'Cancelling', id: 'cancelling' },
+ { label: 'Postponed', id: 'postponed' },
+ { label: 'Not Started', id: 'notStarted' },
+ { label: 'No Status', id: 'none' },
+ ],
+ condition: { field: 'operation', value: 'azure_devops_list_builds' },
+ mode: 'advanced',
+ },
+ {
+ id: 'branchName',
+ title: 'Branch',
+ type: 'short-input',
+ placeholder: 'e.g. refs/heads/main',
+ condition: { field: 'operation', value: 'azure_devops_list_builds' },
+ mode: 'advanced',
+ },
+ {
+ id: 'reasonFilter',
+ title: 'Build Reason',
+ type: 'dropdown',
+ options: [
+ { label: 'Any', id: '' },
+ { label: 'Manual', id: 'manual' },
+ { label: 'CI', id: 'individualCI' },
+ { label: 'Batched CI', id: 'batchedCI' },
+ { label: 'Schedule', id: 'schedule' },
+ { label: 'Pull Request', id: 'pullRequest' },
+ { label: 'Build Completion', id: 'buildCompletion' },
+ { label: 'Resource Trigger', id: 'resourceTrigger' },
+ ],
+ value: () => '',
+ condition: { field: 'operation', value: 'azure_devops_list_builds' },
+ mode: 'advanced',
+ },
+ {
+ id: 'requestedFor',
+ title: 'Requested For',
+ type: 'short-input',
+ placeholder: 'User email or ID',
+ condition: { field: 'operation', value: 'azure_devops_list_builds' },
+ mode: 'advanced',
+ },
+ {
+ id: 'queryOrder',
+ title: 'Order',
+ type: 'dropdown',
+ options: [
+ { label: 'Default', id: '' },
+ { label: 'Queued (newest first)', id: 'queueTimeDescending' },
+ { label: 'Queued (oldest first)', id: 'queueTimeAscending' },
+ { label: 'Started (newest first)', id: 'startTimeDescending' },
+ { label: 'Started (oldest first)', id: 'startTimeAscending' },
+ { label: 'Finished (newest first)', id: 'finishTimeDescending' },
+ { label: 'Finished (oldest first)', id: 'finishTimeAscending' },
+ ],
+ value: () => '',
+ condition: { field: 'operation', value: 'azure_devops_list_builds' },
+ mode: 'advanced',
+ },
+ {
+ id: 'minTime',
+ title: 'After',
+ type: 'short-input',
+ placeholder: 'ISO 8601, e.g. 2026-01-01T00:00:00Z',
+ condition: { field: 'operation', value: 'azure_devops_list_builds' },
+ mode: 'advanced',
+ },
+ {
+ id: 'maxTime',
+ title: 'Before',
+ type: 'short-input',
+ placeholder: 'ISO 8601, e.g. 2026-01-31T23:59:59Z',
+ condition: { field: 'operation', value: 'azure_devops_list_builds' },
+ mode: 'advanced',
+ },
+ {
+ id: 'continuationToken',
+ title: 'Continuation Token',
+ type: 'short-input',
+ placeholder: 'From a previous page',
+ condition: { field: 'operation', value: 'azure_devops_list_builds' },
+ mode: 'advanced',
+ },
+ {
+ id: 'pipelinesContinuationToken',
+ title: 'Continuation Token',
+ type: 'short-input',
+ placeholder: 'From a previous page',
+ condition: { field: 'operation', value: 'azure_devops_list_pipelines' },
+ mode: 'advanced',
+ },
+ {
+ id: 'commentsContinuationToken',
+ title: 'Continuation Token',
+ type: 'short-input',
+ placeholder: 'From a previous page',
+ condition: { field: 'operation', value: 'azure_devops_get_comments' },
+ mode: 'advanced',
+ },
{
id: 'buildId',
title: 'Build ID',
@@ -219,6 +367,22 @@ export const AzureDevOpsBlock: BlockConfig = {
required: true,
condition: { field: 'operation', value: 'azure_devops_get_build_log' },
},
+ {
+ id: 'startLine',
+ title: 'Start Line',
+ type: 'short-input',
+ placeholder: 'First line to return',
+ condition: { field: 'operation', value: 'azure_devops_get_build_log' },
+ mode: 'advanced',
+ },
+ {
+ id: 'endLine',
+ title: 'End Line',
+ type: 'short-input',
+ placeholder: 'Last line to return',
+ condition: { field: 'operation', value: 'azure_devops_get_build_log' },
+ mode: 'advanced',
+ },
{
id: 'fromBuildId',
title: 'From Build ID',
@@ -480,6 +644,14 @@ export const AzureDevOpsBlock: BlockConfig = {
condition: { field: 'operation', value: 'azure_devops_create_work_item' },
mode: 'advanced',
},
+ {
+ id: 'updateIterationPath',
+ title: 'Iteration Path',
+ type: 'short-input',
+ placeholder: 'e.g. MyProject\\Sprint 2',
+ condition: { field: 'operation', value: 'azure_devops_update_work_item' },
+ mode: 'advanced',
+ },
{
id: 'tags',
title: 'Tags',
@@ -508,6 +680,18 @@ export const AzureDevOpsBlock: BlockConfig = {
required: true,
condition: { field: 'operation', value: 'azure_devops_add_comment' },
},
+ {
+ id: 'commentFormat',
+ title: 'Comment Format',
+ type: 'dropdown',
+ options: [
+ { label: 'Default', id: '' },
+ { label: 'Markdown', id: 'markdown' },
+ { label: 'HTML', id: 'html' },
+ ],
+ condition: { field: 'operation', value: 'azure_devops_add_comment' },
+ mode: 'advanced',
+ },
...getTrigger('azure_devops_build_failed').subBlocks,
...getTrigger('azure_devops_work_item_created').subBlocks,
...getTrigger('azure_devops_webhook').subBlocks,
@@ -542,7 +726,11 @@ export const AzureDevOpsBlock: BlockConfig = {
}
switch (params.operation) {
case 'azure_devops_list_pipelines':
- return base
+ return {
+ ...base,
+ top: resolveTop(params.pipelinesTop, params.top),
+ continuationToken: (params.pipelinesContinuationToken as string) || undefined,
+ }
case 'azure_devops_get_pipeline':
return { ...base, pipelineId: Number(params.pipelineId) }
case 'azure_devops_list_pipeline_runs':
@@ -554,11 +742,26 @@ export const AzureDevOpsBlock: BlockConfig = {
...base,
resultFilter: (params.resultFilter as string) || undefined,
top: params.top ? Number(params.top) : undefined,
+ definitionIds: (params.definitionIds as string) || undefined,
+ statusFilter: (params.statusFilter as string) || undefined,
+ branchName: (params.branchName as string) || undefined,
+ reasonFilter: (params.reasonFilter as string) || undefined,
+ requestedFor: (params.requestedFor as string) || undefined,
+ queryOrder: (params.queryOrder as string) || undefined,
+ minTime: normalizeDate(params.minTime),
+ maxTime: normalizeDate(params.maxTime),
+ continuationToken: (params.continuationToken as string) || undefined,
}
case 'azure_devops_list_build_logs':
return { ...base, buildId: Number(params.buildId) }
case 'azure_devops_get_build_log':
- return { ...base, buildId: Number(params.buildId), logId: Number(params.logId) }
+ return {
+ ...base,
+ buildId: Number(params.buildId),
+ logId: Number(params.logId),
+ startLine: params.startLine ? Number(params.startLine) : undefined,
+ endLine: params.endLine ? Number(params.endLine) : undefined,
+ }
case 'azure_devops_get_build_timeline':
return { ...base, buildId: Number(params.buildId) }
case 'azure_devops_get_work_items_between_builds':
@@ -568,7 +771,11 @@ export const AzureDevOpsBlock: BlockConfig = {
toBuildId: Number(params.toBuildId),
}
case 'azure_devops_query_work_items':
- return { ...base, wiqlQuery: params.wiqlQuery as string }
+ return {
+ ...base,
+ wiqlQuery: params.wiqlQuery as string,
+ top: params.queryTop ? Number(params.queryTop) : undefined,
+ }
case 'azure_devops_get_work_item':
return { ...base, workItemId: Number(params.workItemId) }
case 'azure_devops_get_work_items_batch':
@@ -607,6 +814,7 @@ export const AzureDevOpsBlock: BlockConfig = {
completedWork: params.completedWork ? Number(params.completedWork) : undefined,
description: (params.description as string) || undefined,
areaPath: (params.areaPath as string) || undefined,
+ iterationPath: (params.updateIterationPath as string) || undefined,
tags: (params.tags as string) || undefined,
}
case 'azure_devops_add_comment':
@@ -614,9 +822,15 @@ export const AzureDevOpsBlock: BlockConfig = {
...base,
workItemId: Number(params.workItemId),
text: params.commentText as string,
+ format: (params.commentFormat as string) || undefined,
}
case 'azure_devops_get_comments':
- return { ...base, workItemId: Number(params.workItemId) }
+ return {
+ ...base,
+ workItemId: Number(params.workItemId),
+ top: resolveTop(params.commentsTop, params.top),
+ continuationToken: (params.commentsContinuationToken as string) || undefined,
+ }
default:
return base
}
@@ -633,6 +847,9 @@ export const AzureDevOpsBlock: BlockConfig = {
runId: { type: 'number', description: 'Pipeline run ID' },
resultFilter: { type: 'string', description: 'Build result filter' },
top: { type: 'number', description: 'Maximum number of results' },
+ pipelinesTop: { type: 'number', description: 'Maximum pipelines to return (list pipelines)' },
+ queryTop: { type: 'number', description: 'Maximum work items to return (query work items)' },
+ commentsTop: { type: 'number', description: 'Maximum comments to return (get comments)' },
buildId: { type: 'number', description: 'Build ID' },
logId: { type: 'number', description: 'Build log ID' },
fromBuildId: { type: 'number', description: 'Starting build ID for work item range' },
@@ -673,12 +890,33 @@ export const AzureDevOpsBlock: BlockConfig = {
},
areaPath: { type: 'string', description: 'Area path' },
iterationPath: { type: 'string', description: 'Iteration path' },
+ updateIterationPath: { type: 'string', description: 'New iteration path (update work item)' },
tags: { type: 'string', description: 'Semicolon-separated tags' },
state: {
type: 'string',
description: 'Basic-process work item state (To Do, Doing, Done)',
},
commentText: { type: 'string', description: 'Comment text' },
+ commentFormat: { type: 'string', description: 'Comment format (markdown or html)' },
+ definitionIds: { type: 'string', description: 'Comma-separated pipeline definition IDs' },
+ statusFilter: { type: 'string', description: 'Build status filter' },
+ branchName: { type: 'string', description: 'Source branch filter (e.g. refs/heads/main)' },
+ reasonFilter: { type: 'string', description: 'Build reason filter (list builds)' },
+ requestedFor: { type: 'string', description: 'Requested-for user filter (list builds)' },
+ queryOrder: { type: 'string', description: 'Build sort order (list builds)' },
+ minTime: { type: 'string', description: 'Only builds after this ISO 8601 time' },
+ maxTime: { type: 'string', description: 'Only builds before this ISO 8601 time' },
+ continuationToken: { type: 'string', description: 'Continuation token for the next page' },
+ pipelinesContinuationToken: {
+ type: 'string',
+ description: 'Continuation token for the next page of pipelines',
+ },
+ commentsContinuationToken: {
+ type: 'string',
+ description: 'Continuation token for the next page of comments',
+ },
+ startLine: { type: 'number', description: 'First log line to return' },
+ endLine: { type: 'number', description: 'Last log line to return' },
},
outputs: {
diff --git a/apps/sim/blocks/blocks/bitbucket.ts b/apps/sim/blocks/blocks/bitbucket.ts
index efbdabc1d43..75f6a873b90 100644
--- a/apps/sim/blocks/blocks/bitbucket.ts
+++ b/apps/sim/blocks/blocks/bitbucket.ts
@@ -549,6 +549,30 @@ export const BitbucketBlock: BlockConfig = {
},
placeholder: 'src/index.ts',
},
+ {
+ id: 'commitPath',
+ title: 'Changed Path',
+ type: 'short-input',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'bitbucket_list_commits' },
+ placeholder: 'Only commits that changed this file or directory (for example: src/)',
+ },
+ {
+ id: 'commitInclude',
+ title: 'Include Refs',
+ type: 'short-input',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'bitbucket_list_commits' },
+ placeholder: 'Comma-separated branches, tags, or hashes (for example: main)',
+ },
+ {
+ id: 'commitExclude',
+ title: 'Exclude Refs',
+ type: 'short-input',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'bitbucket_list_commits' },
+ placeholder: 'Comma-separated branches, tags, or hashes to leave out',
+ },
{
id: 'state',
title: 'Pull Request State',
@@ -576,6 +600,7 @@ export const BitbucketBlock: BlockConfig = {
type: 'short-input',
required: true,
condition: { field: 'operation', value: 'bitbucket_create_pull_request' },
+ placeholder: 'Add retry handling to the sync job',
},
{
id: 'sourceBranch',
@@ -583,6 +608,7 @@ export const BitbucketBlock: BlockConfig = {
type: 'short-input',
required: true,
condition: { field: 'operation', value: 'bitbucket_create_pull_request' },
+ placeholder: 'feature/my-branch',
},
{
id: 'destinationBranch',
@@ -598,6 +624,7 @@ export const BitbucketBlock: BlockConfig = {
type: 'long-input',
mode: 'advanced',
condition: { field: 'operation', value: 'bitbucket_create_pull_request' },
+ placeholder: 'Describe the change (Markdown)',
},
{
id: 'reviewerAccountIds',
@@ -663,6 +690,7 @@ export const BitbucketBlock: BlockConfig = {
type: 'long-input',
mode: 'advanced',
condition: { field: 'operation', value: 'bitbucket_merge_pull_request' },
+ placeholder: 'Defaults to the Bitbucket-generated merge message',
},
{
id: 'taskId',
@@ -673,6 +701,7 @@ export const BitbucketBlock: BlockConfig = {
field: 'operation',
value: 'bitbucket_get_pull_request_merge_task_status',
},
+ placeholder: 'Task ID returned by Merge Pull Request',
},
{
id: 'content',
@@ -680,6 +709,7 @@ export const BitbucketBlock: BlockConfig = {
type: 'long-input',
required: true,
condition: { field: 'operation', value: 'bitbucket_create_pull_request_comment' },
+ placeholder: 'Comment text (Markdown)',
},
{
id: 'parentId',
@@ -708,6 +738,7 @@ export const BitbucketBlock: BlockConfig = {
type: 'short-input',
mode: 'advanced',
condition: { field: 'operation', value: 'bitbucket_list_pipelines' },
+ placeholder: 'main',
},
{
id: 'pipelineCommitHash',
@@ -738,6 +769,7 @@ export const BitbucketBlock: BlockConfig = {
type: 'short-input',
mode: 'advanced',
condition: { field: 'operation', value: 'bitbucket_list_pipelines' },
+ placeholder: 'deploy-to-production',
},
{
id: 'pipelineTriggerType',
@@ -797,6 +829,18 @@ export const BitbucketBlock: BlockConfig = {
condition: { field: 'operation', value: 'bitbucket_trigger_pipeline' },
placeholder: 'Optional full commit SHA on the selected branch',
},
+ {
+ id: 'targetRefType',
+ title: 'Ref Type',
+ type: 'dropdown',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'bitbucket_trigger_pipeline' },
+ options: [
+ { label: 'Branch', id: 'branch' },
+ { label: 'Tag', id: 'tag' },
+ ],
+ value: () => 'branch',
+ },
{
id: 'stepUuid',
title: 'Step UUID',
@@ -947,7 +991,13 @@ export const BitbucketBlock: BlockConfig = {
case 'bitbucket_delete_branch':
return { ...common, name: optionalString(params.branchName, 'branchName') }
case 'bitbucket_list_commits':
- return { ...common, ...pagination }
+ return {
+ ...common,
+ include: stringList(params.commitInclude, 'commitInclude'),
+ exclude: stringList(params.commitExclude, 'commitExclude'),
+ path: optionalString(params.commitPath, 'commitPath'),
+ ...pagination,
+ }
case 'bitbucket_get_commit':
return { ...common, commit: optionalString(params.revision, 'revision') }
case 'bitbucket_list_directory':
@@ -1047,7 +1097,7 @@ export const BitbucketBlock: BlockConfig = {
case 'bitbucket_trigger_pipeline':
return {
...common,
- refType: 'branch',
+ refType: optionalString(params.targetRefType, 'targetRefType') ?? 'branch',
refName: optionalString(params.targetRef, 'targetRef'),
commitHash: optionalString(params.targetCommitHash, 'targetCommitHash'),
}
@@ -1083,6 +1133,18 @@ export const BitbucketBlock: BlockConfig = {
target: { type: 'string', description: 'Full commit hash or existing ref for the new branch' },
revision: { type: 'string', description: 'Full commit SHA' },
path: { type: 'string', description: 'Repository-relative file or directory path' },
+ commitPath: {
+ type: 'string',
+ description: 'Only list commits that changed this file or directory',
+ },
+ commitInclude: {
+ type: 'string',
+ description: 'Comma-separated refs whose commits to include when listing commits',
+ },
+ commitExclude: {
+ type: 'string',
+ description: 'Comma-separated refs whose commits to exclude when listing commits',
+ },
state: { type: 'string', description: 'Pull request state filter' },
prId: { type: 'number', description: 'Pull request ID' },
title: { type: 'string', description: 'Pull request title' },
@@ -1117,6 +1179,10 @@ export const BitbucketBlock: BlockConfig = {
pipelineUuid: { type: 'string', description: 'Pipeline UUID' },
targetRef: { type: 'string', description: 'Pipeline branch target' },
targetCommitHash: { type: 'string', description: 'Optional pipeline target commit SHA' },
+ targetRefType: {
+ type: 'string',
+ description: 'Pipeline target reference type (branch or tag)',
+ },
stepUuid: { type: 'string', description: 'Pipeline step UUID' },
maxCharacters: { type: 'number', description: 'Maximum bounded raw content length' },
},
diff --git a/apps/sim/blocks/blocks/cbinsights.ts b/apps/sim/blocks/blocks/cbinsights.ts
index cde1b28a07a..d7d73cbaaf1 100644
--- a/apps/sim/blocks/blocks/cbinsights.ts
+++ b/apps/sim/blocks/blocks/cbinsights.ts
@@ -2,6 +2,7 @@ import { Building, ListChecks, Search, Sparkles, Sprout, Users } from '@sim/emcn
import { CbInsightsIcon } from '@/components/icons'
import type { BlockConfig, BlockMeta } from '@/blocks/types'
import { AuthMode, IntegrationType } from '@/blocks/types'
+import { withSavedCredential } from '@/blocks/utils'
/** Operations that take a single organization ID on the path. */
const ORG_OPERATIONS = [
@@ -63,6 +64,8 @@ const DATE_RANGE_OPERATIONS = ['get_commercial_maturity_history', 'get_exit_prob
export const CbInsightsBlock: BlockConfig = {
type: 'cbinsights',
+ hideFromToolbar: true,
+ sunset: { status: 'legacy', replacedBy: 'cbinsights_v2' },
name: 'CB Insights',
description: 'Research private markets — firmographics, funding, and predictive scores',
longDescription:
@@ -909,6 +912,21 @@ export const CbInsightsBlock: BlockConfig = {
},
}
+/** Every operation runs with a saved CB Insights API client. */
+export const CbInsightsV2Block: BlockConfig = {
+ ...CbInsightsBlock,
+ type: 'cbinsights_v2',
+ authMode: AuthMode.OAuth,
+ hideFromToolbar: false,
+ sunset: undefined,
+ ...withSavedCredential(CbInsightsBlock, {
+ id: 'credential',
+ type: 'oauth-input',
+ serviceId: 'cbinsights',
+ title: 'CB Insights Credential',
+ }),
+}
+
export const CbInsightsBlockMeta = {
tags: ['enrichment', 'data-analytics'],
url: 'https://www.cbinsights.com',
diff --git a/apps/sim/blocks/blocks/cloudformation.ts b/apps/sim/blocks/blocks/cloudformation.ts
index 75c6b01007c..75eb9753de0 100644
--- a/apps/sim/blocks/blocks/cloudformation.ts
+++ b/apps/sim/blocks/blocks/cloudformation.ts
@@ -275,6 +275,48 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`,
placeholder: 'Describe the tags to apply...',
},
},
+ {
+ id: 'changeSetTags',
+ title: 'Tags (JSON)',
+ type: 'code',
+ placeholder: '[\n {"key": "env", "value": "prod"}\n]',
+ condition: { field: 'operation', value: 'create_change_set' },
+ mode: 'advanced',
+ wandConfig: {
+ enabled: true,
+ prompt: `Generate a CloudFormation stack tags JSON array based on the user's description.
+Each item must be an object with "key" and "value" string fields.
+
+Return ONLY valid JSON - no explanations, no markdown code blocks.`,
+ placeholder: 'Describe the tags to apply...',
+ },
+ },
+ {
+ id: 'roleArn',
+ title: 'Service Role ARN',
+ type: 'short-input',
+ placeholder: 'arn:aws:iam::123456789012:role/cfn-deploy (optional)',
+ condition: {
+ field: 'operation',
+ value: ['create_stack', 'update_stack', 'create_change_set'],
+ },
+ mode: 'advanced',
+ },
+ {
+ id: 'enableTerminationProtection',
+ title: 'Termination Protection',
+ type: 'switch',
+ condition: { field: 'operation', value: 'create_stack' },
+ mode: 'advanced',
+ },
+ {
+ id: 'changeSetNextToken',
+ title: 'Next Token',
+ type: 'short-input',
+ placeholder: 'nextToken from a previous Describe Change Set call',
+ condition: { field: 'operation', value: 'describe_change_set' },
+ mode: 'advanced',
+ },
{
id: 'onFailure',
title: 'On Failure',
@@ -437,6 +479,11 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`,
...(parsedTimeoutInMinutes !== undefined && {
timeoutInMinutes: parsedTimeoutInMinutes,
}),
+ ...(rest.roleArn && { roleArn: rest.roleArn }),
+ ...((rest.enableTerminationProtection === true ||
+ rest.enableTerminationProtection === 'true') && {
+ enableTerminationProtection: true,
+ }),
}
}
@@ -457,6 +504,7 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`,
...(parameters && { parameters: parseJson(parameters, 'parameters') }),
...(rest.capabilities && { capabilities: rest.capabilities }),
...(tags && { tags: parseJson(tags, 'tags') }),
+ ...(rest.roleArn && { roleArn: rest.roleArn }),
}
}
@@ -501,6 +549,8 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`,
...(rest.capabilities && { capabilities: rest.capabilities }),
...(rest.changeSetType && { changeSetType: rest.changeSetType }),
...(rest.changeSetDescription && { description: rest.changeSetDescription }),
+ tags: rest.changeSetTags ? parseJson(rest.changeSetTags, 'tags') : undefined,
+ ...(rest.roleArn && { roleArn: rest.roleArn }),
}
}
@@ -512,6 +562,7 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`,
awsSecretAccessKey,
changeSetName: rest.changeSetName,
...(rest.stackName && { stackName: rest.stackName }),
+ ...(rest.changeSetNextToken && { nextToken: rest.changeSetNextToken }),
}
}
@@ -637,6 +688,16 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`,
parameters: { type: 'json', description: 'Stack input parameters' },
capabilities: { type: 'string', description: 'Comma-separated capabilities to acknowledge' },
tags: { type: 'json', description: 'Tags to apply to the stack' },
+ changeSetTags: { type: 'json', description: 'Tags to apply through a change set' },
+ roleArn: { type: 'string', description: 'IAM service role ARN CloudFormation assumes' },
+ enableTerminationProtection: {
+ type: 'boolean',
+ description: 'Enable termination protection on a new stack',
+ },
+ changeSetNextToken: {
+ type: 'string',
+ description: 'Pagination token for Describe Change Set changes',
+ },
onFailure: { type: 'string', description: 'Action to take on stack creation failure' },
timeoutInMinutes: { type: 'number', description: 'Stack creation timeout in minutes' },
retainResources: {
@@ -652,7 +713,8 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`,
outputs: {
stacks: {
type: 'array',
- description: 'List of CloudFormation stacks with status, outputs, and tags',
+ description:
+ 'CloudFormation stacks [{stackName, stackId, stackStatus, stackStatusReason, detailedStatus, creationTime, lastUpdatedTime, deletionTime, description, outputs [{outputKey, outputValue, description, exportName}], parameters [{parameterKey, parameterValue, resolvedValue}], tags, capabilities, driftInformation, roleArn, parentId, rootId, changeSetId, notificationArns, timeoutInMinutes, disableRollback, enableTerminationProtection}]',
},
resources: {
type: 'array',
@@ -660,7 +722,8 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`,
},
events: {
type: 'array',
- description: 'Stack events with resource status and timestamps',
+ description:
+ 'Stack events [{eventId, logicalResourceId, physicalResourceId, resourceType, resourceStatus, resourceStatusReason, detailedStatus, resourceProperties, clientRequestToken, operationId, hook fields, timestamp}]',
},
stackDriftDetectionId: {
type: 'string',
@@ -704,7 +767,8 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`,
},
parameters: {
type: 'array',
- description: 'Template parameters',
+ description:
+ 'Template parameters (validate/template summary) or change set parameters (parameterKey, parameterValue, resolvedValue)',
},
capabilities: {
type: 'array',
@@ -756,7 +820,40 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`,
},
changes: {
type: 'array',
- description: 'List of resource changes a change set would make',
+ description:
+ 'Resource changes a change set would make [{action, logicalResourceId, physicalResourceId, resourceType, replacement, scope, details}]',
+ },
+ tags: {
+ type: 'array',
+ description: 'Tags associated with the change set',
+ },
+ notificationArns: {
+ type: 'array',
+ description: 'SNS topic ARNs for change set stack events',
+ },
+ includeNestedStacks: {
+ type: 'boolean',
+ description: 'Whether change sets were created for nested stacks',
+ },
+ parentChangeSetId: {
+ type: 'string',
+ description: 'Parent change set ID, for nested change sets',
+ },
+ rootChangeSetId: {
+ type: 'string',
+ description: 'Top-level change set ID, for nested change sets',
+ },
+ onStackFailure: {
+ type: 'string',
+ description: 'Behavior when change set stack operations fail',
+ },
+ importExistingResources: {
+ type: 'boolean',
+ description: 'Whether the change set imports existing resources',
+ },
+ nextToken: {
+ type: 'string',
+ description: 'Pagination token for the next page of change set changes',
},
},
}
diff --git a/apps/sim/blocks/blocks/cloudwatch.ts b/apps/sim/blocks/blocks/cloudwatch.ts
index ccaf98917c8..83445492d2e 100644
--- a/apps/sim/blocks/blocks/cloudwatch.ts
+++ b/apps/sim/blocks/blocks/cloudwatch.ts
@@ -497,6 +497,22 @@ Return ONLY the filter pattern - no explanations, no extra text.`,
columns: ['name', 'value'],
condition: { field: 'operation', value: 'get_metric_statistics' },
},
+ {
+ id: 'metricExtendedStatistics',
+ title: 'Percentile Statistics',
+ type: 'short-input',
+ placeholder: 'p99, p95 (requested instead of Statistics when set)',
+ condition: { field: 'operation', value: 'get_metric_statistics' },
+ mode: 'advanced',
+ },
+ {
+ id: 'metricStatisticsUnit',
+ title: 'Unit Filter',
+ type: 'short-input',
+ placeholder: 'e.g., Percent, Milliseconds (omit for all units)',
+ condition: { field: 'operation', value: 'get_metric_statistics' },
+ mode: 'advanced',
+ },
{
id: 'alarmNamePrefix',
title: 'Alarm Name Prefix',
@@ -651,6 +667,30 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`,
mode: 'advanced',
description: 'Unix epoch seconds. Defaults to now (mute starts immediately).',
},
+ {
+ id: 'filterNextToken',
+ title: 'Next Token',
+ type: 'short-input',
+ placeholder: 'nextToken from a previous Filter Log Events call',
+ condition: { field: 'operation', value: 'filter_log_events' },
+ mode: 'advanced',
+ },
+ {
+ id: 'logEventsNextToken',
+ title: 'Next Token',
+ type: 'short-input',
+ placeholder: 'nextForwardToken or nextBackwardToken from a previous call',
+ condition: { field: 'operation', value: 'get_log_events' },
+ mode: 'advanced',
+ },
+ {
+ id: 'alarmsNextToken',
+ title: 'Next Token',
+ type: 'short-input',
+ placeholder: 'nextToken from a previous Describe Alarms call',
+ condition: { field: 'operation', value: 'describe_alarms' },
+ mode: 'advanced',
+ },
{
id: 'limit',
title: 'Limit',
@@ -788,6 +828,7 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`,
...(startTime && { startTime: Number(startTime) }),
...(endTime && { endTime: Number(endTime) }),
...(parsedLimit !== undefined && { limit: parsedLimit }),
+ ...(rest.logEventsNextToken && { nextToken: rest.logEventsNextToken }),
}
}
@@ -826,6 +867,7 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`,
startFromHead: rest.startFromHead === 'true' || rest.startFromHead === true,
}),
...(parsedLimit !== undefined && { limit: parsedLimit }),
+ ...(rest.filterNextToken && { nextToken: rest.filterNextToken }),
}
}
@@ -892,6 +934,15 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`,
endTime: Number(endTime),
period: Number(rest.metricPeriod),
statistics: Array.isArray(stat) ? stat : [stat],
+ ...(rest.metricExtendedStatistics && {
+ extendedStatistics: Array.isArray(rest.metricExtendedStatistics)
+ ? rest.metricExtendedStatistics
+ : String(rest.metricExtendedStatistics)
+ .split(',')
+ .map((s: string) => s.trim())
+ .filter(Boolean),
+ }),
+ ...(rest.metricStatisticsUnit && { unit: rest.metricStatisticsUnit }),
...(rest.metricDimensions && {
dimensions: (() => {
const dims = rest.metricDimensions
@@ -962,6 +1013,7 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`,
...(rest.stateValue && { stateValue: rest.stateValue }),
...(rest.alarmType && { alarmType: rest.alarmType }),
...(parsedLimit !== undefined && { limit: parsedLimit }),
+ ...(rest.alarmsNextToken && { nextToken: rest.alarmsNextToken }),
}
case 'describe_alarm_history':
@@ -1099,6 +1151,20 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`,
metricPeriod: { type: 'number', description: 'Granularity in seconds' },
metricStatistics: { type: 'string', description: 'Statistic type (Average, Sum, etc.)' },
metricDimensions: { type: 'json', description: 'Metric dimensions (Name/Value pairs)' },
+ metricExtendedStatistics: {
+ type: 'string',
+ description: 'Comma-separated percentile statistics (e.g., p99, p95)',
+ },
+ metricStatisticsUnit: {
+ type: 'string',
+ description: 'Only return metric data reported with this unit',
+ },
+ filterNextToken: { type: 'string', description: 'Pagination token for Filter Log Events' },
+ logEventsNextToken: {
+ type: 'string',
+ description: 'Forward or backward pagination token for Get Log Events',
+ },
+ alarmsNextToken: { type: 'string', description: 'Pagination token for Describe Alarms' },
metricValue: { type: 'number', description: 'Metric value to publish' },
metricUnit: { type: 'string', description: 'Metric unit (Count, Seconds, Bytes, etc.)' },
publishDimensions: {
@@ -1138,13 +1204,30 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`,
type: 'string',
description: 'Query completion status',
},
+ queryId: {
+ type: 'string',
+ description: 'ID of the Logs Insights query',
+ },
+ nextToken: {
+ type: 'string',
+ description: 'Pagination token for the next page (Filter Log Events, Describe Alarms)',
+ },
+ nextForwardToken: {
+ type: 'string',
+ description: 'Token for the next, newer page of Get Log Events results',
+ },
+ nextBackwardToken: {
+ type: 'string',
+ description: 'Token for the previous, older page of Get Log Events results',
+ },
logGroups: {
type: 'array',
description: 'List of CloudWatch log groups',
},
events: {
type: 'array',
- description: 'Log events with timestamp and message',
+ description:
+ 'Log events [{timestamp, message, ingestionTime, logStreamName, eventId}] (logStreamName and eventId from Filter Log Events)',
},
logStreams: {
type: 'array',
@@ -1160,15 +1243,18 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`,
},
datapoints: {
type: 'array',
- description: 'Metric datapoints with timestamps and values',
+ description:
+ 'Metric datapoints [{timestamp, average, sum, minimum, maximum, sampleCount, unit, extendedStatistics}]',
},
alarms: {
type: 'array',
- description: 'CloudWatch alarms with state and configuration',
+ description:
+ 'CloudWatch alarms [{alarmName, alarmArn, alarmType, stateValue, stateReason, stateReasonData, alarmDescription, actionsEnabled, alarmActions, okActions, insufficientDataActions, metricName, namespace, statistic, extendedStatistic, dimensions, period, unit, threshold, comparisonOperator, evaluationPeriods, datapointsToAlarm, treatMissingData, metrics, alarmRule, stateUpdatedTimestamp, stateTransitionedTimestamp}]',
},
alarmHistoryItems: {
type: 'array',
- description: 'Alarm history items (state changes, configuration updates, actions)',
+ description:
+ 'Alarm history items [{alarmName, alarmType, timestamp, historyItemType, historySummary, historyData, alarmContributorId}]',
},
alarmNames: {
type: 'array',
diff --git a/apps/sim/blocks/blocks/confluence.ts b/apps/sim/blocks/blocks/confluence.ts
index 3f627ea18a8..4e1d31a0c05 100644
--- a/apps/sim/blocks/blocks/confluence.ts
+++ b/apps/sim/blocks/blocks/confluence.ts
@@ -1041,6 +1041,20 @@ export const ConfluenceV2Block: BlockConfig = {
required: true,
condition: { field: 'operation', value: ['search', 'search_in_space'] },
},
+ {
+ id: 'cql',
+ title: 'CQL Filter',
+ type: 'long-input',
+ placeholder: 'type = page AND space = "ENG" AND lastModified > now("-7d")',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'search' },
+ wandConfig: {
+ enabled: true,
+ prompt:
+ 'Write Confluence CQL clauses that are ANDed with a text search. Use fields such as type (page, blogpost, attachment, comment), space, label, creator, contributor, created, lastModified, ancestor, and title, with now("-7d")-style relative dates. Do not include a text ~ clause. Return ONLY the CQL expression.',
+ placeholder: 'Describe how to narrow the search',
+ },
+ },
{
id: 'comment',
title: 'Comment Text',
@@ -1057,6 +1071,14 @@ export const ConfluenceV2Block: BlockConfig = {
required: true,
condition: { field: 'operation', value: ['update_comment', 'delete_comment'] },
},
+ {
+ id: 'parentCommentId',
+ title: 'Reply To Comment ID',
+ type: 'short-input',
+ placeholder: 'Enter a footer or inline comment ID to reply to (optional)',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'create_comment' },
+ },
{
id: 'attachmentId',
title: 'Attachment ID',
@@ -1158,6 +1180,33 @@ export const ConfluenceV2Block: BlockConfig = {
value: () => 'storage',
condition: { field: 'operation', value: 'list_comments' },
},
+ {
+ id: 'commentType',
+ title: 'Comment Type',
+ type: 'dropdown',
+ options: [
+ { label: 'Footer (default)', id: 'footer' },
+ { label: 'Inline', id: 'inline' },
+ ],
+ value: () => 'footer',
+ condition: { field: 'operation', value: 'list_comments' },
+ },
+ {
+ id: 'contentBodyFormat',
+ title: 'Body Format',
+ type: 'dropdown',
+ options: [
+ { label: 'Default', id: '' },
+ { label: 'Storage', id: 'storage' },
+ { label: 'Atlas Doc Format', id: 'atlas_doc_format' },
+ ],
+ value: () => '',
+ mode: 'advanced',
+ condition: {
+ field: 'operation',
+ value: ['get_blogpost', 'list_pages_in_space', 'list_blogposts_in_space'],
+ },
+ },
{
id: 'limit',
title: 'Limit',
@@ -1214,6 +1263,14 @@ export const ConfluenceV2Block: BlockConfig = {
],
},
},
+ {
+ id: 'searchCursor',
+ title: 'Pagination Cursor',
+ type: 'short-input',
+ placeholder: 'Enter nextCursor from a previous search (optional)',
+ mode: 'advanced',
+ condition: { field: 'operation', value: ['search', 'search_in_space'] },
+ },
...getTrigger('confluence_page_created').subBlocks,
...getTrigger('confluence_page_updated').subBlocks,
@@ -1445,10 +1502,20 @@ export const ConfluenceV2Block: BlockConfig = {
spacePropertyKey,
spacePropertyValue,
spacePropertyId,
+ commentType,
+ contentBodyFormat,
+ parentCommentId,
+ cql,
+ searchCursor,
...rest
} = params
const effectivePageId = pageId ? String(pageId).trim() : ''
+ const scopedBodyFormat =
+ ['get_blogpost', 'list_pages_in_space', 'list_blogposts_in_space'].includes(operation) &&
+ contentBodyFormat
+ ? { bodyFormat: contentBodyFormat }
+ : {}
if (operation === 'add_label') {
return {
@@ -1486,6 +1553,27 @@ export const ConfluenceV2Block: BlockConfig = {
operation,
bodyFormat: bodyFormat || 'storage',
cursor: cursor || undefined,
+ commentType: commentType || undefined,
+ ...rest,
+ }
+ }
+
+ if (operation === 'create_comment') {
+ return {
+ credential: oauthCredential,
+ pageId: effectivePageId,
+ operation,
+ parentCommentId: parentCommentId ? String(parentCommentId).trim() : undefined,
+ ...rest,
+ }
+ }
+
+ if (operation === 'search') {
+ return {
+ credential: oauthCredential,
+ operation,
+ cql: cql || undefined,
+ cursor: searchCursor || undefined,
...rest,
}
}
@@ -1514,6 +1602,7 @@ export const ConfluenceV2Block: BlockConfig = {
pageId: effectivePageId || undefined,
operation,
cursor,
+ ...scopedBodyFormat,
...rest,
}
}
@@ -1611,6 +1700,7 @@ export const ConfluenceV2Block: BlockConfig = {
credential: oauthCredential,
operation,
spaceKey: selectedSpaceKey,
+ cursor: searchCursor || undefined,
...rest,
}
}
@@ -1708,6 +1798,7 @@ export const ConfluenceV2Block: BlockConfig = {
blogPostId: blogPostId || undefined,
versionNumber: versionNumber ? Number.parseInt(String(versionNumber), 10) : undefined,
operation,
+ ...scopedBodyFormat,
...rest,
}
},
@@ -1742,6 +1833,14 @@ export const ConfluenceV2Block: BlockConfig = {
blogPostStatus: { type: 'string', description: 'Blog post status (current or draft)' },
purge: { type: 'boolean', description: 'Permanently delete instead of moving to trash' },
bodyFormat: { type: 'string', description: 'Body format for comments' },
+ commentType: { type: 'string', description: 'Comment type to list (footer or inline)' },
+ contentBodyFormat: {
+ type: 'string',
+ description: 'Body format for blog post and page list/get operations',
+ },
+ parentCommentId: { type: 'string', description: 'Comment ID to reply to' },
+ cql: { type: 'string', description: 'Extra CQL clauses ANDed with the search query' },
+ searchCursor: { type: 'string', description: 'Pagination cursor for search operations' },
limit: { type: 'number', description: 'Maximum number of results' },
cursor: { type: 'string', description: 'Pagination cursor from previous response' },
taskId: { type: 'string', description: 'Task identifier' },
@@ -1808,6 +1907,23 @@ export const ConfluenceV2Block: BlockConfig = {
homepageId: { type: 'string', description: 'Space homepage ID' },
description: { type: 'json', description: 'Space description' },
nextCursor: { type: 'string', description: 'Cursor for fetching next page of results' },
+ totalSize: { type: 'number', description: 'Total number of matching search results' },
+ ownerId: { type: 'string', description: 'Account ID of the current page owner (Read Page)' },
+ lastOwnerId: {
+ type: 'string',
+ description: 'Account ID of the previous page owner (Read Page)',
+ },
+ parentType: { type: 'string', description: 'Parent content type (Read Page)' },
+ parentId: { type: 'string', description: 'Parent page identifier' },
+ parentCommentId: {
+ type: 'string',
+ description: 'Parent comment ID when the comment is a reply',
+ },
+ spaceOwnerId: { type: 'string', description: 'Account ID of the space owner (Get Space)' },
+ webUrl: { type: 'string', description: 'URL to view the blog post or comment in Confluence' },
+ authorId: { type: 'string', description: 'Account ID of the content author' },
+ createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' },
+ spaceKey: { type: 'string', description: 'Space key that was searched (Search in Space)' },
},
}
diff --git a/apps/sim/blocks/blocks/dynamodb.ts b/apps/sim/blocks/blocks/dynamodb.ts
index dacec45fb7a..1a67c12ad29 100644
--- a/apps/sim/blocks/blocks/dynamodb.ts
+++ b/apps/sim/blocks/blocks/dynamodb.ts
@@ -588,6 +588,117 @@ Return ONLY the expression - no explanations.`,
condition: { field: 'operation', value: 'query' },
mode: 'advanced',
},
+ {
+ id: 'getProjectionExpression',
+ title: 'Projection Expression',
+ type: 'short-input',
+ placeholder: 'pk, #name, email',
+ condition: { field: 'operation', value: 'get' },
+ mode: 'advanced',
+ },
+ {
+ id: 'getExpressionAttributeNames',
+ title: 'Expression Attribute Names (JSON)',
+ type: 'code',
+ placeholder: '{\n "#name": "name"\n}',
+ condition: { field: 'operation', value: 'get' },
+ mode: 'advanced',
+ },
+ {
+ id: 'queryProjectionExpression',
+ title: 'Projection Expression',
+ type: 'short-input',
+ placeholder: 'pk, sk, #name',
+ condition: { field: 'operation', value: 'query' },
+ mode: 'advanced',
+ },
+ {
+ id: 'queryConsistentRead',
+ title: 'Consistent Read',
+ type: 'dropdown',
+ options: [
+ { label: 'Eventually Consistent', id: 'false' },
+ { label: 'Strongly Consistent', id: 'true' },
+ ],
+ value: () => 'false',
+ condition: { field: 'operation', value: 'query' },
+ mode: 'advanced',
+ },
+ {
+ id: 'scanIndexName',
+ title: 'Index Name',
+ type: 'short-input',
+ placeholder: 'GSI1',
+ condition: { field: 'operation', value: 'scan' },
+ mode: 'advanced',
+ },
+ {
+ id: 'scanConsistentRead',
+ title: 'Consistent Read',
+ type: 'dropdown',
+ options: [
+ { label: 'Eventually Consistent', id: 'false' },
+ { label: 'Strongly Consistent', id: 'true' },
+ ],
+ value: () => 'false',
+ condition: { field: 'operation', value: 'scan' },
+ mode: 'advanced',
+ },
+ {
+ id: 'scanSegment',
+ title: 'Segment',
+ type: 'short-input',
+ placeholder: '0',
+ condition: { field: 'operation', value: 'scan' },
+ mode: 'advanced',
+ },
+ {
+ id: 'scanTotalSegments',
+ title: 'Total Segments',
+ type: 'short-input',
+ placeholder: '4',
+ condition: { field: 'operation', value: 'scan' },
+ mode: 'advanced',
+ },
+ {
+ id: 'putReturnValues',
+ title: 'Return Values',
+ type: 'dropdown',
+ options: [
+ { label: 'None (default)', id: 'NONE' },
+ { label: 'Replaced item (ALL_OLD)', id: 'ALL_OLD' },
+ ],
+ value: () => 'NONE',
+ condition: { field: 'operation', value: 'put' },
+ mode: 'advanced',
+ },
+ {
+ id: 'updateReturnValues',
+ title: 'Return Values',
+ type: 'dropdown',
+ options: [
+ { label: 'All new attributes (default)', id: 'ALL_NEW' },
+ { label: 'Updated new attributes', id: 'UPDATED_NEW' },
+ { label: 'All old attributes', id: 'ALL_OLD' },
+ { label: 'Updated old attributes', id: 'UPDATED_OLD' },
+ { label: 'None', id: 'NONE' },
+ ],
+ value: () => 'ALL_NEW',
+ condition: { field: 'operation', value: 'update' },
+ mode: 'advanced',
+ },
+ {
+ id: 'deleteReturnValues',
+ title: 'Return Values',
+ type: 'dropdown',
+ options: [
+ { label: 'None (default)', id: 'NONE' },
+ { label: 'Deleted item (ALL_OLD)', id: 'ALL_OLD' },
+ ],
+ value: () => 'NONE',
+ condition: { field: 'operation', value: 'delete' },
+ mode: 'advanced',
+ },
],
tools: {
access: [
@@ -661,6 +772,9 @@ Return ONLY the expression - no explanations.`,
if (params.consistentRead === 'true' || params.consistentRead === true) {
result.consistentRead = true
}
+ result.projectionExpression = params.getProjectionExpression || undefined
+ const getNames = parseJson(params.getExpressionAttributeNames, 'expressionAttributeNames')
+ if (getNames !== undefined) result.expressionAttributeNames = getNames
}
if (op === 'put') {
@@ -681,6 +795,7 @@ Return ONLY the expression - no explanations.`,
'expressionAttributeValues'
)
if (values !== undefined) result.expressionAttributeValues = values
+ if (params.putReturnValues === 'ALL_OLD') result.returnValues = 'ALL_OLD'
}
if (op === 'query') {
@@ -694,13 +809,19 @@ Return ONLY the expression - no explanations.`,
'expressionAttributeValues'
)
if (values !== undefined) result.expressionAttributeValues = values
- if (params.indexName) result.indexName = params.indexName
+ result.indexName = params.indexName || undefined
if (params.queryLimit) result.limit = Number.parseInt(String(params.queryLimit), 10)
const esk = parseJson(params.queryExclusiveStartKey, 'exclusiveStartKey')
if (esk !== undefined) result.exclusiveStartKey = esk
- if (params.scanIndexForward === 'false' || params.scanIndexForward === false) {
- result.scanIndexForward = false
- }
+ result.scanIndexForward =
+ params.scanIndexForward === 'false' || params.scanIndexForward === false
+ ? false
+ : undefined
+ result.projectionExpression = params.queryProjectionExpression || undefined
+ result.consistentRead =
+ params.queryConsistentRead === 'true' || params.queryConsistentRead === true
+ ? true
+ : undefined
}
if (op === 'scan') {
@@ -725,6 +846,21 @@ Return ONLY the expression - no explanations.`,
if (lim) result.limit = Number.parseInt(String(lim), 10)
const esk = parseJson(params.scanExclusiveStartKey, 'exclusiveStartKey')
if (esk !== undefined) result.exclusiveStartKey = esk
+ result.indexName = params.scanIndexName || undefined
+ result.consistentRead =
+ params.scanConsistentRead === 'true' || params.scanConsistentRead === true
+ ? true
+ : undefined
+ if (
+ params.scanSegment !== undefined &&
+ params.scanSegment !== null &&
+ params.scanSegment !== ''
+ ) {
+ result.segment = Number.parseInt(String(params.scanSegment), 10)
+ }
+ if (params.scanTotalSegments) {
+ result.totalSegments = Number.parseInt(String(params.scanTotalSegments), 10)
+ }
}
if (op === 'update') {
@@ -746,6 +882,7 @@ Return ONLY the expression - no explanations.`,
if (values !== undefined) result.expressionAttributeValues = values
if (params.updateConditionExpression)
result.conditionExpression = params.updateConditionExpression
+ if (params.updateReturnValues) result.returnValues = params.updateReturnValues
}
if (op === 'delete') {
@@ -768,6 +905,7 @@ Return ONLY the expression - no explanations.`,
'expressionAttributeValues'
)
if (values !== undefined) result.expressionAttributeValues = values
+ if (params.deleteReturnValues === 'ALL_OLD') result.returnValues = 'ALL_OLD'
}
return result
@@ -840,6 +978,26 @@ Return ONLY the expression - no explanations.`,
type: 'string',
description: 'Sort order for query: true for ascending, false for descending',
},
+ getProjectionExpression: { type: 'string', description: 'Attributes to retrieve in get' },
+ getExpressionAttributeNames: {
+ type: 'json',
+ description: 'Attribute name mappings for get projection',
+ },
+ queryProjectionExpression: { type: 'string', description: 'Attributes to retrieve in query' },
+ queryConsistentRead: { type: 'string', description: 'Use strongly consistent read for query' },
+ scanIndexName: { type: 'string', description: 'Secondary index name for scan' },
+ scanConsistentRead: { type: 'string', description: 'Use strongly consistent read for scan' },
+ scanSegment: { type: 'number', description: 'Parallel scan segment number' },
+ scanTotalSegments: { type: 'number', description: 'Parallel scan total segment count' },
+ putReturnValues: { type: 'string', description: 'Return values for put (NONE or ALL_OLD)' },
+ updateReturnValues: {
+ type: 'string',
+ description: 'Return values for update (ALL_NEW, UPDATED_NEW, ALL_OLD, UPDATED_OLD, NONE)',
+ },
+ deleteReturnValues: {
+ type: 'string',
+ description: 'Return values for delete (NONE or ALL_OLD)',
+ },
},
outputs: {
message: {
@@ -858,6 +1016,14 @@ Return ONLY the expression - no explanations.`,
type: 'number',
description: 'Number of items returned',
},
+ scannedCount: {
+ type: 'number',
+ description: 'Number of items evaluated by query/scan before the filter expression',
+ },
+ attributes: {
+ type: 'json',
+ description: 'Replaced (put) or deleted (delete) item when Return Values is ALL_OLD',
+ },
lastEvaluatedKey: {
type: 'json',
description:
diff --git a/apps/sim/blocks/blocks/ec2.ts b/apps/sim/blocks/blocks/ec2.ts
index 3ed4dcf093a..9bba35d6f20 100644
--- a/apps/sim/blocks/blocks/ec2.ts
+++ b/apps/sim/blocks/blocks/ec2.ts
@@ -766,7 +766,7 @@ Return ONLY the JSON array, no explanation.`,
instances: {
type: 'array',
description:
- 'Instances (describe_instances) or per-instance state transitions (start, stop, terminate)',
+ 'Instances (describe_instances: instanceId, instanceType, state, stateReason, launchTime, IPs, vpcId, subnetId, platform, architecture, iamInstanceProfileArn, securityGroups, blockDeviceMappings, networkInterfaces, metadataHttpTokens, tags) or per-instance state transitions (start, stop, terminate)',
},
instanceStatuses: {
type: 'array',
diff --git a/apps/sim/blocks/blocks/evaluator.ts b/apps/sim/blocks/blocks/evaluator.ts
index ef6d44737c1..11708998be6 100644
--- a/apps/sim/blocks/blocks/evaluator.ts
+++ b/apps/sim/blocks/blocks/evaluator.ts
@@ -3,6 +3,7 @@ import { ChartBarIcon } from '@/components/icons'
import { getModelFallbackSubBlock, MODEL_FALLBACK_INPUTS } from '@/blocks/model-fallbacks'
import type { BlockConfig, ParamType } from '@/blocks/types'
import {
+ BEDROCK_SAVED_CREDENTIAL_INPUTS,
getModelOptions,
getProviderCredentialSubBlocks,
getSerializedModelProviderId,
@@ -170,7 +171,7 @@ export const EvaluatorBlock: BlockConfig = {
defaultValue: 'claude-sonnet-5-5',
options: getModelOptions,
},
- ...getProviderCredentialSubBlocks(),
+ ...getProviderCredentialSubBlocks({ bedrockSavedCredential: true }),
getModelFallbackSubBlock(),
{
id: 'temperature',
@@ -277,6 +278,7 @@ export const EvaluatorBlock: BlockConfig = {
},
model: { type: 'string' as ParamType, description: 'AI model to use' },
...PROVIDER_CREDENTIAL_INPUTS,
+ ...BEDROCK_SAVED_CREDENTIAL_INPUTS,
...MODEL_FALLBACK_INPUTS,
temperature: {
type: 'number' as ParamType,
diff --git a/apps/sim/blocks/blocks/gmail.ts b/apps/sim/blocks/blocks/gmail.ts
index af7e410a04c..924929e8fb0 100644
--- a/apps/sim/blocks/blocks/gmail.ts
+++ b/apps/sim/blocks/blocks/gmail.ts
@@ -312,6 +312,15 @@ Return ONLY the email body - no explanations, no extra text.`,
mode: 'advanced',
required: false,
},
+ {
+ id: 'replyTo',
+ title: 'Reply-To',
+ type: 'short-input',
+ placeholder: 'Address replies should go to (e.g., support@example.com)',
+ condition: { field: 'operation', value: ['send_gmail', 'draft_gmail', 'edit_draft_gmail'] },
+ mode: 'advanced',
+ required: false,
+ },
// Label/folder selector (basic mode)
{
id: 'folder',
@@ -382,9 +391,19 @@ Return ONLY the search query - no explanations, no extra text.`,
id: 'maxResults',
title: 'Max Results',
type: 'short-input',
- placeholder: 'Maximum number of results (default: 10)',
+ placeholder:
+ 'Maximum number of results (read: default 1, max 10; search: default 100, max 500)',
condition: { field: 'operation', value: ['search_gmail', 'read_gmail'] },
},
+ {
+ id: 'pageToken',
+ title: 'Page Token',
+ type: 'short-input',
+ placeholder: 'nextPageToken from a previous search',
+ condition: { field: 'operation', value: 'search_gmail' },
+ mode: 'advanced',
+ required: false,
+ },
// Move Email Fields
{
id: 'moveMessageId',
@@ -603,10 +622,11 @@ Return ONLY the search query - no explanations, no extra text.`,
threadId: { type: 'string', description: 'Thread ID to reply to (for threading)' },
replyToMessageId: {
type: 'string',
- description: 'Gmail message ID to reply to (use "id" field from results, not "messageId")',
+ description: 'Gmail message ID to reply to (use "id" field from results, not "rfcMessageId")',
},
cc: { type: 'string', description: 'CC recipients (comma-separated)' },
bcc: { type: 'string', description: 'BCC recipients (comma-separated)' },
+ replyTo: { type: 'string', description: 'Reply-To address for the outgoing email' },
attachments: { type: 'array', description: 'Files to attach (canonical param)' },
// Read operation inputs
folder: { type: 'string', description: 'Gmail folder (canonical param)' },
@@ -616,6 +636,10 @@ Return ONLY the search query - no explanations, no extra text.`,
// Search operation inputs
query: { type: 'string', description: 'Search query' },
maxResults: { type: 'number', description: 'Maximum results' },
+ pageToken: {
+ type: 'string',
+ description: 'Search page token (nextPageToken from a prior search)',
+ },
// Move operation inputs
moveMessageId: { type: 'string', description: 'Message ID to move' },
addLabelIds: { type: 'string', description: 'Label IDs to add (canonical param)' },
@@ -632,7 +656,11 @@ Return ONLY the search query - no explanations, no extra text.`,
outputs: {
// Tool outputs
content: { type: 'string', description: 'Response content' },
- metadata: { type: 'json', description: 'Email metadata' },
+ metadata: {
+ type: 'json',
+ description:
+ 'Email metadata (id, threadId, labelIds, from, to, cc, bcc, replyTo, subject, date, rfcMessageId, inReplyTo, references, snippet, internalDate, hasAttachments, attachmentCount); search/multi-read returns results[] with the same header fields plus nextPageToken and resultSizeEstimate for search',
+ },
attachments: { type: 'file[]', description: 'Email attachments array' },
// Trigger outputs
email_id: { type: 'string', description: 'Gmail message ID' },
@@ -655,6 +683,223 @@ Return ONLY the search query - no explanations, no extra text.`,
},
}
+const LABEL_WRITE_OPERATIONS = ['create_label_gmail', 'update_label_gmail']
+const LABEL_TARGET_OPERATIONS = ['update_label_gmail', 'delete_label_gmail']
+const THREAD_TARGET_OPERATIONS = ['get_thread_gmail', 'trash_thread_gmail', 'untrash_thread_gmail']
+const LIST_OPERATIONS = ['list_drafts_gmail', 'list_threads_gmail']
+const PAGINATED_OPERATIONS = ['search_gmail', ...LIST_OPERATIONS]
+const LABEL_TARGET_FIELD = ['labelTarget', 'manualLabelTarget'] as const
+
+/** Operations only the V2 block exposes; their tools exist only in a `_v2` version. */
+const GMAIL_V2_OPERATION_TOOLS: Record = {
+ list_labels_gmail: 'gmail_list_labels_v2',
+ create_label_gmail: 'gmail_create_label_v2',
+ update_label_gmail: 'gmail_update_label_v2',
+ delete_label_gmail: 'gmail_delete_label_v2',
+ list_drafts_gmail: 'gmail_list_drafts_v2',
+ get_draft_gmail: 'gmail_get_draft_v2',
+ delete_draft_gmail: 'gmail_delete_draft_v2',
+ list_threads_gmail: 'gmail_list_threads_v2',
+ get_thread_gmail: 'gmail_get_thread_v2',
+ trash_thread_gmail: 'gmail_trash_thread_v2',
+ untrash_thread_gmail: 'gmail_untrash_thread_v2',
+}
+
+function selectGmailV2ToolId(params: Record): string {
+ const operation = typeof params.operation === 'string' ? params.operation : ''
+ return (
+ (Object.hasOwn(GMAIL_V2_OPERATION_TOOLS, operation) && GMAIL_V2_OPERATION_TOOLS[operation]) ||
+ selectGmailToolId(params)
+ )
+}
+
+const GMAIL_V2_OPERATION_OPTIONS = [
+ { label: 'List Labels', id: 'list_labels_gmail' },
+ { label: 'Create Label', id: 'create_label_gmail' },
+ { label: 'Update Label', id: 'update_label_gmail' },
+ { label: 'Delete Label', id: 'delete_label_gmail' },
+ { label: 'List Drafts', id: 'list_drafts_gmail' },
+ { label: 'Get Draft', id: 'get_draft_gmail' },
+ { label: 'Delete Draft', id: 'delete_draft_gmail' },
+ { label: 'List Threads', id: 'list_threads_gmail' },
+ { label: 'Get Thread', id: 'get_thread_gmail' },
+ { label: 'Trash Thread', id: 'trash_thread_gmail' },
+ { label: 'Untrash Thread', id: 'untrash_thread_gmail' },
+]
+
+const GMAIL_V2_SUBBLOCKS: BlockConfig['subBlocks'] = [
+ {
+ id: 'labelTarget',
+ title: 'Label',
+ type: 'folder-selector',
+ canonicalParamId: 'labelId',
+ serviceId: 'gmail',
+ selectorKey: 'gmail.labels',
+ requiredScopes: getScopesForService('gmail'),
+ placeholder: 'Select label',
+ dependsOn: ['credential'],
+ mode: 'basic',
+ condition: { field: 'operation', value: LABEL_TARGET_OPERATIONS },
+ required: true,
+ },
+ {
+ id: 'manualLabelTarget',
+ title: 'Label ID',
+ type: 'short-input',
+ canonicalParamId: 'labelId',
+ placeholder: 'Enter label ID (e.g., Label_123)',
+ dependsOn: ['credential'],
+ mode: 'advanced',
+ condition: { field: 'operation', value: LABEL_TARGET_OPERATIONS },
+ required: true,
+ },
+ {
+ id: 'labelName',
+ title: 'Label Name',
+ type: 'short-input',
+ placeholder: 'Label display name (e.g., Clients/Acme)',
+ condition: { field: 'operation', value: LABEL_WRITE_OPERATIONS },
+ required: { field: 'operation', value: 'create_label_gmail' },
+ },
+ {
+ id: 'messageListVisibility',
+ title: 'Message List Visibility',
+ type: 'dropdown',
+ options: [
+ { label: 'Default', id: '' },
+ { label: 'Show', id: 'show' },
+ { label: 'Hide', id: 'hide' },
+ ],
+ value: () => '',
+ condition: { field: 'operation', value: LABEL_WRITE_OPERATIONS },
+ mode: 'advanced',
+ required: false,
+ },
+ {
+ id: 'labelListVisibility',
+ title: 'Label List Visibility',
+ type: 'dropdown',
+ options: [
+ { label: 'Default', id: '' },
+ { label: 'Show', id: 'labelShow' },
+ { label: 'Show if unread', id: 'labelShowIfUnread' },
+ { label: 'Hide', id: 'labelHide' },
+ ],
+ value: () => '',
+ condition: { field: 'operation', value: LABEL_WRITE_OPERATIONS },
+ mode: 'advanced',
+ required: false,
+ },
+ {
+ id: 'targetThreadId',
+ title: 'Thread ID',
+ type: 'short-input',
+ placeholder: 'ID of the thread (threadId from Read, Search, or List Threads)',
+ condition: { field: 'operation', value: THREAD_TARGET_OPERATIONS },
+ required: true,
+ },
+ {
+ id: 'threadFormat',
+ title: 'Format',
+ type: 'dropdown',
+ options: [
+ { label: 'Full', id: 'full' },
+ { label: 'Metadata', id: 'metadata' },
+ { label: 'Minimal', id: 'minimal' },
+ ],
+ value: () => 'full',
+ condition: { field: 'operation', value: 'get_thread_gmail' },
+ mode: 'advanced',
+ required: false,
+ },
+ {
+ id: 'listQuery',
+ title: 'Search Query',
+ type: 'short-input',
+ placeholder: 'Optional Gmail search filter (e.g., from:alice is:unread)',
+ condition: { field: 'operation', value: LIST_OPERATIONS },
+ required: false,
+ wandConfig: {
+ enabled: true,
+ prompt: `Generate a Gmail search query based on the user's request.
+Gmail search supports operators like:
+- from: to: subject: has:attachment
+- is:unread is:starred is:important
+- before: after: older: newer:
+- filename: label: category:
+
+Return ONLY the search query - no explanations, no extra text.`,
+ placeholder: 'Describe which drafts or threads you want...',
+ },
+ },
+ {
+ id: 'threadLabelIds',
+ title: 'Label IDs',
+ type: 'short-input',
+ placeholder: 'Comma-separated label IDs (e.g., INBOX,Label_123)',
+ condition: { field: 'operation', value: 'list_threads_gmail' },
+ mode: 'advanced',
+ required: false,
+ wandConfig: {
+ enabled: true,
+ prompt: `Generate a comma-separated list of Gmail label IDs based on the user's request.
+System labels are uppercase (INBOX, SENT, DRAFT, SPAM, TRASH, STARRED, IMPORTANT, UNREAD); custom labels look like Label_123.
+
+Return ONLY the comma-separated label IDs - no explanations, no extra text.`,
+ placeholder: 'Describe which labels to filter by...',
+ },
+ },
+]
+
+function buildGmailV2SubBlocks(): BlockConfig['subBlocks'] {
+ return GmailBlock.subBlocks.flatMap((subBlock) => {
+ switch (subBlock.id) {
+ case 'operation':
+ return [
+ {
+ ...subBlock,
+ options: [
+ ...(Array.isArray(subBlock.options) ? subBlock.options : []),
+ ...GMAIL_V2_OPERATION_OPTIONS,
+ ],
+ },
+ ]
+ case 'draftId':
+ return [
+ {
+ ...subBlock,
+ placeholder: 'ID of the draft (from List Drafts)',
+ condition: {
+ field: 'operation',
+ value: ['edit_draft_gmail', 'get_draft_gmail', 'delete_draft_gmail'],
+ },
+ },
+ ]
+ case 'maxResults':
+ return [
+ {
+ ...subBlock,
+ placeholder:
+ 'Maximum number of results (read: default 1, max 10; search and lists: default 100, max 500)',
+ condition: { field: 'operation', value: ['read_gmail', ...PAGINATED_OPERATIONS] },
+ },
+ ]
+ case 'pageToken':
+ return [
+ {
+ ...subBlock,
+ placeholder: 'nextPageToken from a previous search or list',
+ condition: { field: 'operation', value: PAGINATED_OPERATIONS },
+ },
+ ]
+ case 'manualLabelId':
+ return [subBlock, ...GMAIL_V2_SUBBLOCKS]
+ default:
+ return [subBlock]
+ }
+ })
+}
+
export const GmailV2Block: BlockConfig = {
...GmailBlock,
sunset: undefined,
@@ -662,6 +907,33 @@ export const GmailV2Block: BlockConfig = {
name: 'Gmail',
hideFromToolbar: false,
integrationType: IntegrationType.Email,
+ canvasPresentation: GmailBlock.canvasPresentation && {
+ ...GmailBlock.canvasPresentation,
+ sentences: {
+ byOperation: {
+ ...GmailBlock.canvasPresentation.sentences?.byOperation,
+ list_labels_gmail: ['List labels'],
+ create_label_gmail: [{ text: 'Create label', field: 'labelName', core: true }],
+ update_label_gmail: [
+ { text: 'Update label', field: LABEL_TARGET_FIELD, core: true },
+ { text: ', renaming it to', field: 'labelName' },
+ ],
+ delete_label_gmail: [{ text: 'Delete label', field: LABEL_TARGET_FIELD, core: true }],
+ list_drafts_gmail: ['List drafts', { text: 'matching', field: 'listQuery' }],
+ get_draft_gmail: [{ text: 'Get draft', field: 'draftId', core: true }],
+ delete_draft_gmail: [{ text: 'Delete draft', field: 'draftId', core: true }],
+ list_threads_gmail: ['List threads', { text: 'matching', field: 'listQuery' }],
+ get_thread_gmail: [{ text: 'Get thread', field: 'targetThreadId', core: true }],
+ trash_thread_gmail: [
+ { text: 'Move thread', field: 'targetThreadId', core: true, after: 'to trash' },
+ ],
+ untrash_thread_gmail: [
+ { text: 'Restore thread', field: 'targetThreadId', core: true, after: 'from trash' },
+ ],
+ },
+ },
+ },
+ subBlocks: buildGmailV2SubBlocks(),
tools: {
...GmailBlock.tools,
access: [
@@ -679,48 +951,204 @@ export const GmailV2Block: BlockConfig = {
'gmail_add_label_v2',
'gmail_remove_label_v2',
'gmail_list_labels_v2',
+ 'gmail_create_label_v2',
+ 'gmail_update_label_v2',
+ 'gmail_delete_label_v2',
+ 'gmail_list_drafts_v2',
+ 'gmail_get_draft_v2',
+ 'gmail_delete_draft_v2',
+ 'gmail_list_threads_v2',
+ 'gmail_get_thread_v2',
+ 'gmail_trash_thread_v2',
+ 'gmail_untrash_thread_v2',
],
config: {
...GmailBlock.tools?.config,
tool: createVersionedToolSelector({
- baseToolSelector: selectGmailToolId,
+ baseToolSelector: selectGmailV2ToolId,
suffix: '_v2',
fallbackToolId: 'gmail_send_v2',
}),
+ params: (params) => {
+ const mapped = GmailBlock.tools?.config?.params?.(params) ?? params
+ const {
+ labelName,
+ targetThreadId,
+ threadFormat,
+ listQuery,
+ threadLabelIds,
+ messageListVisibility,
+ labelListVisibility,
+ ...rest
+ } = mapped
+ if (LABEL_WRITE_OPERATIONS.includes(rest.operation)) {
+ rest.name = labelName || undefined
+ rest.messageListVisibility = messageListVisibility || undefined
+ rest.labelListVisibility = labelListVisibility || undefined
+ }
+ if (THREAD_TARGET_OPERATIONS.includes(rest.operation)) {
+ rest.threadId = targetThreadId
+ rest.format = threadFormat || undefined
+ }
+ if (LIST_OPERATIONS.includes(rest.operation)) {
+ rest.query = listQuery || undefined
+ rest.labelIds = threadLabelIds ? String(threadLabelIds).trim() : undefined
+ }
+ return rest
+ },
},
},
+ inputs: {
+ ...GmailBlock.inputs,
+ labelId: { type: 'string', description: 'Label ID to update or delete (canonical param)' },
+ labelName: { type: 'string', description: 'Label display name' },
+ messageListVisibility: {
+ type: 'string',
+ description: 'Message list visibility for the label (show or hide)',
+ },
+ labelListVisibility: {
+ type: 'string',
+ description: 'Label list visibility (labelShow, labelShowIfUnread, or labelHide)',
+ },
+ targetThreadId: { type: 'string', description: 'Thread ID for thread operations' },
+ threadFormat: { type: 'string', description: 'Thread format (full, metadata, or minimal)' },
+ listQuery: { type: 'string', description: 'Search filter for listing drafts or threads' },
+ threadLabelIds: { type: 'string', description: 'Comma-separated label IDs to filter threads' },
+ },
outputs: {
// V2 tool outputs (API-aligned)
- id: { type: 'string', description: 'Gmail message ID' },
+ id: {
+ type: 'string',
+ description:
+ 'Gmail message ID (thread ID for thread operations, label ID for label operations, draft ID for Get Draft)',
+ },
threadId: { type: 'string', description: 'Gmail thread ID' },
labelIds: { type: 'array', description: 'Email label IDs' },
from: { type: 'string', description: 'Sender' },
to: { type: 'string', description: 'To' },
+ bcc: { type: 'string', description: 'Bcc recipients (only on messages you sent or drafted)' },
+ replyTo: { type: 'string', description: 'Reply-To header' },
subject: { type: 'string', description: 'Subject' },
date: { type: 'string', description: 'Date' },
+ rfcMessageId: {
+ type: 'string',
+ description: 'RFC Message-ID header (not the Gmail message id)',
+ },
+ inReplyTo: { type: 'string', description: 'In-Reply-To header' },
+ references: { type: 'string', description: 'References header' },
+ snippet: { type: 'string', description: 'Short excerpt of the message text' },
+ internalDate: {
+ type: 'string',
+ description: 'Gmail internal creation timestamp in epoch milliseconds',
+ },
body: { type: 'string', description: 'Email body text (best-effort)' },
- results: { type: 'json', description: 'Search/read summary results' },
+ results: {
+ type: 'json',
+ description:
+ 'Search/read summary results [{id, threadId, subject, from, to, cc, bcc, replyTo, date, snippet, rfcMessageId, inReplyTo, references, internalDate}]',
+ },
+ nextPageToken: {
+ type: 'string',
+ description: 'Token for the next page of results',
+ condition: { field: 'operation', value: PAGINATED_OPERATIONS },
+ },
+ resultSizeEstimate: {
+ type: 'number',
+ description: 'Estimated total number of results',
+ condition: { field: 'operation', value: PAGINATED_OPERATIONS },
+ },
+ name: {
+ type: 'string',
+ description: 'Label display name',
+ condition: { field: 'operation', value: LABEL_WRITE_OPERATIONS },
+ },
+ type: {
+ type: 'string',
+ description: 'Label type (system or user)',
+ condition: { field: 'operation', value: LABEL_WRITE_OPERATIONS },
+ },
+ messageListVisibility: {
+ type: 'string',
+ description: 'Message list visibility of the label',
+ condition: { field: 'operation', value: LABEL_WRITE_OPERATIONS },
+ },
+ labelListVisibility: {
+ type: 'string',
+ description: 'Label list visibility of the label',
+ condition: { field: 'operation', value: LABEL_WRITE_OPERATIONS },
+ },
+ labelId: {
+ type: 'string',
+ description: 'ID of the deleted label',
+ condition: { field: 'operation', value: 'delete_label_gmail' },
+ },
+ deleted: {
+ type: 'boolean',
+ description: 'Whether the label or draft was deleted',
+ condition: { field: 'operation', value: ['delete_label_gmail', 'delete_draft_gmail'] },
+ },
+ drafts: {
+ type: 'json',
+ description: 'Drafts [{id, messageId, threadId}]',
+ condition: { field: 'operation', value: 'list_drafts_gmail' },
+ },
+ threads: {
+ type: 'json',
+ description: 'Threads [{id, snippet, historyId}]',
+ condition: { field: 'operation', value: 'list_threads_gmail' },
+ },
+ messages: {
+ type: 'json',
+ description:
+ 'Thread messages [{id, threadId, labelIds, snippet, from, to, cc, bcc, replyTo, subject, date, rfcMessageId, inReplyTo, references, internalDate, body}]',
+ condition: { field: 'operation', value: 'get_thread_gmail' },
+ },
+ historyId: {
+ type: 'string',
+ description: 'ID of the last history record that modified the thread',
+ condition: { field: 'operation', value: 'get_thread_gmail' },
+ },
+ trashed: {
+ type: 'boolean',
+ description: 'Whether the thread was moved to trash',
+ condition: { field: 'operation', value: 'trash_thread_gmail' },
+ },
+ untrashed: {
+ type: 'boolean',
+ description: 'Whether the thread was restored from trash',
+ condition: { field: 'operation', value: 'untrash_thread_gmail' },
+ },
attachments: { type: 'file[]', description: 'Downloaded attachments (if enabled)' },
// Draft-specific outputs
draftId: {
type: 'string',
description: 'Draft ID',
- condition: { field: 'operation', value: ['draft_gmail', 'edit_draft_gmail'] },
+ condition: {
+ field: 'operation',
+ value: ['draft_gmail', 'edit_draft_gmail', 'delete_draft_gmail'],
+ },
},
messageId: {
type: 'string',
description: 'Gmail message ID for the draft',
- condition: { field: 'operation', value: ['draft_gmail', 'edit_draft_gmail'] },
+ condition: {
+ field: 'operation',
+ value: ['draft_gmail', 'edit_draft_gmail', 'get_draft_gmail'],
+ },
},
// Trigger outputs (unchanged)
email_id: { type: 'string', description: 'Gmail message ID' },
thread_id: { type: 'string', description: 'Gmail thread ID' },
- cc: { type: 'string', description: 'CC recipients (comma-separated)' },
+ cc: { type: 'string', description: 'Cc recipients (raw Cc header, comma-separated)' },
body_text: { type: 'string', description: 'Plain text email body' },
body_html: { type: 'string', description: 'HTML email body' },
- labels: { type: 'string', description: 'Email labels (comma-separated)' },
+ labels: {
+ type: 'json',
+ description:
+ 'Email labels (trigger, comma-separated); for List Labels, the label array [{id, name, type, messageListVisibility, labelListVisibility}]',
+ },
has_attachments: { type: 'boolean', description: 'Whether email has attachments' },
raw_email: { type: 'json', description: 'Complete raw email data from Gmail API (if enabled)' },
timestamp: { type: 'string', description: 'Event timestamp' },
diff --git a/apps/sim/blocks/blocks/google_calendar.ts b/apps/sim/blocks/blocks/google_calendar.ts
index 0a4c73828b2..c3ff09602b3 100644
--- a/apps/sim/blocks/blocks/google_calendar.ts
+++ b/apps/sim/blocks/blocks/google_calendar.ts
@@ -338,6 +338,58 @@ Return ONLY the RRULE string - no explanations, no extra text.`,
condition: { field: 'operation', value: ['create', 'update'] },
mode: 'advanced',
},
+ {
+ id: 'optionalAttendees',
+ title: 'Optional Attendees (comma-separated emails)',
+ type: 'short-input',
+ placeholder: 'alex@example.com, sam@example.com',
+ condition: { field: 'operation', value: 'create' },
+ mode: 'advanced',
+ },
+ {
+ id: 'visibility',
+ title: 'Visibility',
+ type: 'dropdown',
+ condition: { field: 'operation', value: ['create', 'update'] },
+ mode: 'advanced',
+ options: [
+ { label: 'Calendar default (unchanged on update)', id: '' },
+ { label: 'Default', id: 'default' },
+ { label: 'Public', id: 'public' },
+ { label: 'Private', id: 'private' },
+ { label: 'Confidential', id: 'confidential' },
+ ],
+ value: () => '',
+ },
+ {
+ id: 'transparency',
+ title: 'Show As',
+ type: 'dropdown',
+ condition: { field: 'operation', value: ['create', 'update'] },
+ mode: 'advanced',
+ options: [
+ { label: 'Calendar default (unchanged on update)', id: '' },
+ { label: 'Busy', id: 'opaque' },
+ { label: 'Free', id: 'transparent' },
+ ],
+ value: () => '',
+ },
+ {
+ id: 'colorId',
+ title: 'Color ID',
+ type: 'short-input',
+ placeholder: '1-11',
+ condition: { field: 'operation', value: ['create', 'update'] },
+ mode: 'advanced',
+ },
+ {
+ id: 'reminders',
+ title: 'Reminders',
+ type: 'long-input',
+ placeholder: '[{"method":"popup","minutes":10},{"method":"email","minutes":60}]',
+ condition: { field: 'operation', value: ['create', 'update'] },
+ mode: 'advanced',
+ },
{
id: 'timeMin',
@@ -400,6 +452,22 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
],
value: () => 'startTime',
},
+ {
+ id: 'updatedMin',
+ title: 'Modified Since',
+ type: 'short-input',
+ placeholder: '2025-06-03T00:00:00Z',
+ condition: { field: 'operation', value: 'list' },
+ mode: 'advanced',
+ },
+ {
+ id: 'listTimeZone',
+ title: 'Response Time Zone',
+ type: 'short-input',
+ placeholder: 'America/Los_Angeles (default: calendar time zone)',
+ condition: { field: 'operation', value: 'list' },
+ mode: 'advanced',
+ },
{
id: 'pageToken',
title: 'Page Token',
@@ -991,10 +1059,25 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
processedParams.maxResults = Number.parseInt(processedParams.maxResults, 10)
}
+ for (const key of ['visibility', 'transparency', 'colorId', 'reminders', 'updatedMin']) {
+ if (processedParams[key] === '') {
+ processedParams[key] = undefined
+ }
+ }
+
if (processedParams.minAccessRole === '') {
processedParams.minAccessRole = undefined
}
+ const listTimeZone = processedParams.listTimeZone
+ processedParams.listTimeZone = undefined
+ if (operation === 'list') {
+ processedParams.timeZone =
+ typeof listTimeZone === 'string' && listTimeZone.trim()
+ ? listTimeZone.trim()
+ : undefined
+ }
+
return {
oauthCredential,
...processedParams,
@@ -1016,6 +1099,16 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
recurrence: { type: 'string', description: 'Recurrence rule (RRULE)' },
addGoogleMeet: { type: 'boolean', description: 'Attach a Google Meet link' },
timeZone: { type: 'string', description: 'Time zone (IANA name)' },
+ optionalAttendees: { type: 'string', description: 'Optional attendee email list' },
+ visibility: {
+ type: 'string',
+ description: 'Event visibility (default, public, private, confidential)',
+ },
+ transparency: { type: 'string', description: 'Show as busy (opaque) or free (transparent)' },
+ colorId: { type: 'string', description: 'Event color ID' },
+ reminders: { type: 'json', description: 'Reminder overrides [{ method, minutes }]' },
+ updatedMin: { type: 'string', description: 'Only events modified since this time' },
+ listTimeZone: { type: 'string', description: 'Time zone used for List Events response times' },
timeMin: { type: 'string', description: 'Start time filter' },
timeMax: { type: 'string', description: 'End time filter' },
@@ -1107,6 +1200,41 @@ export const GoogleCalendarV2Block: BlockConfig = {
id: { type: 'string', description: 'Event or calendar ID' },
htmlLink: { type: 'string', description: 'Event link' },
hangoutLink: { type: 'string', description: 'Google Meet link' },
+ conferenceData: {
+ type: 'json',
+ description:
+ 'Conference details: conferenceId, conferenceSolution { name }, entryPoints [{ entryPointType (video, phone, sip, more), uri, label, pin, meetingCode, passcode }]',
+ },
+ recurringEventId: {
+ type: 'string',
+ description: 'Recurring series ID (for an instance of a recurring event)',
+ },
+ originalStartTime: {
+ type: 'json',
+ description: 'Original start time of a recurring event instance',
+ },
+ reminders: {
+ type: 'json',
+ description: 'Reminders: useDefault and overrides [{ method, minutes }]',
+ },
+ visibility: {
+ type: 'string',
+ description: 'Visibility: default, public, private, or confidential',
+ },
+ transparency: { type: 'string', description: 'Show as: opaque (busy) or transparent (free)' },
+ colorId: { type: 'string', description: 'Event color ID' },
+ eventType: {
+ type: 'string',
+ description:
+ 'Event type: default, outOfOffice, focusTime, workingLocation, fromGmail, birthday',
+ },
+ iCalUID: { type: 'string', description: 'iCalendar UID' },
+ created: { type: 'string', description: 'Event creation time' },
+ updated: { type: 'string', description: 'Event last modified time' },
+ attachments: {
+ type: 'json',
+ description: 'File attachments [{ fileUrl, title, mimeType, iconLink, fileId }]',
+ },
status: { type: 'string', description: 'Event status' },
summary: { type: 'string', description: 'Event or calendar title' },
description: { type: 'string', description: 'Event or calendar description' },
@@ -1114,12 +1242,24 @@ export const GoogleCalendarV2Block: BlockConfig = {
recurrence: { type: 'json', description: 'Recurrence rules (RRULE)' },
start: { type: 'json', description: 'Event start' },
end: { type: 'json', description: 'Event end' },
- attendees: { type: 'json', description: 'Event attendees' },
- creator: { type: 'json', description: 'Event creator' },
- organizer: { type: 'json', description: 'Event organizer' },
+ attendees: {
+ type: 'json',
+ description:
+ 'Event attendees [{ email, displayName, responseStatus (needsAction, declined, tentative, accepted), optional, organizer, self, resource, comment, additionalGuests }]',
+ },
+ creator: { type: 'json', description: 'Event creator { email, displayName, self }' },
+ organizer: { type: 'json', description: 'Event organizer { email, displayName, self }' },
responseStatus: { type: 'string', description: 'Your RSVP response (respond operation)' },
comment: { type: 'string', description: 'Your RSVP note (respond operation)' },
- events: { type: 'json', description: 'List of events (list operation)' },
+ events: {
+ type: 'json',
+ description:
+ 'List of events (list operation); each has the same fields as a single event (id, summary, start, end, attendees, organizer, hangoutLink, conferenceData, recurrence, reminders, visibility, status, htmlLink, and more)',
+ },
+ nextSyncToken: {
+ type: 'string',
+ description: 'Sync token for incremental sync (list operation, last page)',
+ },
eventId: { type: 'string', description: 'Deleted event ID' },
deleted: { type: 'boolean', description: 'Whether deletion/removal was successful' },
instances: { type: 'json', description: 'List of recurring event instances' },
diff --git a/apps/sim/blocks/blocks/google_contacts.ts b/apps/sim/blocks/blocks/google_contacts.ts
index 12efdfa7c1d..2b1a04d85f3 100644
--- a/apps/sim/blocks/blocks/google_contacts.ts
+++ b/apps/sim/blocks/blocks/google_contacts.ts
@@ -155,6 +155,26 @@ export const GoogleContactsBlock: BlockConfig = {
condition: { field: 'operation', value: ['create', 'update'] },
mode: 'advanced',
},
+ {
+ id: 'birthday',
+ title: 'Birthday',
+ type: 'short-input',
+ placeholder: '1990-04-15 or 04-15',
+ condition: { field: 'operation', value: ['create', 'update'] },
+ mode: 'advanced',
+ wandConfig: {
+ enabled: true,
+ prompt: `Convert the user's description of a birthday into a date.
+Use YYYY-MM-DD when the year is known, or MM-DD when it is not.
+
+Examples:
+- "April 15th 1990" -> 1990-04-15
+- "March 3" -> 03-03
+
+Return ONLY the date - no explanations, no extra text.`,
+ placeholder: 'Describe the birthday...',
+ },
+ },
// Get / Update / Delete Fields
{
@@ -277,6 +297,7 @@ export const GoogleContactsBlock: BlockConfig = {
organization: { type: 'string', description: 'Organization name' },
jobTitle: { type: 'string', description: 'Job title' },
notes: { type: 'string', description: 'Notes' },
+ birthday: { type: 'string', description: 'Birthday (YYYY-MM-DD or MM-DD)' },
// Get/Update/Delete inputs
resourceName: { type: 'string', description: 'Contact resource name' },
@@ -292,7 +313,11 @@ export const GoogleContactsBlock: BlockConfig = {
},
outputs: {
content: { type: 'string', description: 'Operation response content' },
- metadata: { type: 'json', description: 'Contact or contacts metadata' },
+ metadata: {
+ type: 'json',
+ description:
+ 'Contact (resourceName, etag, displayName, givenName, familyName, middleName, emails, phones, organizations, addresses, biographies, urls, photos, birthdays, events, nicknames, relations, userDefined, contactGroupResourceNames), list {contacts, nextPageToken, totalItems}, or search {contacts}',
+ },
},
}
diff --git a/apps/sim/blocks/blocks/google_docs.ts b/apps/sim/blocks/blocks/google_docs.ts
index 8fe4799dc14..a126d9312bf 100644
--- a/apps/sim/blocks/blocks/google_docs.ts
+++ b/apps/sim/blocks/blocks/google_docs.ts
@@ -334,6 +334,14 @@ Return ONLY the text to insert - no explanations, no extra text.`,
condition: { field: 'operation', value: 'replace_text' },
description: 'When on, only case-sensitive matches are replaced.',
},
+ {
+ id: 'searchByRegex',
+ title: 'Use Regular Expression',
+ type: 'switch',
+ condition: { field: 'operation', value: 'replace_text' },
+ description: 'When on, the Find text is treated as a regular expression.',
+ mode: 'advanced',
+ },
// Insert Table fields
{
id: 'rows',
@@ -483,6 +491,21 @@ Return ONLY the text to insert - no explanations, no extra text.`,
{ label: 'Star / Circle / Square', id: 'BULLET_STAR_CIRCLE_SQUARE' },
{ label: 'Numbered: Decimal / Alpha / Roman', id: 'NUMBERED_DECIMAL_ALPHA_ROMAN' },
{ label: 'Numbered: Decimal Nested', id: 'NUMBERED_DECIMAL_NESTED' },
+ { label: 'Diamond X / Arrow 3D / Square', id: 'BULLET_DIAMONDX_ARROW3D_SQUARE' },
+ { label: 'Arrow 3D / Circle / Square', id: 'BULLET_ARROW3D_CIRCLE_SQUARE' },
+ {
+ label: 'Numbered: Decimal / Alpha / Roman (parens)',
+ id: 'NUMBERED_DECIMAL_ALPHA_ROMAN_PARENS',
+ },
+ {
+ label: 'Numbered: Upper Alpha / Alpha / Roman',
+ id: 'NUMBERED_UPPERALPHA_ALPHA_ROMAN',
+ },
+ {
+ label: 'Numbered: Upper Roman / Upper Alpha / Decimal',
+ id: 'NUMBERED_UPPERROMAN_UPPERALPHA_DECIMAL',
+ },
+ { label: 'Numbered: Zero Decimal / Alpha / Roman', id: 'NUMBERED_ZERODECIMAL_ALPHA_ROMAN' },
],
condition: { field: 'operation', value: 'create_paragraph_bullets' },
},
@@ -510,6 +533,48 @@ Return ONLY the text to insert - no explanations, no extra text.`,
placeholder: 'Name of the named range(s) to delete',
condition: { field: 'operation', value: 'delete_named_range' },
},
+ // Read options
+ {
+ id: 'includeTabsContent',
+ title: 'Read All Tabs',
+ type: 'switch',
+ condition: { field: 'operation', value: 'read' },
+ description:
+ 'When on, reads every tab of the document instead of only the first tab, and returns each tab separately.',
+ mode: 'advanced',
+ },
+ {
+ id: 'suggestionsViewMode',
+ title: 'Suggested Edits',
+ type: 'dropdown',
+ options: [
+ { label: 'Default (based on access)', id: '' },
+ { label: 'Show inline', id: 'SUGGESTIONS_INLINE' },
+ { label: 'Preview with suggestions accepted', id: 'PREVIEW_SUGGESTIONS_ACCEPTED' },
+ { label: 'Preview with suggestions rejected', id: 'PREVIEW_WITHOUT_SUGGESTIONS' },
+ ],
+ condition: { field: 'operation', value: 'read' },
+ mode: 'advanced',
+ },
+ // Target tab (advanced) for multi-tab documents
+ {
+ id: 'tabId',
+ title: 'Tab ID',
+ type: 'short-input',
+ placeholder: 'Tab ID (empty: first tab; for Find & Replace: all tabs)',
+ condition: {
+ field: 'operation',
+ value: [
+ 'write',
+ 'insert_text',
+ 'replace_text',
+ 'insert_table',
+ 'insert_image',
+ 'insert_page_break',
+ ],
+ },
+ mode: 'advanced',
+ },
// Shared insertion index (advanced) for the insert operations
{
id: 'index',
@@ -652,10 +717,22 @@ Return ONLY the text to insert - no explanations, no extra text.`,
name: { type: 'string', description: 'Name for a created named range' },
namedRangeId: { type: 'string', description: 'ID of a named range to delete' },
namedRangeName: { type: 'string', description: 'Name of named range(s) to delete' },
+ includeTabsContent: { type: 'boolean', description: 'Read every tab of the document' },
+ suggestionsViewMode: { type: 'string', description: 'How suggested edits are returned' },
+ tabId: { type: 'string', description: 'Target tab ID in a multi-tab document' },
+ searchByRegex: { type: 'boolean', description: 'Treat find text as a regular expression' },
},
outputs: {
content: { type: 'string', description: 'Document content' },
- metadata: { type: 'json', description: 'Document metadata' },
+ metadata: {
+ type: 'json',
+ description: 'Document metadata (documentId, title, mimeType, url, revisionId on read)',
+ },
+ tabs: {
+ type: 'json',
+ description:
+ 'Document tabs when Read All Tabs is on: [{tabId, title, parentTabId, index, nestingLevel, content}]',
+ },
updatedContent: { type: 'boolean', description: 'Content update status' },
occurrencesChanged: {
type: 'number',
diff --git a/apps/sim/blocks/blocks/google_drive.ts b/apps/sim/blocks/blocks/google_drive.ts
index 5de854ce29d..183c02dcb2d 100644
--- a/apps/sim/blocks/blocks/google_drive.ts
+++ b/apps/sim/blocks/blocks/google_drive.ts
@@ -387,32 +387,26 @@ Return ONLY the file content - no explanations, no markdown code blocks, no extr
id: 'query',
title: 'Search Query',
type: 'short-input',
- placeholder: 'Search for specific files (e.g., name contains "report")',
+ placeholder: 'Filter by part of the file name (e.g., report)',
condition: { field: 'operation', value: 'list' },
wandConfig: {
enabled: true,
- prompt: `Generate a Google Drive search query based on the user's description.
-Use Google Drive query syntax:
-- name contains 'term' - search by filename
-- mimeType = 'type' - filter by file type
-- modifiedTime > 'date' - filter by date
-- 'email' in owners - filter by owner
-- fullText contains 'term' - search file contents
+ prompt: `Extract a plain file-name search term from the user's description.
+The term is matched against file names only (name contains). Do not use Google Drive query syntax, operators, or quotes; use the Search Files operation for advanced queries.
Examples:
-- "PDF files" -> mimeType = 'application/pdf'
-- "files named report" -> name contains 'report'
-- "spreadsheets modified today" -> mimeType = 'application/vnd.google-apps.spreadsheet' and modifiedTime > '2024-01-01'
+- "files named report" -> report
+- "the Q3 budget spreadsheet" -> Q3 budget
-Return ONLY the query string - no explanations, no quotes around the whole thing, no extra text.`,
- placeholder: 'Describe the files you want to find...',
+Return ONLY the search term - no explanations, no quotes, no extra text.`,
+ placeholder: 'Describe the file name you want to find...',
},
},
{
id: 'pageSize',
title: 'Results Per Page',
type: 'short-input',
- placeholder: 'Number of results (default: 100, max: 100)',
+ placeholder: 'Number of results (default: 100, max: 1000)',
condition: { field: 'operation', value: 'list' },
},
{
@@ -423,6 +417,32 @@ Return ONLY the query string - no explanations, no quotes around the whole thing
mode: 'advanced',
condition: { field: 'operation', value: 'list' },
},
+ {
+ id: 'listOrderBy',
+ title: 'Sort By',
+ type: 'dropdown',
+ options: [
+ { label: 'Default', id: '' },
+ { label: 'Last modified (newest first)', id: 'modifiedTime desc' },
+ { label: 'Last modified (oldest first)', id: 'modifiedTime' },
+ { label: 'Name (A-Z)', id: 'name_natural' },
+ { label: 'Name (Z-A)', id: 'name_natural desc' },
+ { label: 'Created (newest first)', id: 'createdTime desc' },
+ { label: 'Recently used', id: 'recency desc' },
+ { label: 'Folders first, then name', id: 'folder,name_natural' },
+ ],
+ value: () => '',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'list' },
+ },
+ {
+ id: 'listDriveId',
+ title: 'Shared Drive ID',
+ type: 'short-input',
+ placeholder: 'Restrict to one shared drive (default: My Drive and all shared drives)',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'list' },
+ },
{
id: 'downloadFileSelector',
title: 'Select File',
@@ -798,6 +818,57 @@ Return ONLY the message text - no subject line, no greetings/signatures, no extr
placeholder: 'Describe why you are sharing this file...',
},
},
+ {
+ id: 'shareExpirationTime',
+ title: 'Access Expires',
+ type: 'short-input',
+ placeholder: 'Optional expiry, at most one year out (e.g., 2025-12-31T23:59:59Z)',
+ mode: 'advanced',
+ condition: {
+ field: 'operation',
+ value: 'share',
+ and: { field: 'shareType', value: ['user', 'group'] },
+ },
+ wandConfig: {
+ enabled: true,
+ generationType: 'timestamp',
+ prompt: `Generate an RFC 3339 timestamp for when shared access should expire, based on the user's description. It must be in the future and no more than one year from now.
+Examples:
+- "in 30 days" -> a timestamp 30 days from now, e.g. 2025-02-14T00:00:00Z
+- "end of the year" -> 2025-12-31T23:59:59Z
+
+Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
+ placeholder: 'Describe when access should expire (e.g., "in 2 weeks")...',
+ },
+ },
+ {
+ id: 'shareAllowFileDiscovery',
+ title: 'Discoverable in Search',
+ type: 'dropdown',
+ options: [
+ { label: 'Default', id: '' },
+ { label: 'Yes', id: 'true' },
+ { label: 'No (link only)', id: 'false' },
+ ],
+ value: () => '',
+ mode: 'advanced',
+ condition: {
+ field: 'operation',
+ value: 'share',
+ and: { field: 'shareType', value: ['domain', 'anyone'] },
+ },
+ },
+ {
+ id: 'shareMoveToNewOwnersRoot',
+ title: "Move to New Owner's My Drive",
+ type: 'switch',
+ mode: 'advanced',
+ condition: {
+ field: 'operation',
+ value: 'share',
+ and: { field: 'role', value: 'owner' },
+ },
+ },
{
id: 'unshareFileSelector',
title: 'Select File',
@@ -854,6 +925,14 @@ Return ONLY the message text - no subject line, no greetings/signatures, no extr
condition: { field: 'operation', value: 'list_permissions' },
required: true,
},
+ {
+ id: 'permissionsPageSize',
+ title: 'Results Per Page',
+ type: 'short-input',
+ placeholder: 'Number of permissions (max: 100; default: all for My Drive files)',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'list_permissions' },
+ },
{
id: 'permissionsPageToken',
title: 'Page Token',
@@ -1023,7 +1102,7 @@ Return ONLY the query string - no explanations, no quotes around the whole thing
id: 'searchPageSize',
title: 'Results Per Page',
type: 'short-input',
- placeholder: 'Number of results (default: 100, max: 100)',
+ placeholder: 'Number of results (default: 100, max: 1000)',
mode: 'advanced',
condition: { field: 'operation', value: 'search' },
},
@@ -1035,6 +1114,32 @@ Return ONLY the query string - no explanations, no quotes around the whole thing
mode: 'advanced',
condition: { field: 'operation', value: 'search' },
},
+ {
+ id: 'searchOrderBy',
+ title: 'Sort By',
+ type: 'dropdown',
+ options: [
+ { label: 'Default', id: '' },
+ { label: 'Last modified (newest first)', id: 'modifiedTime desc' },
+ { label: 'Last modified (oldest first)', id: 'modifiedTime' },
+ { label: 'Name (A-Z)', id: 'name_natural' },
+ { label: 'Name (Z-A)', id: 'name_natural desc' },
+ { label: 'Created (newest first)', id: 'createdTime desc' },
+ { label: 'Recently used', id: 'recency desc' },
+ { label: 'Folders first, then name', id: 'folder,name_natural' },
+ ],
+ value: () => '',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'search' },
+ },
+ {
+ id: 'searchDriveId',
+ title: 'Shared Drive ID',
+ type: 'short-input',
+ placeholder: 'Restrict to one shared drive (default: My Drive and all shared drives)',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'search' },
+ },
{
id: 'untrashFileSelector',
title: 'Select File to Restore',
@@ -1232,6 +1337,25 @@ Return ONLY the query string - no explanations, no quotes around the whole thing
mode: 'advanced',
condition: { field: 'operation', value: 'list_comments' },
},
+ {
+ id: 'commentsStartModifiedTime',
+ title: 'Modified After',
+ type: 'short-input',
+ placeholder: 'Only comments modified after this time (e.g., 2025-01-01T00:00:00Z)',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'list_comments' },
+ wandConfig: {
+ enabled: true,
+ generationType: 'timestamp',
+ prompt: `Generate an RFC 3339 timestamp based on the user's description.
+Examples:
+- "last week" -> a timestamp 7 days before now, e.g. 2025-01-08T00:00:00Z
+- "since January 1st 2025" -> 2025-01-01T00:00:00Z
+
+Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
+ placeholder: 'Describe the cutoff time (e.g., "last 7 days")...',
+ },
+ },
{
id: 'includeDeleted',
title: 'Include Deleted Comments',
@@ -1453,6 +1577,15 @@ Return ONLY the comment text - no explanations, no quotes, no extra formatting.`
commentsPageToken,
getContentExportMimeType,
exportMimeType,
+ listOrderBy,
+ searchOrderBy,
+ listDriveId,
+ searchDriveId,
+ permissionsPageSize,
+ commentsStartModifiedTime,
+ shareExpirationTime,
+ shareAllowFileDiscovery,
+ shareMoveToNewOwnersRoot,
...rest
} = params
@@ -1555,6 +1688,7 @@ Return ONLY the comment text - no explanations, no quotes, no extra formatting.`
if (params.operation === 'search') effectivePageSize = searchPageSize
else if (params.operation === 'list_revisions') effectivePageSize = revisionsPageSize
else if (params.operation === 'list_comments') effectivePageSize = commentsPageSize
+ else if (params.operation === 'list_permissions') effectivePageSize = permissionsPageSize
let effectivePageToken: string | undefined = pageToken
if (params.operation === 'search') effectivePageToken = searchPageToken
@@ -1564,6 +1698,36 @@ Return ONLY the comment text - no explanations, no quotes, no extra formatting.`
else if (params.operation !== 'list') effectivePageToken = undefined
const effectiveQuery = params.operation === 'search' ? searchQuery : query
+
+ const effectiveOrderBy =
+ params.operation === 'list'
+ ? listOrderBy
+ : params.operation === 'search'
+ ? searchOrderBy
+ : undefined
+ const effectiveDriveId =
+ params.operation === 'list'
+ ? listDriveId
+ : params.operation === 'search'
+ ? searchDriveId
+ : undefined
+
+ const isShare = params.operation === 'share'
+ const isUserOrGroupShare = isShare && (shareType === 'user' || shareType === 'group')
+ const isDomainOrAnyoneShare = isShare && (shareType === 'domain' || shareType === 'anyone')
+ const allowFileDiscoveryValue = !isDomainOrAnyoneShare
+ ? undefined
+ : shareAllowFileDiscovery === 'true' || shareAllowFileDiscovery === true
+ ? true
+ : shareAllowFileDiscovery === 'false' || shareAllowFileDiscovery === false
+ ? false
+ : undefined
+ const moveToNewOwnersRootValue =
+ isShare &&
+ rest.role === 'owner' &&
+ (shareMoveToNewOwnersRoot === true || shareMoveToNewOwnersRoot === 'true')
+ ? true
+ : undefined
const effectiveMimeType =
params.operation === 'get_content'
? getContentExportMimeType
@@ -1590,6 +1754,15 @@ Return ONLY the comment text - no explanations, no quotes, no extra formatting.`
includeRevisions: includeRevisionsValue,
includeDeleted: includeDeletedValue,
transferOwnership: rest.role === 'owner' ? true : undefined,
+ orderBy: effectiveOrderBy?.trim() || undefined,
+ driveId: effectiveDriveId?.trim() || undefined,
+ startModifiedTime:
+ params.operation === 'list_comments'
+ ? commentsStartModifiedTime?.trim() || undefined
+ : undefined,
+ expirationTime: isUserOrGroupShare ? shareExpirationTime?.trim() || undefined : undefined,
+ allowFileDiscovery: allowFileDiscoveryValue,
+ moveToNewOwnersRoot: moveToNewOwnersRootValue,
...rest,
}
},
@@ -1653,6 +1826,27 @@ Return ONLY the comment text - no explanations, no quotes, no extra formatting.`
anchor: { type: 'string', description: 'Anchor region for a new comment' },
includeDeleted: { type: 'string', description: 'Include deleted comments when listing' },
commentsPageSize: { type: 'string', description: 'Results per page for comments' },
+ listOrderBy: { type: 'string', description: 'Sort order when listing files' },
+ searchOrderBy: { type: 'string', description: 'Sort order when searching files' },
+ listDriveId: { type: 'string', description: 'Shared drive to restrict listing to' },
+ searchDriveId: { type: 'string', description: 'Shared drive to restrict search to' },
+ permissionsPageSize: { type: 'string', description: 'Results per page for permissions' },
+ commentsStartModifiedTime: {
+ type: 'string',
+ description: 'Only list comments modified after this RFC 3339 time',
+ },
+ shareExpirationTime: {
+ type: 'string',
+ description: 'RFC 3339 time when a user or group permission expires',
+ },
+ shareAllowFileDiscovery: {
+ type: 'string',
+ description: 'Whether a domain or anyone permission is discoverable in search',
+ },
+ shareMoveToNewOwnersRoot: {
+ type: 'boolean',
+ description: "Move the file to the new owner's My Drive root when transferring ownership",
+ },
},
outputs: {
file: { type: 'file', description: 'Downloaded file stored in execution files' },
@@ -1660,6 +1854,10 @@ Return ONLY the comment text - no explanations, no quotes, no extra formatting.`
metadata: { type: 'json', description: 'Complete file metadata (from download)' },
content: { type: 'string', description: 'File content as text' },
nextPageToken: { type: 'string', description: 'Token for fetching the next page of results' },
+ incompleteSearch: {
+ type: 'boolean',
+ description: 'Whether a list or search was incomplete and some results may be missing',
+ },
permission: { type: 'json', description: 'Permission details' },
permissions: { type: 'json', description: 'List of permissions' },
user: { type: 'json', description: 'User information' },
diff --git a/apps/sim/blocks/blocks/google_forms.ts b/apps/sim/blocks/blocks/google_forms.ts
index 1390fde09a4..4ee9fe1a337 100644
--- a/apps/sim/blocks/blocks/google_forms.ts
+++ b/apps/sim/blocks/blocks/google_forms.ts
@@ -416,7 +416,8 @@ Example for "Add a required multiple choice question about favorite color":
outputs: {
responses: {
type: 'json',
- description: 'Array of form responses',
+ description:
+ 'Array of form responses [{responseId, createTime, lastSubmittedTime, answers, respondentEmail, totalScore}]',
condition: {
field: 'operation',
value: 'get_responses',
@@ -434,7 +435,8 @@ Example for "Add a required multiple choice question about favorite color":
},
response: {
type: 'json',
- description: 'Single form response',
+ description:
+ 'Single form response (responseId, createTime, lastSubmittedTime, answers, respondentEmail, totalScore)',
condition: {
field: 'operation',
value: 'get_responses',
diff --git a/apps/sim/blocks/blocks/google_groups.test.ts b/apps/sim/blocks/blocks/google_groups.test.ts
new file mode 100644
index 00000000000..3a218434b93
--- /dev/null
+++ b/apps/sim/blocks/blocks/google_groups.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from 'vitest'
+import { GoogleGroupsBlock } from '@/blocks/blocks/google_groups'
+
+/**
+ * The executor builds tool params as `{ ...inputs, ...transformedParams }`, so an empty dropdown
+ * value the serializer fills in reaches the tool unless the mapper assigns that key explicitly.
+ */
+describe('GoogleGroupsBlock update settings', () => {
+ const mapParams = GoogleGroupsBlock.tools.config?.params as (
+ params: Record
+ ) => Record
+
+ it('maps every unchanged setting to undefined so empty values are never sent', () => {
+ const mapped = mapParams({
+ operation: 'update_settings',
+ oauthCredential: 'cred-1',
+ groupEmail: 'team@example.com',
+ whoCanJoin: '',
+ whoCanViewMembership: '',
+ whoCanViewGroup: '',
+ whoCanPostMessage: '',
+ allowExternalMembers: '',
+ customReplyTo: '',
+ })
+ for (const key of [
+ 'whoCanJoin',
+ 'whoCanViewMembership',
+ 'whoCanViewGroup',
+ 'whoCanPostMessage',
+ 'allowExternalMembers',
+ 'customReplyTo',
+ ]) {
+ expect(mapped).toHaveProperty(key, undefined)
+ }
+ })
+
+ it('forwards a chosen setting as a string', () => {
+ const mapped = mapParams({
+ operation: 'update_settings',
+ oauthCredential: 'cred-1',
+ groupEmail: 'team@example.com',
+ whoCanJoin: 'INVITED_CAN_JOIN',
+ })
+ expect(mapped.whoCanJoin).toBe('INVITED_CAN_JOIN')
+ })
+})
diff --git a/apps/sim/blocks/blocks/google_groups.ts b/apps/sim/blocks/blocks/google_groups.ts
index 14ffa3576a1..4b94aa69d7c 100644
--- a/apps/sim/blocks/blocks/google_groups.ts
+++ b/apps/sim/blocks/blocks/google_groups.ts
@@ -4,6 +4,31 @@ import type { BlockConfig, BlockMeta } from '@/blocks/types'
import { AuthMode, IntegrationType } from '@/blocks/types'
import { SERVICE_ACCOUNT_SUBBLOCKS } from '@/blocks/utils'
+/** Settings the Update Settings operation forwards when set; empty means unchanged. */
+const UPDATE_SETTINGS_FIELDS = [
+ 'whoCanJoin',
+ 'whoCanViewMembership',
+ 'whoCanViewGroup',
+ 'whoCanPostMessage',
+ 'allowExternalMembers',
+ 'whoCanDiscoverGroup',
+ 'whoCanContactOwner',
+ 'whoCanLeaveGroup',
+ 'whoCanModerateMembers',
+ 'whoCanModerateContent',
+ 'messageModerationLevel',
+ 'spamModerationLevel',
+ 'replyTo',
+ 'allowWebPosting',
+ 'isArchived',
+ 'archiveOnly',
+ 'includeInGlobalAddressList',
+ 'membersCanPostAsTheGroup',
+ 'enableCollaborativeInbox',
+ 'customReplyTo',
+ 'primaryLanguage',
+] as const
+
export const GoogleGroupsBlock: BlockConfig = {
type: 'google_groups',
name: 'Google Groups',
@@ -322,6 +347,326 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`,
placeholder: 'OWNER,MANAGER,MEMBER',
condition: { field: 'operation', value: 'list_members' },
},
+ {
+ id: 'userKey',
+ title: 'Member Email or ID',
+ type: 'short-input',
+ placeholder: 'Only groups this user belongs to (e.g., user@example.com)',
+ condition: { field: 'operation', value: 'list_groups' },
+ },
+ {
+ id: 'sortOrder',
+ title: 'Sort by Email',
+ type: 'dropdown',
+ options: [
+ { label: 'Default', id: '' },
+ { label: 'Ascending', id: 'ASCENDING' },
+ { label: 'Descending', id: 'DESCENDING' },
+ ],
+ value: () => '',
+ condition: { field: 'operation', value: 'list_groups' },
+ mode: 'advanced',
+ },
+ {
+ id: 'includeDerivedMembership',
+ title: 'Include Nested Members',
+ type: 'switch',
+ condition: { field: 'operation', value: 'list_members' },
+ mode: 'advanced',
+ },
+ {
+ id: 'pageToken',
+ title: 'Page Token',
+ type: 'short-input',
+ placeholder: 'Token from a previous response',
+ condition: { field: 'operation', value: ['list_groups', 'list_members'] },
+ mode: 'advanced',
+ },
+ // Update Settings Fields (empty means unchanged)
+ {
+ id: 'whoCanJoin',
+ title: 'Who Can Join',
+ type: 'dropdown',
+ options: [
+ { label: 'No change', id: '' },
+ { label: 'Anyone Can Join', id: 'ANYONE_CAN_JOIN' },
+ { label: 'All In Domain Can Join', id: 'ALL_IN_DOMAIN_CAN_JOIN' },
+ { label: 'Invited Can Join', id: 'INVITED_CAN_JOIN' },
+ { label: 'Can Request To Join', id: 'CAN_REQUEST_TO_JOIN' },
+ ],
+ value: () => '',
+ condition: { field: 'operation', value: 'update_settings' },
+ },
+ {
+ id: 'whoCanViewMembership',
+ title: 'Who Can View Members',
+ type: 'dropdown',
+ options: [
+ { label: 'No change', id: '' },
+ { label: 'All In Domain Can View', id: 'ALL_IN_DOMAIN_CAN_VIEW' },
+ { label: 'All Members Can View', id: 'ALL_MEMBERS_CAN_VIEW' },
+ { label: 'All Managers Can View', id: 'ALL_MANAGERS_CAN_VIEW' },
+ ],
+ value: () => '',
+ condition: { field: 'operation', value: 'update_settings' },
+ },
+ {
+ id: 'whoCanViewGroup',
+ title: 'Who Can View Messages',
+ type: 'dropdown',
+ options: [
+ { label: 'No change', id: '' },
+ { label: 'Anyone Can View', id: 'ANYONE_CAN_VIEW' },
+ { label: 'All In Domain Can View', id: 'ALL_IN_DOMAIN_CAN_VIEW' },
+ { label: 'All Members Can View', id: 'ALL_MEMBERS_CAN_VIEW' },
+ { label: 'All Managers Can View', id: 'ALL_MANAGERS_CAN_VIEW' },
+ ],
+ value: () => '',
+ condition: { field: 'operation', value: 'update_settings' },
+ },
+ {
+ id: 'whoCanPostMessage',
+ title: 'Who Can Post',
+ type: 'dropdown',
+ options: [
+ { label: 'No change', id: '' },
+ { label: 'None Can Post', id: 'NONE_CAN_POST' },
+ { label: 'All Managers Can Post', id: 'ALL_MANAGERS_CAN_POST' },
+ { label: 'All Members Can Post', id: 'ALL_MEMBERS_CAN_POST' },
+ { label: 'All Owners Can Post', id: 'ALL_OWNERS_CAN_POST' },
+ { label: 'All In Domain Can Post', id: 'ALL_IN_DOMAIN_CAN_POST' },
+ { label: 'Anyone Can Post', id: 'ANYONE_CAN_POST' },
+ ],
+ value: () => '',
+ condition: { field: 'operation', value: 'update_settings' },
+ },
+ {
+ id: 'allowExternalMembers',
+ title: 'Allow External Members',
+ type: 'dropdown',
+ options: [
+ { label: 'No change', id: '' },
+ { label: 'Yes', id: 'true' },
+ { label: 'No', id: 'false' },
+ ],
+ value: () => '',
+ condition: { field: 'operation', value: 'update_settings' },
+ },
+ {
+ id: 'whoCanDiscoverGroup',
+ title: 'Who Can Discover',
+ type: 'dropdown',
+ options: [
+ { label: 'No change', id: '' },
+ { label: 'Anyone Can Discover', id: 'ANYONE_CAN_DISCOVER' },
+ { label: 'All In Domain Can Discover', id: 'ALL_IN_DOMAIN_CAN_DISCOVER' },
+ { label: 'All Members Can Discover', id: 'ALL_MEMBERS_CAN_DISCOVER' },
+ ],
+ value: () => '',
+ condition: { field: 'operation', value: 'update_settings' },
+ mode: 'advanced',
+ },
+ {
+ id: 'whoCanContactOwner',
+ title: 'Who Can Contact Owner',
+ type: 'dropdown',
+ options: [
+ { label: 'No change', id: '' },
+ { label: 'Anyone Can Contact', id: 'ANYONE_CAN_CONTACT' },
+ { label: 'All In Domain Can Contact', id: 'ALL_IN_DOMAIN_CAN_CONTACT' },
+ { label: 'All Members Can Contact', id: 'ALL_MEMBERS_CAN_CONTACT' },
+ { label: 'All Managers Can Contact', id: 'ALL_MANAGERS_CAN_CONTACT' },
+ { label: 'All Owners Can Contact', id: 'ALL_OWNERS_CAN_CONTACT' },
+ ],
+ value: () => '',
+ condition: { field: 'operation', value: 'update_settings' },
+ mode: 'advanced',
+ },
+ {
+ id: 'whoCanLeaveGroup',
+ title: 'Who Can Leave',
+ type: 'dropdown',
+ options: [
+ { label: 'No change', id: '' },
+ { label: 'All Members Can Leave', id: 'ALL_MEMBERS_CAN_LEAVE' },
+ { label: 'All Managers Can Leave', id: 'ALL_MANAGERS_CAN_LEAVE' },
+ { label: 'None Can Leave', id: 'NONE_CAN_LEAVE' },
+ ],
+ value: () => '',
+ condition: { field: 'operation', value: 'update_settings' },
+ mode: 'advanced',
+ },
+ {
+ id: 'whoCanModerateMembers',
+ title: 'Who Can Manage Members',
+ type: 'dropdown',
+ options: [
+ { label: 'No change', id: '' },
+ { label: 'All Members', id: 'ALL_MEMBERS' },
+ { label: 'Owners And Managers', id: 'OWNERS_AND_MANAGERS' },
+ { label: 'Owners Only', id: 'OWNERS_ONLY' },
+ { label: 'None', id: 'NONE' },
+ ],
+ value: () => '',
+ condition: { field: 'operation', value: 'update_settings' },
+ mode: 'advanced',
+ },
+ {
+ id: 'whoCanModerateContent',
+ title: 'Who Can Moderate Content',
+ type: 'dropdown',
+ options: [
+ { label: 'No change', id: '' },
+ { label: 'All Members', id: 'ALL_MEMBERS' },
+ { label: 'Owners And Managers', id: 'OWNERS_AND_MANAGERS' },
+ { label: 'Owners Only', id: 'OWNERS_ONLY' },
+ { label: 'None', id: 'NONE' },
+ ],
+ value: () => '',
+ condition: { field: 'operation', value: 'update_settings' },
+ mode: 'advanced',
+ },
+ {
+ id: 'messageModerationLevel',
+ title: 'Message Moderation',
+ type: 'dropdown',
+ options: [
+ { label: 'No change', id: '' },
+ { label: 'Moderate All Messages', id: 'MODERATE_ALL_MESSAGES' },
+ { label: 'Moderate Non Members', id: 'MODERATE_NON_MEMBERS' },
+ { label: 'Moderate New Members', id: 'MODERATE_NEW_MEMBERS' },
+ { label: 'Moderate None', id: 'MODERATE_NONE' },
+ ],
+ value: () => '',
+ condition: { field: 'operation', value: 'update_settings' },
+ mode: 'advanced',
+ },
+ {
+ id: 'spamModerationLevel',
+ title: 'Spam Handling',
+ type: 'dropdown',
+ options: [
+ { label: 'No change', id: '' },
+ { label: 'Allow', id: 'ALLOW' },
+ { label: 'Moderate', id: 'MODERATE' },
+ { label: 'Silently Moderate', id: 'SILENTLY_MODERATE' },
+ { label: 'Reject', id: 'REJECT' },
+ ],
+ value: () => '',
+ condition: { field: 'operation', value: 'update_settings' },
+ mode: 'advanced',
+ },
+ {
+ id: 'replyTo',
+ title: 'Default Reply To',
+ type: 'dropdown',
+ options: [
+ { label: 'No change', id: '' },
+ { label: 'Reply To Sender', id: 'REPLY_TO_SENDER' },
+ { label: 'Reply To List', id: 'REPLY_TO_LIST' },
+ { label: 'Reply To Owner', id: 'REPLY_TO_OWNER' },
+ { label: 'Reply To Custom', id: 'REPLY_TO_CUSTOM' },
+ { label: 'Reply To Ignore', id: 'REPLY_TO_IGNORE' },
+ { label: 'Reply To Managers', id: 'REPLY_TO_MANAGERS' },
+ ],
+ value: () => '',
+ condition: { field: 'operation', value: 'update_settings' },
+ mode: 'advanced',
+ },
+ {
+ id: 'allowWebPosting',
+ title: 'Allow Web Posting',
+ type: 'dropdown',
+ options: [
+ { label: 'No change', id: '' },
+ { label: 'Yes', id: 'true' },
+ { label: 'No', id: 'false' },
+ ],
+ value: () => '',
+ condition: { field: 'operation', value: 'update_settings' },
+ mode: 'advanced',
+ },
+ {
+ id: 'isArchived',
+ title: 'Archive Messages',
+ type: 'dropdown',
+ options: [
+ { label: 'No change', id: '' },
+ { label: 'Yes', id: 'true' },
+ { label: 'No', id: 'false' },
+ ],
+ value: () => '',
+ condition: { field: 'operation', value: 'update_settings' },
+ mode: 'advanced',
+ },
+ {
+ id: 'archiveOnly',
+ title: 'Archive Only (Inactive)',
+ type: 'dropdown',
+ options: [
+ { label: 'No change', id: '' },
+ { label: 'Yes', id: 'true' },
+ { label: 'No', id: 'false' },
+ ],
+ value: () => '',
+ condition: { field: 'operation', value: 'update_settings' },
+ mode: 'advanced',
+ },
+ {
+ id: 'includeInGlobalAddressList',
+ title: 'Include in Global Address List',
+ type: 'dropdown',
+ options: [
+ { label: 'No change', id: '' },
+ { label: 'Yes', id: 'true' },
+ { label: 'No', id: 'false' },
+ ],
+ value: () => '',
+ condition: { field: 'operation', value: 'update_settings' },
+ mode: 'advanced',
+ },
+ {
+ id: 'membersCanPostAsTheGroup',
+ title: 'Members Can Post as Group',
+ type: 'dropdown',
+ options: [
+ { label: 'No change', id: '' },
+ { label: 'Yes', id: 'true' },
+ { label: 'No', id: 'false' },
+ ],
+ value: () => '',
+ condition: { field: 'operation', value: 'update_settings' },
+ mode: 'advanced',
+ },
+ {
+ id: 'enableCollaborativeInbox',
+ title: 'Collaborative Inbox',
+ type: 'dropdown',
+ options: [
+ { label: 'No change', id: '' },
+ { label: 'Yes', id: 'true' },
+ { label: 'No', id: 'false' },
+ ],
+ value: () => '',
+ condition: { field: 'operation', value: 'update_settings' },
+ mode: 'advanced',
+ },
+ {
+ id: 'customReplyTo',
+ title: 'Custom Reply-To Email',
+ type: 'short-input',
+ placeholder: 'reply@example.com',
+ condition: { field: 'operation', value: 'update_settings' },
+ mode: 'advanced',
+ },
+ {
+ id: 'primaryLanguage',
+ title: 'Primary Language',
+ type: 'short-input',
+ placeholder: 'en-US',
+ condition: { field: 'operation', value: 'update_settings' },
+ mode: 'advanced',
+ },
],
tools: {
access: [
@@ -392,6 +737,10 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`,
domain: rest.domain,
query: rest.query,
maxResults: rest.maxResults ? Number(rest.maxResults) : undefined,
+ pageToken: rest.pageToken || undefined,
+ userKey: rest.userKey || undefined,
+ orderBy: rest.sortOrder ? 'email' : undefined,
+ sortOrder: rest.sortOrder || undefined,
}
case 'get_group':
case 'delete_group':
@@ -420,6 +769,11 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`,
groupKey: rest.groupKey,
maxResults: rest.maxResults ? Number(rest.maxResults) : undefined,
roles: rest.roles,
+ pageToken: rest.pageToken || undefined,
+ includeDerivedMembership:
+ rest.includeDerivedMembership === true || rest.includeDerivedMembership === 'true'
+ ? true
+ : undefined,
}
case 'get_member':
case 'remove_member':
@@ -474,6 +828,14 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`,
return {
oauthCredential,
groupEmail: rest.groupEmail,
+ ...Object.fromEntries(
+ UPDATE_SETTINGS_FIELDS.map((key) => [
+ key,
+ rest[key] === undefined || rest[key] === null || rest[key] === ''
+ ? undefined
+ : String(rest[key]),
+ ])
+ ),
}
default:
return { oauthCredential, ...rest }
@@ -500,6 +862,79 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`,
roles: { type: 'string', description: 'Filter by roles for list members' },
alias: { type: 'string', description: 'Alias email address' },
groupEmail: { type: 'string', description: 'Group email address for settings operations' },
+ userKey: { type: 'string', description: 'Only list groups this member belongs to' },
+ sortOrder: { type: 'string', description: 'Sort groups by email (ASCENDING or DESCENDING)' },
+ includeDerivedMembership: { type: 'boolean', description: 'Include indirect members' },
+ pageToken: { type: 'string', description: 'Pagination token' },
+ whoCanJoin: { type: 'string', description: 'Group setting whoCanJoin (update_settings)' },
+ whoCanViewMembership: {
+ type: 'string',
+ description: 'Group setting whoCanViewMembership (update_settings)',
+ },
+ whoCanViewGroup: {
+ type: 'string',
+ description: 'Group setting whoCanViewGroup (update_settings)',
+ },
+ whoCanPostMessage: {
+ type: 'string',
+ description: 'Group setting whoCanPostMessage (update_settings)',
+ },
+ allowExternalMembers: {
+ type: 'string',
+ description: 'Group setting allowExternalMembers (update_settings)',
+ },
+ whoCanDiscoverGroup: {
+ type: 'string',
+ description: 'Group setting whoCanDiscoverGroup (update_settings)',
+ },
+ whoCanContactOwner: {
+ type: 'string',
+ description: 'Group setting whoCanContactOwner (update_settings)',
+ },
+ whoCanLeaveGroup: {
+ type: 'string',
+ description: 'Group setting whoCanLeaveGroup (update_settings)',
+ },
+ whoCanModerateMembers: {
+ type: 'string',
+ description: 'Group setting whoCanModerateMembers (update_settings)',
+ },
+ whoCanModerateContent: {
+ type: 'string',
+ description: 'Group setting whoCanModerateContent (update_settings)',
+ },
+ messageModerationLevel: {
+ type: 'string',
+ description: 'Group setting messageModerationLevel (update_settings)',
+ },
+ spamModerationLevel: {
+ type: 'string',
+ description: 'Group setting spamModerationLevel (update_settings)',
+ },
+ replyTo: { type: 'string', description: 'Group setting replyTo (update_settings)' },
+ allowWebPosting: {
+ type: 'string',
+ description: 'Group setting allowWebPosting (update_settings)',
+ },
+ isArchived: { type: 'string', description: 'Group setting isArchived (update_settings)' },
+ archiveOnly: { type: 'string', description: 'Group setting archiveOnly (update_settings)' },
+ includeInGlobalAddressList: {
+ type: 'string',
+ description: 'Group setting includeInGlobalAddressList (update_settings)',
+ },
+ membersCanPostAsTheGroup: {
+ type: 'string',
+ description: 'Group setting membersCanPostAsTheGroup (update_settings)',
+ },
+ enableCollaborativeInbox: {
+ type: 'string',
+ description: 'Group setting enableCollaborativeInbox (update_settings)',
+ },
+ customReplyTo: { type: 'string', description: 'Group setting customReplyTo (update_settings)' },
+ primaryLanguage: {
+ type: 'string',
+ description: 'Group setting primaryLanguage (update_settings)',
+ },
},
outputs: {
groups: { type: 'json', description: 'Array of group objects (for list_groups)' },
@@ -510,7 +945,11 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`,
message: { type: 'string', description: 'Success message (for delete/remove operations)' },
nextPageToken: { type: 'string', description: 'Token for fetching next page of results' },
aliases: { type: 'json', description: 'Array of alias objects (for list_aliases)' },
- settings: { type: 'json', description: 'Group settings object (for get/update_settings)' },
+ settings: {
+ type: 'json',
+ description:
+ 'Group settings object for get/update_settings (email, name, description, whoCanJoin, whoCanViewMembership, whoCanViewGroup, whoCanPostMessage, allowExternalMembers, messageModerationLevel, replyTo, ..., defaultSender)',
+ },
deleted: { type: 'boolean', description: 'Deletion result (for remove_alias)' },
},
}
diff --git a/apps/sim/blocks/blocks/google_meet.ts b/apps/sim/blocks/blocks/google_meet.ts
index 1037e01eb50..d97e90b2342 100644
--- a/apps/sim/blocks/blocks/google_meet.ts
+++ b/apps/sim/blocks/blocks/google_meet.ts
@@ -196,13 +196,26 @@ export const GoogleMeetBlock: BlockConfig = {
accessType: { type: 'string', description: 'Access type' },
entryPointAccess: { type: 'string', description: 'Entry point access' },
activeConference: { type: 'string', description: 'Active conference record' },
+ moderation: { type: 'string', description: 'Host moderation (ON or OFF)' },
+ attendanceReportGenerationType: {
+ type: 'string',
+ description: 'Attendance report setting (GENERATE_REPORT or DO_NOT_GENERATE)',
+ },
+ artifactConfig: {
+ type: 'json',
+ description: 'Auto recording, transcription, and smart notes settings (ON or OFF each)',
+ },
ended: { type: 'boolean', description: 'Whether conference was ended' },
conferenceRecords: { type: 'json', description: 'List of conference records' },
startTime: { type: 'string', description: 'Conference start time' },
endTime: { type: 'string', description: 'Conference end time' },
expireTime: { type: 'string', description: 'Record expiration time' },
space: { type: 'string', description: 'Associated space name' },
- participants: { type: 'json', description: 'List of participants' },
+ participants: {
+ type: 'json',
+ description:
+ 'List of participants [{ name, earliestStartTime, latestEndTime, displayName, userType, user }]',
+ },
nextPageToken: { type: 'string', description: 'Next page token' },
totalSize: { type: 'number', description: 'Total participant count' },
},
diff --git a/apps/sim/blocks/blocks/google_sheets.ts b/apps/sim/blocks/blocks/google_sheets.ts
index 0fcd4a73133..5563dbae06e 100644
--- a/apps/sim/blocks/blocks/google_sheets.ts
+++ b/apps/sim/blocks/blocks/google_sheets.ts
@@ -551,6 +551,44 @@ Return ONLY the range string - no sheet name, no explanations, no quotes.`,
condition: { field: 'operation', value: 'read' },
mode: 'advanced',
},
+ {
+ id: 'valueRenderOption',
+ title: 'Value Render Option',
+ type: 'dropdown',
+ options: [
+ { label: 'Formatted (as displayed)', id: 'FORMATTED_VALUE' },
+ { label: 'Unformatted (raw values)', id: 'UNFORMATTED_VALUE' },
+ { label: 'Formulas', id: 'FORMULA' },
+ ],
+ condition: { field: 'operation', value: ['read', 'batch_get'] },
+ mode: 'advanced',
+ },
+ {
+ id: 'dateTimeRenderOption',
+ title: 'Date/Time Render Option',
+ type: 'dropdown',
+ options: [
+ { label: 'Serial Number', id: 'SERIAL_NUMBER' },
+ { label: 'Formatted String', id: 'FORMATTED_STRING' },
+ ],
+ condition: {
+ field: 'operation',
+ value: ['read', 'batch_get'],
+ and: { field: 'valueRenderOption', value: ['UNFORMATTED_VALUE', 'FORMULA'] },
+ },
+ mode: 'advanced',
+ },
+ {
+ id: 'majorDimension',
+ title: 'Major Dimension',
+ type: 'dropdown',
+ options: [
+ { label: 'Rows', id: 'ROWS' },
+ { label: 'Columns', id: 'COLUMNS' },
+ ],
+ condition: { field: 'operation', value: 'batch_get' },
+ mode: 'advanced',
+ },
{
id: 'values',
title: 'Values',
@@ -586,6 +624,23 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
],
condition: { field: 'operation', value: ['write', 'update', 'batch_update'] },
},
+ {
+ id: 'appendValueInputOption',
+ title: 'Value Input Option',
+ type: 'dropdown',
+ options: [
+ { label: 'User Entered (Parse formulas)', id: 'USER_ENTERED' },
+ { label: "Raw (Don't parse formulas)", id: 'RAW' },
+ ],
+ condition: { field: 'operation', value: 'append' },
+ },
+ {
+ id: 'includeValuesInResponse',
+ title: 'Return Updated Values',
+ type: 'switch',
+ condition: { field: 'operation', value: ['write', 'update', 'append', 'batch_update'] },
+ mode: 'advanced',
+ },
{
id: 'values',
title: 'Values',
@@ -891,6 +946,8 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
filterColumn,
filterValue,
filterMatchType,
+ majorDimension,
+ appendValueInputOption,
...rest
} = params
@@ -926,6 +983,11 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
spreadsheetId: effectiveSpreadsheetId,
ranges: parsedRanges,
oauthCredential,
+ ...(majorDimension ? { majorDimension } : {}),
+ ...(rest.valueRenderOption ? { valueRenderOption: rest.valueRenderOption } : {}),
+ ...(rest.dateTimeRenderOption
+ ? { dateTimeRenderOption: rest.dateTimeRenderOption }
+ : {}),
}
}
@@ -1016,6 +1078,10 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
? { filterValue: filterValue as string }
: {}),
...(filterMatchType ? { filterMatchType: filterMatchType as string } : {}),
+ ...(operation === 'append'
+ ? { valueInputOption: appendValueInputOption || undefined }
+ : {}),
+ majorDimension: undefined,
}
},
},
@@ -1028,7 +1094,24 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
cellRange: { type: 'string', description: 'Cell range (e.g., A1:D10)' },
values: { type: 'string', description: 'Cell values data' },
valueInputOption: { type: 'string', description: 'Value input option' },
+ appendValueInputOption: { type: 'string', description: 'Value input option for Append' },
insertDataOption: { type: 'string', description: 'Data insertion option' },
+ includeValuesInResponse: {
+ type: 'boolean',
+ description: 'Return the values of the updated cells in updatedData',
+ },
+ valueRenderOption: {
+ type: 'string',
+ description: 'How read values are rendered: FORMATTED_VALUE, UNFORMATTED_VALUE, or FORMULA',
+ },
+ dateTimeRenderOption: {
+ type: 'string',
+ description: 'How read dates are rendered: SERIAL_NUMBER or FORMATTED_STRING',
+ },
+ majorDimension: {
+ type: 'string',
+ description: 'Major dimension of batch read values: ROWS or COLUMNS',
+ },
title: { type: 'string', description: 'Title for new spreadsheet' },
sheetTitles: { type: 'string', description: 'Comma-separated sheet names for new spreadsheet' },
ranges: { type: 'string', description: 'JSON array of ranges for batch operations' },
@@ -1083,6 +1166,17 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
'Filter summary (present only when a filter was requested): applied, column, matchType, columnFound, matchedRows, totalRows',
condition: { field: 'operation', value: 'read' },
},
+ majorDimension: {
+ type: 'string',
+ description: 'Major dimension of the returned values (ROWS or COLUMNS)',
+ condition: { field: 'operation', value: 'read' },
+ },
+ updatedData: {
+ type: 'json',
+ description:
+ 'Values of the updated cells (range, majorDimension, values), present only when Return Updated Values is on',
+ condition: { field: 'operation', value: ['write', 'update', 'append'] },
+ },
updatedRange: {
type: 'string',
description: 'Updated range',
diff --git a/apps/sim/blocks/blocks/google_slides.ts b/apps/sim/blocks/blocks/google_slides.ts
index 6df71191c55..f58dcaf077b 100644
--- a/apps/sim/blocks/blocks/google_slides.ts
+++ b/apps/sim/blocks/blocks/google_slides.ts
@@ -3466,6 +3466,34 @@ Return ONLY the text content - no explanations, no markdown formatting markers,
pageType: { type: 'string', description: 'Page type (SLIDE, MASTER, etc.)' },
pageElements: { type: 'json', description: 'Page elements array' },
slideProperties: { type: 'json', description: 'Slide-specific properties' },
+ slideContents: {
+ type: 'json',
+ description:
+ 'Plain-text view of each slide from Read: [{objectId, index, text, speakerNotes, isSkipped}]',
+ },
+ revisionId: {
+ type: 'string',
+ description: 'Presentation revision ID from Get Page (Read returns it in metadata)',
+ },
+ pageProperties: {
+ type: 'json',
+ description: 'Page properties from Get Page (pageBackgroundFill, colorScheme)',
+ },
+ layoutProperties: {
+ type: 'json',
+ description:
+ 'Layout properties from Get Page, for layout pages (masterObjectId, name, displayName)',
+ },
+ notesProperties: {
+ type: 'json',
+ description: 'Notes properties from Get Page, for notes pages (speakerNotesObjectId)',
+ },
+ masterProperties: {
+ type: 'json',
+ description: 'Master properties from Get Page, for master pages (displayName)',
+ },
+ pageText: { type: 'string', description: 'Text on the page from Get Page' },
+ speakerNotes: { type: 'string', description: 'Speaker notes of the slide from Get Page' },
deleted: { type: 'boolean', description: 'Whether object was deleted' },
duplicatedObjectId: { type: 'string', description: 'Object ID of the duplicate' },
moved: { type: 'boolean', description: 'Whether slides were moved' },
diff --git a/apps/sim/blocks/blocks/google_tasks.test.ts b/apps/sim/blocks/blocks/google_tasks.test.ts
new file mode 100644
index 00000000000..1f073772be0
--- /dev/null
+++ b/apps/sim/blocks/blocks/google_tasks.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from 'vitest'
+import { GoogleTasksBlock } from '@/blocks/blocks/google_tasks'
+
+/**
+ * The executor builds tool params as `{ ...inputs, ...transformedParams }`, so an empty dropdown
+ * value the serializer fills in reaches the tool unless the mapper assigns that key explicitly.
+ */
+describe('GoogleTasksBlock list filters', () => {
+ const mapParams = GoogleTasksBlock.tools.config?.params as (
+ params: Record
+ ) => Record
+
+ it('maps unset tri-state filters to undefined', () => {
+ const mapped = mapParams({
+ operation: 'list',
+ oauthCredential: 'cred-1',
+ showHidden: '',
+ showDeleted: '',
+ showAssigned: '',
+ })
+ for (const key of ['showHidden', 'showDeleted', 'showAssigned']) {
+ expect(mapped).toHaveProperty(key, undefined)
+ }
+ })
+
+ it('converts chosen filters to booleans', () => {
+ const mapped = mapParams({ operation: 'list', oauthCredential: 'cred-1', showHidden: 'true' })
+ expect(mapped.showHidden).toBe(true)
+ })
+})
diff --git a/apps/sim/blocks/blocks/google_tasks.ts b/apps/sim/blocks/blocks/google_tasks.ts
index 3d1d6ba3ca8..39899bd5200 100644
--- a/apps/sim/blocks/blocks/google_tasks.ts
+++ b/apps/sim/blocks/blocks/google_tasks.ts
@@ -2,7 +2,7 @@ import { GoogleTasksIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig, BlockMeta } from '@/blocks/types'
import { AuthMode, IntegrationType } from '@/blocks/types'
-import { SERVICE_ACCOUNT_SUBBLOCKS } from '@/blocks/utils'
+import { parseOptionalBooleanInput, SERVICE_ACCOUNT_SUBBLOCKS } from '@/blocks/utils'
/**
* Canonical basic/advanced pair for the task list, shared by the card
@@ -159,6 +159,23 @@ Return ONLY the timestamp - no explanations, no extra text.`,
],
},
+ {
+ id: 'parent',
+ title: 'Parent Task ID',
+ type: 'short-input',
+ placeholder: 'Create as a subtask of this task',
+ condition: { field: 'operation', value: 'create' },
+ mode: 'advanced',
+ },
+ {
+ id: 'previous',
+ title: 'Previous Sibling Task ID',
+ type: 'short-input',
+ placeholder: 'Place after this task (first if empty)',
+ condition: { field: 'operation', value: 'create' },
+ mode: 'advanced',
+ },
+
// Get/Update/Delete Task Fields - Task ID
{
id: 'taskId',
@@ -231,6 +248,133 @@ Return ONLY the timestamp - no explanations, no extra text.`,
{ label: 'No', id: 'false' },
],
},
+ {
+ id: 'showHidden',
+ title: 'Show Hidden',
+ type: 'dropdown',
+ condition: { field: 'operation', value: 'list' },
+ options: [
+ { label: 'Default', id: '' },
+ { label: 'Yes', id: 'true' },
+ { label: 'No', id: 'false' },
+ ],
+ value: () => '',
+ mode: 'advanced',
+ },
+ {
+ id: 'showDeleted',
+ title: 'Show Deleted',
+ type: 'dropdown',
+ condition: { field: 'operation', value: 'list' },
+ options: [
+ { label: 'Default', id: '' },
+ { label: 'Yes', id: 'true' },
+ { label: 'No', id: 'false' },
+ ],
+ value: () => '',
+ mode: 'advanced',
+ },
+ {
+ id: 'showAssigned',
+ title: 'Show Assigned',
+ type: 'dropdown',
+ condition: { field: 'operation', value: 'list' },
+ options: [
+ { label: 'Default', id: '' },
+ { label: 'Yes', id: 'true' },
+ { label: 'No', id: 'false' },
+ ],
+ value: () => '',
+ mode: 'advanced',
+ },
+ {
+ id: 'dueMin',
+ title: 'Due After',
+ type: 'short-input',
+ placeholder: '2025-06-01T00:00:00.000Z',
+ condition: { field: 'operation', value: 'list' },
+ mode: 'advanced',
+ wandConfig: {
+ enabled: true,
+ prompt: `Generate an RFC 3339 timestamp in UTC based on the user's description.
+The timestamp should be in the format: YYYY-MM-DDTHH:MM:SS.000Z (UTC timezone).
+
+Return ONLY the timestamp - no explanations, no extra text.`,
+ generationType: 'timestamp',
+ },
+ },
+ {
+ id: 'dueMax',
+ title: 'Due Before',
+ type: 'short-input',
+ placeholder: '2025-06-30T00:00:00.000Z',
+ condition: { field: 'operation', value: 'list' },
+ mode: 'advanced',
+ wandConfig: {
+ enabled: true,
+ prompt: `Generate an RFC 3339 timestamp in UTC based on the user's description.
+The timestamp should be in the format: YYYY-MM-DDTHH:MM:SS.000Z (UTC timezone).
+
+Return ONLY the timestamp - no explanations, no extra text.`,
+ generationType: 'timestamp',
+ },
+ },
+ {
+ id: 'completedMin',
+ title: 'Completed After',
+ type: 'short-input',
+ placeholder: '2025-06-01T00:00:00.000Z',
+ condition: { field: 'operation', value: 'list' },
+ mode: 'advanced',
+ wandConfig: {
+ enabled: true,
+ prompt: `Generate an RFC 3339 timestamp in UTC based on the user's description.
+The timestamp should be in the format: YYYY-MM-DDTHH:MM:SS.000Z (UTC timezone).
+
+Return ONLY the timestamp - no explanations, no extra text.`,
+ generationType: 'timestamp',
+ },
+ },
+ {
+ id: 'completedMax',
+ title: 'Completed Before',
+ type: 'short-input',
+ placeholder: '2025-06-30T00:00:00.000Z',
+ condition: { field: 'operation', value: 'list' },
+ mode: 'advanced',
+ wandConfig: {
+ enabled: true,
+ prompt: `Generate an RFC 3339 timestamp in UTC based on the user's description.
+The timestamp should be in the format: YYYY-MM-DDTHH:MM:SS.000Z (UTC timezone).
+
+Return ONLY the timestamp - no explanations, no extra text.`,
+ generationType: 'timestamp',
+ },
+ },
+ {
+ id: 'updatedMin',
+ title: 'Updated After',
+ type: 'short-input',
+ placeholder: '2025-06-01T00:00:00.000Z',
+ condition: { field: 'operation', value: 'list' },
+ mode: 'advanced',
+ wandConfig: {
+ enabled: true,
+ prompt: `Generate an RFC 3339 timestamp in UTC based on the user's description.
+The timestamp should be in the format: YYYY-MM-DDTHH:MM:SS.000Z (UTC timezone).
+
+Return ONLY the timestamp - no explanations, no extra text.`,
+ generationType: 'timestamp',
+ },
+ },
+ {
+ id: 'pageToken',
+ title: 'Page Token',
+ type: 'short-input',
+ placeholder: 'Token from a previous response',
+ condition: { field: 'operation', value: ['list', 'list_task_lists'] },
+ mode: 'advanced',
+ },
],
tools: {
@@ -276,6 +420,10 @@ Return ONLY the timestamp - no explanations, no extra text.`,
processedParams.showCompleted = showCompleted === 'true'
}
+ for (const key of ['showHidden', 'showDeleted', 'showAssigned'] as const) {
+ processedParams[key] = parseOptionalBooleanInput(processedParams[key])
+ }
+
return {
oauthCredential,
...processedParams,
@@ -295,6 +443,20 @@ Return ONLY the timestamp - no explanations, no extra text.`,
taskId: { type: 'string', description: 'Task identifier' },
maxResults: { type: 'string', description: 'Maximum number of results' },
showCompleted: { type: 'string', description: 'Whether to show completed tasks' },
+ parent: { type: 'string', description: 'Parent task ID for a subtask' },
+ previous: { type: 'string', description: 'Previous sibling task ID' },
+ showHidden: { type: 'string', description: 'Whether to show hidden tasks' },
+ showDeleted: { type: 'string', description: 'Whether to show deleted tasks' },
+ showAssigned: {
+ type: 'string',
+ description: 'Whether to show tasks assigned from Docs or Chat',
+ },
+ dueMin: { type: 'string', description: 'Lower bound for due date' },
+ dueMax: { type: 'string', description: 'Upper bound for due date' },
+ completedMin: { type: 'string', description: 'Lower bound for completion date' },
+ completedMax: { type: 'string', description: 'Upper bound for completion date' },
+ updatedMin: { type: 'string', description: 'Lower bound for last modification time' },
+ pageToken: { type: 'string', description: 'Pagination token' },
},
outputs: {
@@ -310,7 +472,18 @@ Return ONLY the timestamp - no explanations, no extra text.`,
position: { type: 'string', description: 'Position among sibling tasks' },
completed: { type: 'string', description: 'Completion date' },
deleted: { type: 'boolean', description: 'Whether the task is deleted' },
- tasks: { type: 'json', description: 'Array of tasks (list operation)' },
+ hidden: { type: 'boolean', description: 'Whether the task is hidden' },
+ links: { type: 'json', description: 'Links attached to the task [{type, description, link}]' },
+ assignmentInfo: {
+ type: 'json',
+ description:
+ 'Source of a task assigned from Docs or Chat (linkToTask, surfaceType, driveResourceInfo, spaceInfo)',
+ },
+ tasks: {
+ type: 'json',
+ description:
+ 'Array of tasks (list operation): [{id, title, notes, status, due, completed, updated, selfLink, webViewLink, parent, position, hidden, deleted, links, assignmentInfo}]',
+ },
taskLists: { type: 'json', description: 'Array of task lists (list_task_lists operation)' },
taskId: { type: 'string', description: 'Deleted task ID (delete operation)' },
nextPageToken: { type: 'string', description: 'Token for next page of results' },
diff --git a/apps/sim/blocks/blocks/google_vault.ts b/apps/sim/blocks/blocks/google_vault.ts
index c5eb6608df2..ea4c05148fe 100644
--- a/apps/sim/blocks/blocks/google_vault.ts
+++ b/apps/sim/blocks/blocks/google_vault.ts
@@ -264,6 +264,32 @@ export const GoogleVaultBlock: BlockConfig = {
placeholder: 'Enter Matter ID (optional to fetch a specific matter)',
condition: { field: 'operation', value: 'list_matters' },
},
+ {
+ id: 'matterView',
+ title: 'Detail Level',
+ type: 'dropdown',
+ options: [
+ { label: 'Basic', id: 'BASIC' },
+ { label: 'Full (include collaborators)', id: 'FULL' },
+ ],
+ value: () => 'BASIC',
+ condition: { field: 'operation', value: 'list_matters' },
+ mode: 'advanced',
+ },
+ {
+ id: 'matterState',
+ title: 'Matter State',
+ type: 'dropdown',
+ options: [
+ { label: 'All', id: '' },
+ { label: 'Open', id: 'OPEN' },
+ { label: 'Closed', id: 'CLOSED' },
+ { label: 'Deleted', id: 'DELETED' },
+ ],
+ value: () => '',
+ condition: { field: 'operation', value: 'list_matters' },
+ mode: 'advanced',
+ },
// Download Export File inputs
{
id: 'bucketName',
@@ -561,6 +587,46 @@ Return ONLY the search query - no explanations, no quotes, no extra text.`,
placeholder: 'Describe what content to search for...',
},
},
+ {
+ id: 'exportIncludeSharedDrives',
+ title: 'Include Shared Drives',
+ type: 'switch',
+ condition: {
+ field: 'operation',
+ value: ['create_matters_export', 'create_saved_query'],
+ and: { field: 'corpus', value: 'DRIVE' },
+ },
+ },
+ {
+ id: 'exportFormat',
+ title: 'Export Format',
+ type: 'dropdown',
+ options: [
+ { label: 'Default', id: '' },
+ { label: 'MBOX', id: 'MBOX' },
+ { label: 'PST', id: 'PST' },
+ ],
+ value: () => '',
+ condition: {
+ field: 'operation',
+ value: 'create_matters_export',
+ and: { field: 'corpus', value: 'DRIVE', not: true },
+ },
+ mode: 'advanced',
+ },
+ {
+ id: 'dataScope',
+ title: 'Data Scope',
+ type: 'dropdown',
+ options: [
+ { label: 'All data', id: 'ALL_DATA' },
+ { label: 'Held data only', id: 'HELD_DATA' },
+ { label: 'Unprocessed data (Mail and Groups)', id: 'UNPROCESSED_DATA' },
+ ],
+ value: () => 'ALL_DATA',
+ condition: { field: 'operation', value: ['create_matters_export', 'create_saved_query'] },
+ mode: 'advanced',
+ },
// Drive-specific option for holds
{
id: 'includeSharedDrives',
@@ -625,6 +691,7 @@ Return ONLY the search query - no explanations, no quotes, no extra text.`,
field: 'operation',
value: ['list_matters_export', 'list_matters_holds', 'list_matters', 'list_saved_queries'],
},
+ mode: 'advanced',
},
{
id: 'pageToken',
@@ -635,6 +702,7 @@ Return ONLY the search query - no explanations, no quotes, no extra text.`,
field: 'operation',
value: ['list_matters_export', 'list_matters_holds', 'list_matters', 'list_saved_queries'],
},
+ mode: 'advanced',
},
{
@@ -854,8 +922,15 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`,
listExportId,
listHoldId,
listSavedQueryId,
+ matterView,
+ matterState,
+ exportIncludeSharedDrives,
+ exportFormat,
+ dataScope,
...rest
} = params
+ const isQueryOperation =
+ params.operation === 'create_matters_export' || params.operation === 'create_saved_query'
return {
...rest,
oauthCredential,
@@ -879,6 +954,14 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`,
...(listExportId && { exportId: listExportId }),
...(listHoldId && { holdId: listHoldId }),
...(listSavedQueryId && { savedQueryId: listSavedQueryId }),
+ ...(params.operation === 'list_matters' && matterView && { view: matterView }),
+ ...(params.operation === 'list_matters' && matterState && { state: matterState }),
+ ...(isQueryOperation && {
+ includeSharedDrives:
+ exportIncludeSharedDrives === true || exportIncludeSharedDrives === 'true',
+ ...(dataScope && { dataScope }),
+ }),
+ ...(params.operation === 'create_matters_export' && exportFormat && { exportFormat }),
}
},
},
@@ -978,19 +1061,43 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`,
type: 'string',
description: 'Specific saved query ID to fetch (list_saved_queries)',
},
+ matterView: {
+ type: 'string',
+ description: 'Matter detail level: BASIC or FULL (list_matters)',
+ },
+ matterState: {
+ type: 'string',
+ description: 'Only list matters in this state: OPEN, CLOSED, or DELETED (list_matters)',
+ },
+ exportIncludeSharedDrives: {
+ type: 'boolean',
+ description: 'Include shared drive files (DRIVE create_matters_export, create_saved_query)',
+ },
+ exportFormat: {
+ type: 'string',
+ description: 'Export file format: MBOX or PST (create_matters_export, non-DRIVE corpus)',
+ },
+ dataScope: {
+ type: 'string',
+ description:
+ 'Data to search: ALL_DATA, HELD_DATA, or UNPROCESSED_DATA (create_matters_export, create_saved_query)',
+ },
},
outputs: {
matters: {
type: 'json',
- description: 'Array of matter objects (for list_matters without matterId)',
+ description:
+ 'Array of matter objects (matterId, name, description, state; matterPermissions with Full detail) (for list_matters without matterId)',
},
exports: {
type: 'json',
- description: 'Array of export objects (for list_matters_export without exportId)',
+ description:
+ 'Array of export objects (id, name, status, createTime, stats, cloudStorageSink.files with bucketName/objectName/size) (for list_matters_export without exportId)',
},
holds: {
type: 'json',
- description: 'Array of hold objects (for list_matters_holds without holdId)',
+ description:
+ 'Array of hold objects (holdId, name, corpus, updateTime, query, accounts or orgUnit) (for list_matters_holds without holdId)',
},
matter: {
type: 'json',
diff --git a/apps/sim/blocks/blocks/iam.ts b/apps/sim/blocks/blocks/iam.ts
index cb5015562d5..ce502858d6a 100644
--- a/apps/sim/blocks/blocks/iam.ts
+++ b/apps/sim/blocks/blocks/iam.ts
@@ -1,8 +1,42 @@
+import { isRecordLike } from '@sim/utils/object'
import { IAMIcon } from '@/components/icons'
import type { BlockConfig, BlockMeta } from '@/blocks/types'
import { AuthMode, IntegrationType } from '@/blocks/types'
import { withSavedCredential } from '@/blocks/utils'
+/**
+ * Converts the Tags table, a `[{ key, value }]` list (or its JSON string), a plain
+ * object, or a JSON object string into the JSON object string the tools accept.
+ */
+function serializeTags(tags: unknown): string | undefined {
+ if (typeof tags === 'string') {
+ const trimmed = tags.trim()
+ if (!trimmed) return undefined
+ if (!trimmed.startsWith('[')) return trimmed
+ let parsed: unknown
+ try {
+ parsed = JSON.parse(trimmed)
+ } catch {
+ return trimmed
+ }
+ const serialized = serializeTags(parsed)
+ if (serialized === undefined && Array.isArray(parsed) && parsed.length > 0) {
+ throw new Error('Tags must be a list of {"key", "value"} entries or a JSON object')
+ }
+ return serialized
+ }
+ if (isRecordLike(tags)) return Object.keys(tags).length > 0 ? JSON.stringify(tags) : undefined
+ if (!Array.isArray(tags)) return undefined
+ const obj: Record = {}
+ for (const row of tags) {
+ const entry = isRecordLike(row) && isRecordLike(row.cells) ? row.cells : row
+ const key = isRecordLike(entry) ? entry.key : undefined
+ const value = isRecordLike(entry) ? entry.value : undefined
+ if (typeof key === 'string' && key && value !== undefined) obj[key] = String(value ?? '')
+ }
+ return Object.keys(obj).length > 0 ? JSON.stringify(obj) : undefined
+}
+
/**
* Raised when `contextEntries` is not a JSON array of context entries.
*
@@ -347,6 +381,24 @@ export const IAMBlock: BlockConfig = {
required: false,
mode: 'advanced',
},
+ {
+ id: 'permissionsBoundary',
+ title: 'Permissions Boundary ARN',
+ type: 'short-input',
+ placeholder: 'arn:aws:iam::123456789012:policy/MyBoundary',
+ condition: { field: 'operation', value: ['create_user', 'create_role'] },
+ required: false,
+ mode: 'advanced',
+ },
+ {
+ id: 'tags',
+ title: 'Tags',
+ type: 'table',
+ columns: ['key', 'value'],
+ condition: { field: 'operation', value: ['create_user', 'create_role'] },
+ required: false,
+ mode: 'advanced',
+ },
{
id: 'scope',
title: 'Policy Scope',
@@ -374,6 +426,20 @@ export const IAMBlock: BlockConfig = {
required: false,
mode: 'advanced',
},
+ {
+ id: 'policyUsageFilter',
+ title: 'Policy Usage',
+ type: 'dropdown',
+ options: [
+ { label: 'All', id: 'all' },
+ { label: 'Permissions Policies', id: 'PermissionsPolicy' },
+ { label: 'Permissions Boundaries', id: 'PermissionsBoundary' },
+ ],
+ value: () => 'all',
+ condition: { field: 'operation', value: 'list_policies' },
+ required: false,
+ mode: 'advanced',
+ },
{
id: 'policySourceArn',
title: 'Principal ARN',
@@ -602,15 +668,18 @@ export const IAMBlock: BlockConfig = {
case 'delete_user':
result.userName = rest.userName
break
- case 'create_user':
+ case 'create_user': {
result.userName = rest.userName
if (rest.path) result.path = rest.path
+ result.permissionsBoundary = rest.permissionsBoundary || undefined
+ result.tags = serializeTags(rest.tags)
break
+ }
case 'get_role':
case 'delete_role':
result.roleName = rest.roleName
break
- case 'create_role':
+ case 'create_role': {
result.roleName = rest.roleName
result.assumeRolePolicyDocument = rest.assumeRolePolicyDocument
if (rest.description) result.description = rest.description
@@ -619,7 +688,10 @@ export const IAMBlock: BlockConfig = {
const parsed = Number.parseInt(String(maxSessionDuration), 10)
if (!Number.isNaN(parsed)) result.maxSessionDuration = parsed
}
+ result.permissionsBoundary = rest.permissionsBoundary || undefined
+ result.tags = serializeTags(rest.tags)
break
+ }
case 'attach_user_policy':
case 'detach_user_policy':
result.userName = rest.userName
@@ -633,6 +705,11 @@ export const IAMBlock: BlockConfig = {
case 'list_policies':
if (rest.scope) result.scope = rest.scope
if (onlyAttached === 'true' || onlyAttached === true) result.onlyAttached = true
+ result.policyUsageFilter =
+ rest.policyUsageFilter === 'PermissionsPolicy' ||
+ rest.policyUsageFilter === 'PermissionsBoundary'
+ ? rest.policyUsageFilter
+ : undefined
if (rest.pathPrefix) result.pathPrefix = rest.pathPrefix
if (maxItems) {
const parsed = Number.parseInt(String(maxItems), 10)
@@ -734,6 +811,15 @@ export const IAMBlock: BlockConfig = {
maxSessionDuration: { type: 'number', description: 'Max session duration in seconds' },
scope: { type: 'string', description: 'Policy scope filter (All, AWS, Local)' },
onlyAttached: { type: 'string', description: 'Only return attached policies' },
+ policyUsageFilter: {
+ type: 'string',
+ description: 'Policy usage filter (PermissionsPolicy, PermissionsBoundary)',
+ },
+ permissionsBoundary: {
+ type: 'string',
+ description: 'Managed policy ARN to set as the permissions boundary on a new user or role',
+ },
+ tags: { type: 'json', description: 'Tag key/value pairs for a new user or role' },
pathPrefix: { type: 'string', description: 'Path prefix filter' },
maxItems: { type: 'number', description: 'Maximum number of items to return' },
marker: { type: 'string', description: 'Pagination marker' },
@@ -766,7 +852,7 @@ export const IAMBlock: BlockConfig = {
policies: {
type: 'json',
description:
- 'List of IAM policies (policyName, policyId, arn, path, attachmentCount, isAttachable, createDate, updateDate)',
+ 'List of IAM policies (policyName, policyId, arn, path, attachmentCount, isAttachable, createDate, updateDate, defaultVersionId, permissionsBoundaryUsageCount)',
},
groups: {
type: 'json',
diff --git a/apps/sim/blocks/blocks/identity_center.ts b/apps/sim/blocks/blocks/identity_center.ts
index bf9244c3b2b..a9b885903ac 100644
--- a/apps/sim/blocks/blocks/identity_center.ts
+++ b/apps/sim/blocks/blocks/identity_center.ts
@@ -296,6 +296,15 @@ export const IdentityCenterBlock: BlockConfig = {
],
},
},
+ {
+ id: 'filterAccountId',
+ title: 'Filter by AWS Account ID',
+ type: 'short-input',
+ placeholder: '123456789012',
+ condition: { field: 'operation', value: 'list_account_assignments' },
+ required: false,
+ mode: 'advanced',
+ },
{
id: 'requestId',
title: 'Request ID',
@@ -496,6 +505,7 @@ export const IdentityCenterBlock: BlockConfig = {
result.instanceArn = rest.instanceArn
result.principalId = rest.principalId
result.principalType = rest.principalType
+ result.accountId = rest.filterAccountId || undefined
if (maxResults) {
const parsed = Number.parseInt(String(maxResults), 10)
if (!Number.isNaN(parsed)) result.maxResults = parsed
@@ -534,6 +544,10 @@ export const IdentityCenterBlock: BlockConfig = {
userId: { type: 'string', description: 'Identity Store user ID' },
groupId: { type: 'string', description: 'Identity Store group ID' },
requestId: { type: 'string', description: 'Assignment creation/deletion request ID' },
+ filterAccountId: {
+ type: 'string',
+ description: 'Only list assignments for this AWS account ID (list_account_assignments)',
+ },
maxResults: {
type: 'number',
description: 'Maximum number of results to return (1-100; List Accounts allows at most 20)',
@@ -570,6 +584,8 @@ export const IdentityCenterBlock: BlockConfig = {
email: { type: 'string', description: 'Email address of the user' },
userStatus: { type: 'string', description: 'User account status (ENABLED or DISABLED)' },
title: { type: 'string', description: 'Job title of the user' },
+ givenName: { type: 'string', description: 'Given (first) name of the user' },
+ familyName: { type: 'string', description: 'Family (last) name of the user' },
externalIds: {
type: 'json',
description: 'External identity provider IDs on the user or group (issuer, id)',
diff --git a/apps/sim/blocks/blocks/intune.ts b/apps/sim/blocks/blocks/intune.ts
index c8bdca0cd48..fb1b0614601 100644
--- a/apps/sim/blocks/blocks/intune.ts
+++ b/apps/sim/blocks/blocks/intune.ts
@@ -246,13 +246,13 @@ export const IntuneBlock: BlockConfig = {
devices: {
type: 'json',
description:
- 'Managed devices (id, deviceName, operatingSystem, osVersion, complianceState, userPrincipalName, lastSyncDateTime, isEncrypted, serialNumber, manufacturer, model)',
+ 'Managed devices (id, deviceName, operatingSystem, osVersion, complianceState, userPrincipalName, lastSyncDateTime, isEncrypted, serialNumber, manufacturer, model, managementAgent, deviceEnrollmentType, jailBroken, isSupervised, complianceGracePeriodExpirationDateTime, exchangeAccessState, imei, phoneNumber, wiFiMacAddress, deviceActionResults)',
condition: { field: 'operation', value: ['intune_list_devices', 'intune_list_app_devices'] },
},
device: {
type: 'json',
description:
- 'Managed device (id, deviceName, operatingSystem, osVersion, complianceState, managementState, userPrincipalName, lastSyncDateTime, isEncrypted, serialNumber, storage capacity)',
+ 'Managed device (id, deviceName, operatingSystem, osVersion, complianceState, userPrincipalName, lastSyncDateTime, isEncrypted, serialNumber, storage capacity, managementAgent, deviceEnrollmentType, deviceRegistrationState, jailBroken, isSupervised, complianceGracePeriodExpirationDateTime, partnerReportedThreatState, exchangeAccessState, imei, phoneNumber, wiFiMacAddress, deviceActionResults with actionName and actionState of remote actions)',
condition: { field: 'operation', value: ['intune_get_device'] },
},
apps: {
@@ -270,25 +270,25 @@ export const IntuneBlock: BlockConfig = {
policies: {
type: 'json',
description:
- 'Compliance policy metadata (id, displayName, description, createdDateTime, lastModifiedDateTime, version)',
+ 'Compliance policy metadata (id, displayName, description, createdDateTime, lastModifiedDateTime, version, odataType naming the platform policy type)',
condition: { field: 'operation', value: ['intune_list_compliance_policies'] },
},
policy: {
type: 'json',
description:
- 'Common compliance policy metadata (id, displayName, description, createdDateTime, lastModifiedDateTime, version); excludes platform-specific rule settings',
+ 'Common compliance policy metadata (id, displayName, description, createdDateTime, lastModifiedDateTime, version, odataType naming the platform policy type); excludes platform-specific rule settings',
condition: { field: 'operation', value: ['intune_get_compliance_policy'] },
},
configurations: {
type: 'json',
description:
- 'Device configuration metadata (id, displayName, description, createdDateTime, lastModifiedDateTime, version)',
+ 'Device configuration metadata (id, displayName, description, createdDateTime, lastModifiedDateTime, version, odataType naming the platform configuration type)',
condition: { field: 'operation', value: ['intune_list_device_configurations'] },
},
configuration: {
type: 'json',
description:
- 'Common device configuration metadata (id, displayName, description, createdDateTime, lastModifiedDateTime, version); excludes platform-specific settings',
+ 'Common device configuration metadata (id, displayName, description, createdDateTime, lastModifiedDateTime, version, odataType naming the platform configuration type); excludes platform-specific settings',
condition: { field: 'operation', value: ['intune_get_device_configuration'] },
},
deviceStatuses: {
diff --git a/apps/sim/blocks/blocks/jira.ts b/apps/sim/blocks/blocks/jira.ts
index 081576bcb62..454636cd4e4 100644
--- a/apps/sim/blocks/blocks/jira.ts
+++ b/apps/sim/blocks/blocks/jira.ts
@@ -537,6 +537,37 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
condition: { field: 'operation', value: ['write', 'update'] },
mode: 'advanced',
},
+ {
+ id: 'customFields',
+ title: 'Additional Fields',
+ type: 'code',
+ language: 'json',
+ placeholder: '{"customfield_10016": 5, "customfield_10050": {"value": "Option A"}}',
+ condition: { field: 'operation', value: ['write', 'update'] },
+ mode: 'advanced',
+ wandConfig: {
+ enabled: true,
+ prompt: `Generate a JSON object mapping Jira field IDs to values for an issue create or edit request, based on the user's description.
+Use Jira REST API v3 value shapes:
+- Number or text custom fields: "customfield_10016": 5
+- Select lists: "customfield_10050": {"value": "Option A"}
+- Multi-select: "customfield_10051": [{"value": "A"}, {"value": "B"}]
+- User pickers: "customfield_10060": {"accountId": "5b10a2844c20165700ede21g"}
+- Sprint: "customfield_10020": 42
+
+Return ONLY the JSON object - no explanations or markdown formatting.`,
+ placeholder: 'Describe the fields to set (e.g., "story points 5, team Platform")...',
+ generationType: 'json-object',
+ },
+ },
+ {
+ id: 'updateParentIssue',
+ title: 'New Parent Issue Key',
+ type: 'short-input',
+ placeholder: 'Parent issue key or epic key (e.g., PROJ-123)',
+ condition: { field: 'operation', value: 'update' },
+ mode: 'advanced',
+ },
{
id: 'notifyUsers',
title: 'Notify Users',
@@ -603,6 +634,24 @@ Return ONLY the comment text - no explanations.`,
condition: { field: 'operation', value: 'transition' },
mode: 'advanced',
},
+ {
+ id: 'transitionFields',
+ title: 'Transition Fields',
+ type: 'code',
+ language: 'json',
+ placeholder: '{"assignee": {"accountId": "5b10a2844c20165700ede21g"}}',
+ condition: { field: 'operation', value: 'transition' },
+ mode: 'advanced',
+ wandConfig: {
+ enabled: true,
+ prompt: `Generate a JSON object of Jira fields to set on a transition screen, keyed by field ID, based on the user's description.
+Use Jira REST API v3 value shapes (e.g., "assignee": {"accountId": "..."}, "customfield_10050": {"value": "Option A"}).
+
+Return ONLY the JSON object - no explanations or markdown formatting.`,
+ placeholder: 'Describe the fields to set (e.g., "assign to the QA lead")...',
+ generationType: 'json-object',
+ },
+ },
{
id: 'jql',
title: 'JQL Query',
@@ -659,6 +708,21 @@ Return ONLY the JQL query - no explanations or markdown formatting.`,
condition: { field: 'operation', value: 'search' },
mode: 'advanced',
},
+ {
+ id: 'expand',
+ title: 'Expand',
+ type: 'short-input',
+ placeholder: 'Comma-separated expansions (e.g., names,renderedFields,changelog)',
+ condition: { field: 'operation', value: 'search' },
+ mode: 'advanced',
+ },
+ {
+ id: 'includeAttachments',
+ title: 'Download Attachment Files',
+ type: 'switch',
+ condition: { field: 'operation', value: ['read', 'get_attachments'] },
+ mode: 'advanced',
+ },
{
id: 'commentBody',
title: 'Comment Text',
@@ -1071,6 +1135,7 @@ Return ONLY the comment text - no explanations.`,
environment: params.environment || undefined,
customFieldId: params.customFieldId || undefined,
customFieldValue: customFieldValue,
+ customFields: params.customFields || undefined,
}
return {
...baseParams,
@@ -1100,6 +1165,8 @@ Return ONLY the comment text - no explanations.`,
environment: params.environment || undefined,
customFieldId: params.customFieldId || undefined,
customFieldValue: params.customFieldValue || undefined,
+ customFields: params.customFields || undefined,
+ parent: String(params.updateParentIssue ?? '').trim() || undefined,
notifyUsers:
params.notifyUsers === 'false'
? false
@@ -1117,6 +1184,10 @@ Return ONLY the comment text - no explanations.`,
...baseParams,
issueKey: effectiveIssueKey,
...(effectiveProjectId && { projectId: effectiveProjectId }),
+ includeAttachments:
+ params.includeAttachments === true || params.includeAttachments === 'true'
+ ? true
+ : undefined,
}
}
case 'read-bulk': {
@@ -1146,6 +1217,7 @@ Return ONLY the comment text - no explanations.`,
transitionId: params.transitionId,
comment: params.transitionComment,
resolution: params.resolution || undefined,
+ fields: params.transitionFields || undefined,
}
}
case 'search': {
@@ -1160,6 +1232,7 @@ Return ONLY the comment text - no explanations.`,
.map((f: string) => f.trim())
.filter(Boolean)
: undefined,
+ expand: params.expand ? String(params.expand).trim() || undefined : undefined,
}
}
case 'add_comment': {
@@ -1196,6 +1269,10 @@ Return ONLY the comment text - no explanations.`,
return {
...baseParams,
issueKey: effectiveIssueKey,
+ includeAttachments:
+ params.includeAttachments === true || params.includeAttachments === 'true'
+ ? true
+ : undefined,
}
}
case 'add_attachment': {
@@ -1385,6 +1462,20 @@ Return ONLY the comment text - no explanations.`,
environment: { type: 'string', description: 'Environment information' },
customFieldId: { type: 'string', description: 'Custom field ID (e.g., customfield_10001)' },
customFieldValue: { type: 'string', description: 'Value for the custom field' },
+ customFields: {
+ type: 'json',
+ description: 'JSON object mapping field IDs to values for create or update',
+ },
+ updateParentIssue: { type: 'string', description: 'New parent issue key for update' },
+ transitionFields: {
+ type: 'json',
+ description: 'JSON object of fields to set during a transition',
+ },
+ expand: { type: 'string', description: 'Comma-separated search expansions' },
+ includeAttachments: {
+ type: 'boolean',
+ description: 'Download attachment file contents for read or get attachments',
+ },
notifyUsers: { type: 'string', description: 'Whether to send notifications on update' },
deleteSubtasks: { type: 'string', description: 'Whether to delete subtasks (true/false)' },
accountId: {
@@ -1461,6 +1552,60 @@ Return ONLY the comment text - no explanations.`,
status: { type: 'string', description: 'Issue status name' },
assignee: { type: 'string', description: 'Issue assignee display name or account ID' },
+ descriptionAdf: {
+ type: 'json',
+ description: 'Issue description as raw Atlassian Document Format (read)',
+ },
+ renderedDescription: {
+ type: 'string',
+ description: 'Issue description rendered as HTML (read)',
+ },
+ environment: { type: 'string', description: 'Environment text (read)' },
+ statusName: { type: 'string', description: 'Issue status name (read)' },
+ priority: { type: 'json', description: 'Issue priority (id, name, iconUrl) (read)' },
+ reporter: {
+ type: 'json',
+ description: 'Reporter (accountId, displayName, emailAddress, active) (read)',
+ },
+ creator: {
+ type: 'json',
+ description: 'Creator (accountId, displayName, emailAddress, active) (read)',
+ },
+ issuetype: { type: 'json', description: 'Issue type (id, name, subtask) (read)' },
+ labels: { type: 'json', description: 'Issue labels as an array of strings (read)' },
+ components: { type: 'json', description: 'Components [{id, name, description}] (read)' },
+ fixVersions: {
+ type: 'json',
+ description: 'Fix versions [{id, name, released, releaseDate}] (read)',
+ },
+ resolution: { type: 'json', description: 'Resolution (id, name, description) (read)' },
+ resolutiondate: { type: 'string', description: 'When the issue was resolved (read)' },
+ duedate: { type: 'string', description: 'Due date YYYY-MM-DD (read)' },
+ timetracking: {
+ type: 'json',
+ description:
+ 'Time tracking (originalEstimate, remainingEstimate, timeSpent and their *Seconds) (read)',
+ },
+ parent: { type: 'json', description: 'Parent issue or epic (id, key, summary) (read)' },
+ subtasks: { type: 'json', description: 'Subtasks [{id, key, summary, statusName}] (read)' },
+ issuelinks: {
+ type: 'json',
+ description: 'Issue links [{id, type, inwardIssue, outwardIssue}] (read)',
+ },
+ watches: { type: 'json', description: 'Watch info (watchCount, isWatching) (read)' },
+ watchers: {
+ type: 'json',
+ description: 'Watching users [{accountId, displayName, emailAddress}] (read)',
+ },
+ customFields: {
+ type: 'json',
+ description:
+ 'Every customfield_* value keyed by field ID, e.g. sprint or story points (read)',
+ },
+ names: {
+ type: 'json',
+ description: 'Map of field ID to display name (read; search when expand includes names)',
+ },
url: { type: 'string', description: 'URL to the created/accessed issue' },
id: { type: 'string', description: 'Jira issue ID' },
key: { type: 'string', description: 'Jira issue key' },
@@ -1473,7 +1618,8 @@ Return ONLY the comment text - no explanations.`,
maxResults: { type: 'number', description: 'Maximum results per page' },
issues: {
type: 'json',
- description: 'Array of matching issues with key, summary, status, assignee, dates',
+ description:
+ 'Array of matching issues with key, summary, description, status, assignee, reporter, priority, labels, components, fixVersions, parent, timetracking, dates. descriptionAdf, subtasks, issuelinks, attachments, and customFields are only included when Fields is set; renderedDescription needs the renderedFields expansion',
},
comments: {
@@ -1489,7 +1635,11 @@ Return ONLY the comment text - no explanations.`,
type: 'json',
description: 'Array of attachments with id, filename, size, mimeType, created, author',
},
- files: { type: 'file[]', description: 'Uploaded attachment files' },
+ files: {
+ type: 'file[]',
+ description:
+ 'Uploaded attachment files (add attachment) or downloaded attachment files (read or get attachments with Download Attachment Files on)',
+ },
attachmentIds: { type: 'json', description: 'Uploaded attachment IDs' },
// jira_delete_attachment, jira_delete_comment, jira_delete_issue, jira_delete_worklog, jira_delete_issue_link outputs
@@ -1563,7 +1713,7 @@ Return ONLY the comment text - no explanations.`,
worklog_id: { type: 'string', description: 'Worklog ID (for worklog events)' },
time_spent: { type: 'string', description: 'Time spent (for worklog events)' },
changelog: { type: 'json', description: 'Changelog object (for update events)' },
- issue: { type: 'json', description: 'Complete issue object from webhook' },
+ issue: { type: 'json', description: 'Complete raw issue object (read, webhook)' },
sprint: { type: 'json', description: 'Sprint object (for sprint events)' },
project: { type: 'json', description: 'Project object (for project events)' },
version: { type: 'json', description: 'Version object (for version events)' },
diff --git a/apps/sim/blocks/blocks/jira_service_management.ts b/apps/sim/blocks/blocks/jira_service_management.ts
index 48d34480281..52b59ff2da5 100644
--- a/apps/sim/blocks/blocks/jira_service_management.ts
+++ b/apps/sim/blocks/blocks/jira_service_management.ts
@@ -647,6 +647,30 @@ Return ONLY the comment text - no explanations.`,
value: () => 'true',
condition: { field: 'operation', value: 'add_comment' },
},
+ {
+ id: 'includePublicComments',
+ title: 'Include Public Comments',
+ type: 'dropdown',
+ options: [
+ { label: 'Yes', id: 'true' },
+ { label: 'No', id: 'false' },
+ ],
+ value: () => 'true',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'get_comments' },
+ },
+ {
+ id: 'includeInternalComments',
+ title: 'Include Internal Comments',
+ type: 'dropdown',
+ options: [
+ { label: 'Yes', id: 'true' },
+ { label: 'No', id: 'false' },
+ ],
+ value: () => 'true',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'get_comments' },
+ },
{
id: 'accountIds',
title: 'Account IDs',
@@ -698,10 +722,48 @@ Return ONLY the comment text - no explanations.`,
{ label: 'My Requests', id: 'OWNED_REQUESTS' },
{ label: 'Participated', id: 'PARTICIPATED_REQUESTS' },
{ label: 'Approver', id: 'APPROVER' },
+ { label: 'Organization', id: 'ORGANIZATION' },
+ { label: 'All My Organizations', id: 'ALL_ORGANIZATIONS' },
],
value: () => 'ALL_REQUESTS',
condition: { field: 'operation', value: 'get_requests' },
},
+ {
+ id: 'requestApprovalStatus',
+ title: 'Approval Status',
+ type: 'dropdown',
+ options: [
+ { label: 'Any', id: '' },
+ { label: 'Pending My Approval', id: 'MY_PENDING_APPROVAL' },
+ { label: 'My Approval History', id: 'MY_HISTORY_APPROVAL' },
+ ],
+ value: () => '',
+ mode: 'advanced',
+ condition: {
+ field: 'operation',
+ value: 'get_requests',
+ and: { field: 'requestOwnership', value: 'APPROVER' },
+ },
+ },
+ {
+ id: 'requestOrganizationId',
+ title: 'Organization ID',
+ type: 'short-input',
+ placeholder: 'Customer organization ID (e.g., 3)',
+ condition: {
+ field: 'operation',
+ value: 'get_requests',
+ and: { field: 'requestOwnership', value: 'ORGANIZATION' },
+ },
+ },
+ {
+ id: 'requestTypeFilter',
+ title: 'Request Type ID',
+ type: 'short-input',
+ placeholder: 'Filter by request type ID (e.g., 25)',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'get_requests' },
+ },
{
id: 'requestStatus',
title: 'Request Status',
@@ -1200,6 +1262,15 @@ Return ONLY the comment text - no explanations.`,
serviceDeskId: params.serviceDeskId,
requestOwnership: params.requestOwnership,
requestStatus: params.requestStatus,
+ approvalStatus:
+ params.requestOwnership === 'APPROVER'
+ ? params.requestApprovalStatus || undefined
+ : undefined,
+ organizationId:
+ params.requestOwnership === 'ORGANIZATION'
+ ? params.requestOrganizationId || undefined
+ : undefined,
+ requestTypeId: params.requestTypeFilter || undefined,
searchTerm: params.searchTerm,
expand: params.expand,
start: toOptionalInt(params.startIndex),
@@ -1225,6 +1296,8 @@ Return ONLY the comment text - no explanations.`,
return {
...baseParams,
issueIdOrKey: params.issueIdOrKey,
+ isPublic: params.includePublicComments === 'false' ? false : undefined,
+ internal: params.includeInternalComments === 'false' ? false : undefined,
expand: params.expand,
start: toOptionalInt(params.startIndex),
limit: toOptionalInt(params.maxResults),
@@ -1662,6 +1735,17 @@ Return ONLY the comment text - no explanations.`,
requestOwnership: { type: 'string', description: 'Request ownership filter' },
requestStatus: { type: 'string', description: 'Request status filter' },
searchTerm: { type: 'string', description: 'Search term for requests' },
+ requestApprovalStatus: {
+ type: 'string',
+ description: 'Approval status filter for approver requests',
+ },
+ requestOrganizationId: {
+ type: 'string',
+ description: 'Organization ID filter for organization requests',
+ },
+ requestTypeFilter: { type: 'string', description: 'Request type ID filter for requests' },
+ includePublicComments: { type: 'string', description: 'Include public comments' },
+ includeInternalComments: { type: 'string', description: 'Include internal comments' },
includeCount: { type: 'string', description: 'Include issue count for queues' },
startIndex: { type: 'string', description: 'Pagination start index' },
maxResults: { type: 'string', description: 'Maximum results to return' },
@@ -1722,6 +1806,33 @@ Return ONLY the comment text - no explanations.`,
request: { type: 'json', description: 'Request object' },
requests: { type: 'json', description: 'Array of requests' },
url: { type: 'string', description: 'URL to the request' },
+ webUrl: {
+ type: 'string',
+ description: 'Customer portal URL for the request (from _links.web)',
+ },
+ requestTypeId: { type: 'string', description: 'Request type ID' },
+ serviceDeskId: { type: 'string', description: 'Service desk ID' },
+ createdDate: {
+ type: 'json',
+ description: 'Creation date (iso8601, friendly, epochMillis)',
+ },
+ currentStatus: {
+ type: 'json',
+ description: 'Current request status (status, statusCategory, statusDate)',
+ },
+ reporter: {
+ type: 'json',
+ description: 'Reporter (accountId, displayName, emailAddress, active)',
+ },
+ requestFieldValues: {
+ type: 'json',
+ description: 'Request field values [{fieldId, label, value, renderedValue}]',
+ },
+ issueIdOrKey: { type: 'string', description: 'Issue ID or key the operation acted on' },
+ author: {
+ type: 'json',
+ description: 'Comment author (accountId, displayName, emailAddress)',
+ },
commentId: { type: 'string', description: 'Comment ID' },
body: { type: 'string', description: 'Comment body' },
isPublic: { type: 'boolean', description: 'Whether comment is public' },
@@ -1739,6 +1850,21 @@ Return ONLY the comment text - no explanations.`,
approval: { type: 'json', description: 'Approval object' },
approvalId: { type: 'string', description: 'Approval ID' },
decision: { type: 'string', description: 'Approval decision' },
+ id: { type: 'string', description: 'Approval ID (answer approval) or form ID (attach form)' },
+ finalDecision: {
+ type: 'string',
+ description: 'Final approval decision (approved, declined, pending)',
+ },
+ canAnswerApproval: {
+ type: 'boolean',
+ description: 'Whether the current user can answer the approval',
+ },
+ approvers: {
+ type: 'json',
+ description:
+ 'Approvers [{approver: {accountId, displayName, emailAddress}, approverDecision}]',
+ },
+ completedDate: { type: 'json', description: 'Approval completion date' },
total: { type: 'number', description: 'Total count' },
isLastPage: { type: 'boolean', description: 'Whether this is the last page' },
requestTypeFields: { type: 'json', description: 'Array of request type fields' },
@@ -1765,6 +1891,7 @@ Return ONLY the comment text - no explanations.`,
description: 'Form publishing and request type configuration',
},
updated: { type: 'string', description: 'Last updated timestamp' },
+ projectIdOrKey: { type: 'string', description: 'Project ID or key for form templates' },
forms: {
type: 'json',
description:
@@ -1817,7 +1944,13 @@ Return ONLY the comment text - no explanations.`,
description: 'Single Assets object (id, label, objectKey, objectType, attributes)',
},
objectId: { type: 'string', description: 'Assets object ID (delete operation)' },
- isLast: { type: 'boolean', description: 'Whether this is the last page of schemas' },
+ isLast: {
+ type: 'boolean',
+ description: 'Whether this is the last page of schemas or AQL search results',
+ },
+ pageNumber: { type: 'number', description: 'AQL search page number' },
+ pageSize: { type: 'number', description: 'Number of objects on this AQL search page' },
+ startAt: { type: 'number', description: 'Index of the first object on this AQL search page' },
},
triggers: {
enabled: true,
diff --git a/apps/sim/blocks/blocks/kalshi.ts b/apps/sim/blocks/blocks/kalshi.ts
index d3d20ce0eba..280e6eebed4 100644
--- a/apps/sim/blocks/blocks/kalshi.ts
+++ b/apps/sim/blocks/blocks/kalshi.ts
@@ -1,7 +1,7 @@
import { KalshiIcon } from '@/components/icons'
import type { BlockConfig, BlockMeta } from '@/blocks/types'
import { AuthMode, IntegrationType } from '@/blocks/types'
-import { createVersionedToolSelector } from '@/blocks/utils'
+import { createVersionedToolSelector, withSavedCredential } from '@/blocks/utils'
/*
* Mutually exclusive alternates, not canonical pairs — an order carries either
@@ -22,7 +22,7 @@ export const KalshiBlock: BlockConfig = {
category: 'tools',
integrationType: IntegrationType.Analytics,
hideFromToolbar: true,
- sunset: { status: 'legacy', replacedBy: 'kalshi_v2' },
+ sunset: { status: 'legacy', replacedBy: 'kalshi_v3' },
bgColor: '#09C285',
iconColor: '#09C285',
icon: KalshiIcon,
@@ -824,14 +824,14 @@ Return ONLY the numeric timestamp (seconds since Unix epoch) - no explanations,
export const KalshiV2Block: BlockConfig = {
...KalshiBlock,
- sunset: undefined,
+ sunset: { status: 'legacy', replacedBy: 'kalshi_v3' },
type: 'kalshi_v2',
name: 'Kalshi',
description: 'Access prediction markets and trade on Kalshi',
longDescription:
'Integrate Kalshi prediction markets into the workflow. Can get markets, market, events, event, balance, positions, orders, orderbook, trades, candlesticks, fills, series, exchange status, and place/cancel/amend trades.',
integrationType: IntegrationType.Analytics,
- hideFromToolbar: false,
+ hideFromToolbar: true,
tools: {
...KalshiBlock.tools,
access: [
@@ -996,6 +996,24 @@ export const KalshiV2Block: BlockConfig = {
},
}
+/**
+ * Signs account and trading requests with a saved Kalshi API key; the market-data operations
+ * stay public and need no credential.
+ */
+export const KalshiV3Block: BlockConfig = {
+ ...KalshiV2Block,
+ type: 'kalshi_v3',
+ authMode: AuthMode.OAuth,
+ hideFromToolbar: false,
+ sunset: undefined,
+ ...withSavedCredential(KalshiV2Block, {
+ id: 'credential',
+ type: 'oauth-input',
+ serviceId: 'kalshi',
+ title: 'Kalshi Credential',
+ }),
+}
+
export const KalshiBlockMeta = {
tags: ['prediction-markets', 'data-analytics'],
url: 'https://kalshi.com',
diff --git a/apps/sim/blocks/blocks/lambda.ts b/apps/sim/blocks/blocks/lambda.ts
index 8c8af02bd15..1bebcaf688b 100644
--- a/apps/sim/blocks/blocks/lambda.ts
+++ b/apps/sim/blocks/blocks/lambda.ts
@@ -17,6 +17,7 @@ const PARAM_KINDS = {
logType: 'string',
clientContext: 'string',
qualifier: 'string',
+ tenantId: 'string',
functionVersion: 'string',
masterRegion: 'string',
marker: 'string',
@@ -47,6 +48,8 @@ const PARAM_KINDS = {
snapStartApplyOn: 'string',
logFormat: 'string',
logGroup: 'string',
+ applicationLogLevel: 'string',
+ systemLogLevel: 'string',
dryRun: 'boolean',
revisionId: 'string',
codeSha256: 'string',
@@ -119,7 +122,15 @@ const ALL_PARAM_NAMES = Object.keys(PARAM_KINDS) as ParamName[]
* executor's `{ ...inputs, ...transformedParams }` merge.
*/
const OPERATION_PARAMS: Record = {
- invoke: ['functionName', 'payload', 'invocationType', 'logType', 'clientContext', 'qualifier'],
+ invoke: [
+ 'functionName',
+ 'payload',
+ 'invocationType',
+ 'logType',
+ 'clientContext',
+ 'qualifier',
+ 'tenantId',
+ ],
list_functions: ['functionVersion', 'masterRegion', 'marker', 'maxItems'],
get_function: ['functionName', 'qualifier'],
get_function_configuration: ['functionName', 'qualifier'],
@@ -183,6 +194,8 @@ const OPERATION_PARAMS: Record = {
'snapStartApplyOn',
'logFormat',
'logGroup',
+ 'applicationLogLevel',
+ 'systemLogLevel',
'revisionId',
],
delete_function: ['functionName', 'qualifier'],
@@ -822,11 +835,22 @@ export const LambdaBlock: BlockConfig = {
],
},
},
+ {
+ id: 'tenantId',
+ title: 'Tenant ID',
+ type: 'short-input',
+ placeholder: 'Tenant identifier, for functions with tenant isolation',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'invoke' },
+ },
{
id: 'functionVersion',
title: 'Include All Versions',
type: 'dropdown',
- options: [{ label: 'ALL', id: 'ALL' }],
+ options: [
+ { label: 'Unpublished only ($LATEST)', id: '' },
+ { label: 'ALL', id: 'ALL' },
+ ],
mode: 'advanced',
condition: { field: 'operation', value: 'list_functions' },
},
@@ -916,6 +940,7 @@ export const LambdaBlock: BlockConfig = {
title: 'Package Type',
type: 'dropdown',
options: [
+ { label: 'Infer from code source', id: '' },
{ label: 'Zip (.zip archive)', id: 'Zip' },
{ label: 'Image (container image)', id: 'Image' },
],
@@ -1096,6 +1121,7 @@ export const LambdaBlock: BlockConfig = {
title: 'X-Ray Tracing',
type: 'dropdown',
options: [
+ { label: 'Default (unchanged on update)', id: '' },
{ label: 'Pass Through', id: 'PassThrough' },
{ label: 'Active', id: 'Active' },
],
@@ -1137,6 +1163,7 @@ export const LambdaBlock: BlockConfig = {
title: 'SnapStart',
type: 'dropdown',
options: [
+ { label: 'Default (unchanged on update)', id: '' },
{ label: 'None', id: 'None' },
{ label: 'Published Versions', id: 'PublishedVersions' },
],
@@ -1151,6 +1178,7 @@ export const LambdaBlock: BlockConfig = {
title: 'Log Format',
type: 'dropdown',
options: [
+ { label: 'Default (unchanged on update)', id: '' },
{ label: 'Text', id: 'Text' },
{ label: 'JSON', id: 'JSON' },
],
@@ -1171,6 +1199,35 @@ export const LambdaBlock: BlockConfig = {
value: ['create_function', 'update_function_configuration'],
},
},
+ {
+ id: 'applicationLogLevel',
+ title: 'Application Log Level',
+ type: 'dropdown',
+ options: [
+ { label: 'Unchanged', id: '' },
+ { label: 'TRACE', id: 'TRACE' },
+ { label: 'DEBUG', id: 'DEBUG' },
+ { label: 'INFO', id: 'INFO' },
+ { label: 'WARN', id: 'WARN' },
+ { label: 'ERROR', id: 'ERROR' },
+ { label: 'FATAL', id: 'FATAL' },
+ ],
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'update_function_configuration' },
+ },
+ {
+ id: 'systemLogLevel',
+ title: 'System Log Level',
+ type: 'dropdown',
+ options: [
+ { label: 'Unchanged', id: '' },
+ { label: 'DEBUG', id: 'DEBUG' },
+ { label: 'INFO', id: 'INFO' },
+ { label: 'WARN', id: 'WARN' },
+ ],
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'update_function_configuration' },
+ },
{
id: 'dryRun',
title: 'Dry Run',
@@ -1304,6 +1361,7 @@ export const LambdaBlock: BlockConfig = {
title: 'Function URL Auth Type',
type: 'dropdown',
options: [
+ { label: 'Not set', id: '' },
{ label: 'NONE', id: 'NONE' },
{ label: 'AWS_IAM', id: 'AWS_IAM' },
],
@@ -1325,6 +1383,7 @@ export const LambdaBlock: BlockConfig = {
title: 'Enabled',
type: 'dropdown',
options: [
+ { label: 'Default (enabled on create, unchanged on update)', id: '' },
{ label: 'No', id: 'false' },
{ label: 'Yes', id: 'true' },
],
@@ -1359,6 +1418,7 @@ export const LambdaBlock: BlockConfig = {
title: 'Starting Position',
type: 'dropdown',
options: [
+ { label: 'Not set (not used for SQS)', id: '' },
{ label: 'Latest', id: 'LATEST' },
{ label: 'Trim Horizon (oldest)', id: 'TRIM_HORIZON' },
{ label: 'At Timestamp', id: 'AT_TIMESTAMP' },
@@ -1419,6 +1479,7 @@ export const LambdaBlock: BlockConfig = {
title: 'Bisect Batch On Error',
type: 'dropdown',
options: [
+ { label: 'Default (unchanged on update)', id: '' },
{ label: 'No', id: 'false' },
{ label: 'Yes', id: 'true' },
],
@@ -1566,6 +1627,7 @@ export const LambdaBlock: BlockConfig = {
title: 'DocumentDB Full Document',
type: 'dropdown',
options: [
+ { label: 'Default (unchanged on update)', id: '' },
{ label: 'Default (change delta only)', id: 'Default' },
{ label: 'Update Lookup (full document)', id: 'UpdateLookup' },
],
@@ -1662,6 +1724,7 @@ export const LambdaBlock: BlockConfig = {
title: 'Invoke Mode',
type: 'dropdown',
options: [
+ { label: 'Default (unchanged on update)', id: '' },
{ label: 'Buffered', id: 'BUFFERED' },
{ label: 'Response Stream', id: 'RESPONSE_STREAM' },
],
@@ -1676,6 +1739,7 @@ export const LambdaBlock: BlockConfig = {
title: 'CORS Allow Credentials',
type: 'dropdown',
options: [
+ { label: 'Default (unchanged on update)', id: '' },
{ label: 'No', id: 'false' },
{ label: 'Yes', id: 'true' },
],
@@ -1761,6 +1825,7 @@ export const LambdaBlock: BlockConfig = {
title: 'Compatible Architecture',
type: 'dropdown',
options: [
+ { label: 'Any', id: '' },
{ label: 'x86_64', id: 'x86_64' },
{ label: 'arm64', id: 'arm64' },
],
@@ -1923,6 +1988,7 @@ export const LambdaBlock: BlockConfig = {
logType: { type: 'string', description: 'Log Type' },
clientContext: { type: 'string', description: 'Client Context' },
qualifier: { type: 'string', description: 'Qualifier' },
+ tenantId: { type: 'string', description: 'Tenant ID' },
functionVersion: { type: 'string', description: 'Include All Versions' },
masterRegion: { type: 'string', description: 'Master Region' },
marker: { type: 'string', description: 'Marker' },
@@ -1953,6 +2019,8 @@ export const LambdaBlock: BlockConfig = {
snapStartApplyOn: { type: 'string', description: 'SnapStart' },
logFormat: { type: 'string', description: 'Log Format' },
logGroup: { type: 'string', description: 'Log Group' },
+ applicationLogLevel: { type: 'string', description: 'Application Log Level' },
+ systemLogLevel: { type: 'string', description: 'System Log Level' },
dryRun: { type: 'boolean', description: 'Dry Run' },
revisionId: { type: 'string', description: 'Revision ID' },
codeSha256: { type: 'string', description: 'Code SHA256' },
@@ -2063,12 +2131,18 @@ export const LambdaBlock: BlockConfig = {
},
code: {
type: 'json',
- description: 'Presigned download URL for the deployment package, or the container image URI',
+ description:
+ 'Deployment package location (repositoryType, location presigned download URL, imageUri, resolvedImageUri, sourceKmsKeyArn, error)',
},
tags: {
type: 'json',
description: "The function's tags",
},
+ tagsError: {
+ type: 'json',
+ description:
+ 'Why the tags could not be read, when a partial tag-read failure occurred (errorCode, message)',
+ },
reservedConcurrentExecutions: {
type: 'number',
description: 'Concurrency reserved for this function, if any',
diff --git a/apps/sim/blocks/blocks/microsoft_ad.ts b/apps/sim/blocks/blocks/microsoft_ad.ts
index fb8afdc0d9f..26efed35581 100644
--- a/apps/sim/blocks/blocks/microsoft_ad.ts
+++ b/apps/sim/blocks/blocks/microsoft_ad.ts
@@ -18,6 +18,7 @@ const USER_ID_OPERATIONS = [
'add_user_app_role_assignment',
'remove_user_app_role_assignment',
'list_user_devices',
+ 'list_user_memberships',
]
/**
@@ -25,7 +26,11 @@ const USER_ID_OPERATIONS = [
* addresses the user, so these are the only per-user operations that can run without a User ID,
* and only when continuing from a previous page.
*/
-const PAGED_USER_ID_OPERATIONS = ['list_user_app_role_assignments', 'list_user_devices']
+const PAGED_USER_ID_OPERATIONS = [
+ 'list_user_app_role_assignments',
+ 'list_user_devices',
+ 'list_user_memberships',
+]
/** Per-user operations that always require a User ID, whichever page is being fetched. */
const ALWAYS_USER_ID_OPERATIONS = USER_ID_OPERATIONS.filter(
@@ -65,6 +70,7 @@ const PAGED_OPERATIONS = [
'list_service_principals',
'list_devices',
'list_user_devices',
+ 'list_user_memberships',
'list_conditional_access_policies',
]
@@ -169,6 +175,9 @@ export const MicrosoftAdBlock: BlockConfig = {
],
delete_group: [{ text: 'Delete group', field: 'groupId', core: true }],
list_group_members: [{ text: 'List members of group', field: 'groupId', core: true }],
+ list_user_memberships: [
+ { text: 'List groups and roles for user', field: 'userId', core: true },
+ ],
add_group_member: [
{ text: 'Add member', field: 'memberId', core: true },
{ text: 'to group', field: 'groupId', core: true },
@@ -257,6 +266,7 @@ export const MicrosoftAdBlock: BlockConfig = {
{ label: 'List Group Members', id: 'list_group_members' },
{ label: 'Add Group Member', id: 'add_group_member' },
{ label: 'Remove Group Member', id: 'remove_group_member' },
+ { label: 'List User Memberships', id: 'list_user_memberships' },
{ label: 'Assign License', id: 'assign_license' },
{ label: 'List User Licenses', id: 'list_user_licenses' },
{ label: 'List Subscribed SKUs', id: 'list_subscribed_skus' },
@@ -767,6 +777,24 @@ export const MicrosoftAdBlock: BlockConfig = {
value: () => 'registered',
condition: { field: 'operation', value: 'list_user_devices' },
},
+ {
+ id: 'includeManager',
+ title: 'Include Manager',
+ type: 'switch',
+ condition: { field: 'operation', value: 'get_user' },
+ mode: 'advanced',
+ },
+ {
+ id: 'membershipScope',
+ title: 'Membership',
+ type: 'dropdown',
+ options: [
+ { label: 'Direct Memberships', id: 'direct' },
+ { label: 'Include Nested Groups', id: 'transitive' },
+ ],
+ value: () => 'direct',
+ condition: { field: 'operation', value: 'list_user_memberships' },
+ },
{
id: 'policyId',
title: 'Policy ID',
@@ -797,6 +825,7 @@ export const MicrosoftAdBlock: BlockConfig = {
'microsoft_ad_update_group',
'microsoft_ad_delete_group',
'microsoft_ad_list_group_members',
+ 'microsoft_ad_list_user_memberships',
'microsoft_ad_add_group_member',
'microsoft_ad_remove_group_member',
'microsoft_ad_assign_license',
@@ -833,6 +862,12 @@ export const MicrosoftAdBlock: BlockConfig = {
const values = params as Record
result.filter = values[FILTER_FIELD_BY_OPERATION[params.operation]] || undefined
result.search = values[SEARCH_FIELD_BY_OPERATION[params.operation]] || undefined
+ if (params.operation === 'get_user') {
+ result.includeManager = params.includeManager === true || params.includeManager === 'true'
+ }
+ if (params.operation === 'list_user_memberships') {
+ result.transitive = params.membershipScope === 'transitive'
+ }
if (params.operation === 'set_password') {
result.forceChangePasswordNextSignIn = params.forceChangePasswordNextSignIn !== 'false'
result.forceChangePasswordNextSignInWithMfa = params.forceChangePasswordNextSignInWithMfa
@@ -914,6 +949,8 @@ export const MicrosoftAdBlock: BlockConfig = {
deviceSearch: { type: 'string' },
deviceFilter: { type: 'string' },
deviceRelationship: { type: 'string' },
+ includeManager: { type: 'boolean' },
+ membershipScope: { type: 'string' },
policyId: { type: 'string' },
policyFilter: { type: 'string' },
},
@@ -921,10 +958,18 @@ export const MicrosoftAdBlock: BlockConfig = {
users: {
type: 'array',
description:
- 'User objects (id, displayName, givenName, surname, userPrincipalName, mail, jobTitle, department, officeLocation, mobilePhone, accountEnabled)',
+ 'User objects (id, displayName, givenName, surname, userPrincipalName, mail, jobTitle, department, officeLocation, mobilePhone, accountEnabled, businessPhones, preferredLanguage, employeeId, employeeType, companyName, userType, usageLocation, city, country, createdDateTime, onPremisesSyncEnabled)',
},
userCount: { type: 'number', description: 'Number of users returned on this page' },
- user: { type: 'json', description: 'The single user this operation created or read' },
+ user: {
+ type: 'json',
+ description: 'The single user this operation created or read',
+ },
+ manager: {
+ type: 'json',
+ description:
+ "Get User with Include Manager on: the user's manager (id, displayName, mail, userPrincipalName, jobTitle), or null when none is assigned",
+ },
userId: { type: 'string', description: 'Object ID of the user the operation acted on' },
userPrincipalName: { type: 'string', description: 'User principal name of that user' },
displayName: { type: 'string', description: 'Display name of the user the operation acted on' },
@@ -938,10 +983,17 @@ export const MicrosoftAdBlock: BlockConfig = {
groupId: { type: 'string', description: 'Object ID of the group the operation acted on' },
members: {
type: 'array',
- description: 'Group or directory role members (id, displayName, mail, odataType)',
+ description:
+ 'Group or directory role members (id, displayName, mail, odataType, userPrincipalName)',
},
memberCount: { type: 'number', description: 'Number of members returned on this page' },
memberId: { type: 'string', description: 'Object ID of the member the operation acted on' },
+ memberships: {
+ type: 'array',
+ description:
+ 'Groups, directory roles, and administrative units the user belongs to (id, displayName, description, odataType, mail, mailEnabled, securityEnabled, groupTypes, roleTemplateId)',
+ },
+ membershipCount: { type: 'number', description: 'Number of memberships returned on this page' },
roles: {
type: 'array',
description: 'Activated directory roles (id, displayName, description, roleTemplateId)',
@@ -1130,7 +1182,7 @@ export const MicrosoftAdBlockMeta = {
description:
'Disable an Azure AD account and strip its group memberships when someone leaves. Use for secure offboarding.',
content:
- '# Offboard User\n\nRevoke access for a departing user.\n\n## Steps\n1. Resolve the account with Get User by user principal name or id.\n2. Use Update User to set Account Enabled to No so the user can no longer sign in.\n3. Use List Group Members or the user record to enumerate the groups the user belongs to.\n4. For each group, call Remove Group Member with the user id.\n\n## Output\nReturn the disabled user id and the list of groups the user was removed from. Note any group where removal failed so it can be handled manually, and recommend writing the action to an audit record.',
+ '# Offboard User\n\nRevoke access for a departing user.\n\n## Steps\n1. Resolve the account with Get User by user principal name or id.\n2. Use Update User to set Account Enabled to No so the user can no longer sign in.\n3. Use List User Memberships with the user id to enumerate everything the user belongs to directly.\n4. For each membership whose odataType is #microsoft.graph.group, call Remove Group Member with the user id. For each one whose odataType is #microsoft.graph.directoryRole, call Remove Directory Role Member with the role id and user id. List any other types (such as administrative units) for manual follow-up.\n\n## Output\nReturn the disabled user id and the groups and directory roles the user was removed from. Note any group or role where removal failed so it can be handled manually, and recommend writing the action to an audit record.',
},
{
name: 'audit-group-membership',
diff --git a/apps/sim/blocks/blocks/microsoft_dataverse.ts b/apps/sim/blocks/blocks/microsoft_dataverse.ts
index 53c5f7e3f20..d12ccbaa543 100644
--- a/apps/sim/blocks/blocks/microsoft_dataverse.ts
+++ b/apps/sim/blocks/blocks/microsoft_dataverse.ts
@@ -222,6 +222,13 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`,
generationType: 'json-object',
},
},
+ {
+ id: 'returnRecord',
+ title: 'Return Updated Record',
+ type: 'switch',
+ condition: { field: 'operation', value: 'update_record' },
+ mode: 'advanced',
+ },
// FetchXML Query
{
id: 'fetchXml',
@@ -536,6 +543,22 @@ Return ONLY the orderby expression - no $orderby= prefix, no explanations.`,
condition: { field: 'operation', value: 'list_records' },
mode: 'advanced',
},
+ {
+ id: 'pageSize',
+ title: 'Page Size',
+ type: 'short-input',
+ placeholder: '1-5000 (default 100 when Max Results is empty); reuse with Next Page Link',
+ condition: { field: 'operation', value: 'list_records' },
+ mode: 'advanced',
+ },
+ {
+ id: 'nextLink',
+ title: 'Next Page Link',
+ type: 'short-input',
+ placeholder: 'nextLink from the previous page (empty on the last page; stop when empty)',
+ condition: { field: 'operation', value: 'list_records' },
+ mode: 'advanced',
+ },
{
id: 'expand',
title: 'Expand',
@@ -662,6 +685,13 @@ Return ONLY the expand expression - no $expand= prefix, no explanations.`,
}
})
+ for (const key of ['top', 'skip', 'pageSize'] as const) {
+ const value = cleanParams[key]
+ if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value))) {
+ cleanParams[key] = Number(value)
+ }
+ }
+
return cleanParams
},
},
@@ -682,6 +712,16 @@ Return ONLY the expand expression - no $expand= prefix, no explanations.`,
description: 'Set to "true" to include total record count in the response (list records)',
},
expand: { type: 'string', description: 'Navigation properties to expand' },
+ pageSize: {
+ type: 'string',
+ description:
+ 'Records per page for list records (1-5000); keep unchanged across Next Page Link pages',
+ },
+ nextLink: { type: 'string', description: 'nextLink from a previous list records page' },
+ returnRecord: {
+ type: 'boolean',
+ description: 'Return the updated record (update record)',
+ },
navigationProperty: {
type: 'string',
description: 'Navigation property name for associations',
@@ -734,6 +774,11 @@ Return ONLY the expand expression - no $expand= prefix, no explanations.`,
description: 'Total matching records server-side',
},
nextLink: { type: 'string', description: 'URL for next page of results' },
+ pageSize: {
+ type: 'number',
+ description:
+ 'Page size sent for list records (null when Max Results applied instead); pass it with nextLink for the next page',
+ },
created: { type: 'boolean', description: 'Whether a new record was created (upsert)' },
userId: { type: 'string', description: 'Authenticated user ID (WhoAmI)' },
businessUnitId: { type: 'string', description: 'Business unit ID (WhoAmI)' },
diff --git a/apps/sim/blocks/blocks/microsoft_excel.ts b/apps/sim/blocks/blocks/microsoft_excel.ts
index e87f2a0f403..c4e6be972d7 100644
--- a/apps/sim/blocks/blocks/microsoft_excel.ts
+++ b/apps/sim/blocks/blocks/microsoft_excel.ts
@@ -82,6 +82,7 @@ function normalizeExcelAgentParams(params: Record, separateShee
'showTotals',
'rowHidden',
'columnHidden',
+ 'valuesOnly',
])
result[field] = optionalBoolean(params[field])
for (const field of [
@@ -1504,6 +1505,19 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
],
value: () => '',
},
+ {
+ id: 'valuesOnly',
+ title: 'Used Range Cells',
+ type: 'dropdown',
+ description: 'Applies only when no cell range is set and the used range is read.',
+ condition: { field: 'operation', value: ['read'] },
+ mode: 'advanced',
+ options: [
+ { label: 'Cells with values', id: '' },
+ { label: 'Cells with values or formatting', id: 'false' },
+ ],
+ value: () => '',
+ },
{
id: 'fontUnderline',
title: 'Underline',
@@ -2006,7 +2020,8 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
throw new Error('Sheet name is required. Please select or enter a sheet name.')
}
const rangeParams = { ...base, sheetName: effectiveSheetName, cellRange: trimmedRange }
- if (operation === 'read') return rangeParams
+ if (operation === 'read')
+ return { ...rangeParams, valuesOnly: optionalBoolean(params.valuesOnly) }
return {
...rangeParams,
values: optionalJson(values),
@@ -2048,6 +2063,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
numberFormat: { type: 'json', description: 'Number Formats' },
columnHidden: { type: 'string', description: 'Hide Columns' },
rowHidden: { type: 'string', description: 'Hide Rows' },
+ valuesOnly: { type: 'string', description: 'Used range counts only cells with values' },
fontUnderline: { type: 'string', description: 'Underline' },
operation: { type: 'string', description: 'Operation to perform' },
fileSource: { type: 'string', description: 'File source (onedrive or sharepoint)' },
diff --git a/apps/sim/blocks/blocks/microsoft_planner.ts b/apps/sim/blocks/blocks/microsoft_planner.ts
index 2a983c7354c..b5fead93b20 100644
--- a/apps/sim/blocks/blocks/microsoft_planner.ts
+++ b/apps/sim/blocks/blocks/microsoft_planner.ts
@@ -867,11 +867,12 @@ Return ONLY the comma-separated category keys - no explanations, no extra text.`
task: {
type: 'json',
description:
- 'The Microsoft Planner task object, including details such as id, title, description, status, due date, and assignees.',
+ 'The Microsoft Planner task object as returned by Graph (id, title, planId, bucketId, percentComplete, priority, startDateTime, dueDateTime, assignments keyed by user ID, appliedCategories, @odata.etag). The description and checklist live on task details.',
},
tasks: {
type: 'json',
- description: 'Array of Microsoft Planner tasks',
+ description:
+ 'Array of Microsoft Planner tasks (id, title, planId, bucketId, percentComplete, priority, startDateTime, dueDateTime, createdDateTime, completedDateTime, hasDescription, assignments as user IDs, assignees with userId/assignedDateTime/assignedBy, appliedCategories, checklistItemCount, activeChecklistItemCount, referenceCount, previewType, orderHint, conversationThreadId, createdBy, completedBy, etag)',
},
taskId: {
type: 'string',
@@ -904,7 +905,8 @@ Return ONLY the comma-separated category keys - no explanations, no extra text.`
},
taskDetails: {
type: 'json',
- description: 'The Microsoft Planner task details including checklist and references',
+ description:
+ 'The Microsoft Planner task details (id, description, previewType, checklist keyed by item ID with title/isChecked/orderHint, references keyed by encoded URL with alias/type, @odata.etag for Update Task Details)',
},
deleted: {
type: 'boolean',
diff --git a/apps/sim/blocks/blocks/microsoft_teams.ts b/apps/sim/blocks/blocks/microsoft_teams.ts
index 9aaaf5810f2..978da9e3382 100644
--- a/apps/sim/blocks/blocks/microsoft_teams.ts
+++ b/apps/sim/blocks/blocks/microsoft_teams.ts
@@ -15,6 +15,34 @@ const CHAT_FIELD = ['chatSelector', 'manualChatId'] as const
const CHANNEL_FIELD = ['channelSelector', 'manualChannelId'] as const
const ATTACHMENT_FIELD = ['attachmentFiles', 'fileReferences'] as const
+/** Operations that address a message in either a chat or a channel, so neither id set is required. */
+const MESSAGE_LOOKUP_OPERATIONS = ['get_message', 'set_reaction', 'unset_reaction'] as const
+const TEAM_REQUIRED_OPERATIONS = [
+ 'read_channel',
+ 'write_channel',
+ 'update_channel_message',
+ 'delete_channel_message',
+ 'reply_to_message',
+ 'list_team_members',
+ 'list_channel_members',
+ 'list_channels',
+] as const
+const CHAT_REQUIRED_OPERATIONS = [
+ 'read_chat',
+ 'write_chat',
+ 'update_chat_message',
+ 'delete_chat_message',
+ 'list_chat_members',
+] as const
+const CHANNEL_REQUIRED_OPERATIONS = [
+ 'read_channel',
+ 'write_channel',
+ 'update_channel_message',
+ 'delete_channel_message',
+ 'reply_to_message',
+ 'list_channel_members',
+] as const
+
export const MicrosoftTeamsBlock: BlockConfig = {
type: 'microsoft_teams',
name: 'Microsoft Teams',
@@ -178,9 +206,10 @@ export const MicrosoftTeamsBlock: BlockConfig = {
'list_team_members',
'list_channel_members',
'list_channels',
+ ...MESSAGE_LOOKUP_OPERATIONS,
],
},
- required: true,
+ required: { field: 'operation', value: [...TEAM_REQUIRED_OPERATIONS] },
},
{
id: 'manualTeamId',
@@ -201,9 +230,10 @@ export const MicrosoftTeamsBlock: BlockConfig = {
'list_team_members',
'list_channel_members',
'list_channels',
+ ...MESSAGE_LOOKUP_OPERATIONS,
],
},
- required: true,
+ required: { field: 'operation', value: [...TEAM_REQUIRED_OPERATIONS] },
},
{
id: 'chatSelector',
@@ -224,9 +254,10 @@ export const MicrosoftTeamsBlock: BlockConfig = {
'update_chat_message',
'delete_chat_message',
'list_chat_members',
+ ...MESSAGE_LOOKUP_OPERATIONS,
],
},
- required: true,
+ required: { field: 'operation', value: [...CHAT_REQUIRED_OPERATIONS] },
},
{
id: 'manualChatId',
@@ -244,9 +275,10 @@ export const MicrosoftTeamsBlock: BlockConfig = {
'update_chat_message',
'delete_chat_message',
'list_chat_members',
+ ...MESSAGE_LOOKUP_OPERATIONS,
],
},
- required: true,
+ required: { field: 'operation', value: [...CHAT_REQUIRED_OPERATIONS] },
},
{
id: 'channelSelector',
@@ -268,9 +300,10 @@ export const MicrosoftTeamsBlock: BlockConfig = {
'delete_channel_message',
'reply_to_message',
'list_channel_members',
+ ...MESSAGE_LOOKUP_OPERATIONS,
],
},
- required: true,
+ required: { field: 'operation', value: [...CHANNEL_REQUIRED_OPERATIONS] },
},
{
id: 'manualChannelId',
@@ -289,9 +322,10 @@ export const MicrosoftTeamsBlock: BlockConfig = {
'delete_channel_message',
'reply_to_message',
'list_channel_members',
+ ...MESSAGE_LOOKUP_OPERATIONS,
],
},
- required: true,
+ required: { field: 'operation', value: [...CHANNEL_REQUIRED_OPERATIONS] },
},
{
id: 'messageId',
@@ -341,12 +375,55 @@ export const MicrosoftTeamsBlock: BlockConfig = {
},
required: true,
},
+ {
+ id: 'contentType',
+ title: 'Message Format',
+ type: 'dropdown',
+ options: [
+ { label: 'Plain Text', id: 'text' },
+ { label: 'HTML', id: 'html' },
+ ],
+ value: () => 'text',
+ mode: 'advanced',
+ condition: {
+ field: 'operation',
+ value: [
+ 'write_chat',
+ 'write_channel',
+ 'update_chat_message',
+ 'update_channel_message',
+ 'reply_to_message',
+ ],
+ },
+ },
{
id: 'includeAttachments',
title: 'Include Attachments',
type: 'switch',
condition: { field: 'operation', value: ['read_chat', 'read_channel'] },
},
+ {
+ id: 'includeReplies',
+ title: 'Include Thread Replies',
+ type: 'switch',
+ condition: { field: 'operation', value: 'read_channel' },
+ },
+ {
+ id: 'top',
+ title: 'Page Size',
+ type: 'short-input',
+ placeholder: '50 (max 50)',
+ mode: 'advanced',
+ condition: { field: 'operation', value: ['read_chat', 'read_channel', 'list_chats'] },
+ },
+ {
+ id: 'pageToken',
+ title: 'Page Token',
+ type: 'short-input',
+ placeholder: 'nextPageToken from a previous run',
+ mode: 'advanced',
+ condition: { field: 'operation', value: ['read_chat', 'read_channel', 'list_chats'] },
+ },
// File upload (basic mode)
{
id: 'attachmentFiles',
@@ -571,6 +648,10 @@ export const MicrosoftTeamsBlock: BlockConfig = {
type: 'boolean',
description: 'Download and include message attachments',
},
+ contentType: { type: 'string', description: 'Message body format: text or html' },
+ includeReplies: { type: 'boolean', description: 'Include thread replies (channel reads)' },
+ top: { type: 'number', description: 'Page size (1-50)' },
+ pageToken: { type: 'string', description: 'nextPageToken from a previous read or list' },
},
outputs: {
content: { type: 'string', description: 'Formatted message content from chat/channel' },
@@ -609,6 +690,44 @@ export const MicrosoftTeamsBlock: BlockConfig = {
type: 'boolean',
description: 'Whether Graph indicated additional pages beyond this response',
},
+ nextPageToken: {
+ type: 'string',
+ description: 'Pass as Page Token to fetch the next page; null on the last page',
+ },
+ chatId: { type: 'string', description: 'Chat ID that was read or written' },
+ teamId: { type: 'string', description: 'Team ID that was read or written' },
+ attachmentCount: { type: 'number', description: 'Total number of attachments found' },
+ replyToId: { type: 'string', description: 'Parent message ID (channel replies)' },
+ from: {
+ type: 'json',
+ description:
+ 'Get Message sender identity set: user, application (bot), or device, each with id and displayName',
+ },
+ senderId: { type: 'string', description: 'Get Message sender user or application ID' },
+ senderType: {
+ type: 'string',
+ description: 'Get Message sender kind: user, application, device',
+ },
+ subject: { type: 'string', description: 'Get Message subject' },
+ summary: { type: 'string', description: 'Get Message summary (channel messages)' },
+ importance: { type: 'string', description: 'Get Message importance: normal, high, urgent' },
+ webUrl: { type: 'string', description: 'Get Message link in Teams' },
+ contentType: { type: 'string', description: 'Get Message body type: text or html' },
+ lastModifiedDateTime: {
+ type: 'string',
+ description: 'Get Message last change time (including reactions)',
+ },
+ lastEditedDateTime: { type: 'string', description: 'Get Message last edit time' },
+ deletedDateTime: { type: 'string', description: 'Get Message deletion time' },
+ mentions: {
+ type: 'json',
+ description: 'Get Message mentions: id, mentionText, mentioned (user/application/...)',
+ },
+ reactions: {
+ type: 'json',
+ description:
+ 'Get Message reactions: reactionType, displayName, createdDateTime, userId, userDisplayName',
+ },
type: { type: 'string', description: 'Type of Teams message' },
id: { type: 'string', description: 'Unique message identifier' },
timestamp: { type: 'string', description: 'Message timestamp' },
diff --git a/apps/sim/blocks/blocks/microsoft_word.ts b/apps/sim/blocks/blocks/microsoft_word.ts
index 1d8f9e5600c..b746ca48e5a 100644
--- a/apps/sim/blocks/blocks/microsoft_word.ts
+++ b/apps/sim/blocks/blocks/microsoft_word.ts
@@ -515,7 +515,7 @@ export const MicrosoftWordBlock: BlockConfig = {
metadata: {
type: 'json',
description:
- 'Document metadata (documentId, name, mimeType, webViewLink, size, createdTime, modifiedTime)',
+ 'Document metadata (documentId, name, mimeType, webViewLink, size, createdTime, modifiedTime, driveId, parentFolderId, createdBy {id, displayName}, lastModifiedBy {id, displayName})',
},
updatedContent: {
type: 'boolean',
@@ -524,7 +524,7 @@ export const MicrosoftWordBlock: BlockConfig = {
documents: {
type: 'json',
description:
- 'Word documents that matched a list or search operation ([{documentId, name, mimeType, webViewLink, size, createdTime, modifiedTime}])',
+ 'Word documents that matched a list or search operation ([{documentId, name, mimeType, webViewLink, size, createdTime, modifiedTime, driveId, parentFolderId, createdBy, lastModifiedBy}])',
},
nextPageToken: {
type: 'string',
diff --git a/apps/sim/blocks/blocks/mssql.ts b/apps/sim/blocks/blocks/mssql.ts
index f4074b179c7..7333e3b5b57 100644
--- a/apps/sim/blocks/blocks/mssql.ts
+++ b/apps/sim/blocks/blocks/mssql.ts
@@ -1,7 +1,8 @@
import { getErrorMessage } from '@sim/utils/errors'
import { MicrosoftSqlIcon } from '@/components/icons'
import type { BlockConfig, BlockMeta } from '@/blocks/types'
-import { IntegrationType } from '@/blocks/types'
+import { AuthMode, IntegrationType } from '@/blocks/types'
+import { withSavedCredential } from '@/blocks/utils'
const MSSQL_WAND_PROMPT = `You are an expert Microsoft SQL Server developer. Write T-SQL queries based on the user's request.
@@ -67,6 +68,8 @@ Return ONLY the SQL query - no explanations, no markdown, no extra text.`
export const MSSQLBlock: BlockConfig = {
type: 'mssql',
+ hideFromToolbar: true,
+ sunset: { status: 'legacy', replacedBy: 'mssql_v2' },
name: 'Microsoft SQL Server',
description: 'Connect to Microsoft SQL Server database',
longDescription: 'Query, insert, update, delete, execute raw T-SQL, and introspect schemas.',
@@ -406,6 +409,46 @@ export const MSSQLBlock: BlockConfig = {
},
}
+/**
+ * Microsoft SQL Server on a saved connection: host, port, database, login, password, and
+ * transport-security settings come from the credential, never the block.
+ */
+export const MSSQLV2Block: BlockConfig = {
+ ...MSSQLBlock,
+ type: 'mssql_v2',
+ authMode: AuthMode.OAuth,
+ hideFromToolbar: false,
+ sunset: undefined,
+ canvasPresentation: {
+ defaultTitle: 'Microsoft SQL Server',
+ sentences: {
+ byOperation: {
+ query: [{ text: 'Run SELECT query', field: 'query', core: true }],
+ insert: [
+ { text: 'Insert', field: 'data', core: true },
+ { text: 'into', field: 'table', core: true },
+ ],
+ update: [
+ { text: 'Update rows in', field: 'table', core: true },
+ { text: ', where', field: 'where' },
+ { text: ', setting', field: 'data' },
+ ],
+ delete: [
+ { text: 'Delete rows from', field: 'table', core: true },
+ { text: ', where', field: 'where' },
+ ],
+ execute: [{ text: 'Execute raw T-SQL', field: 'query', core: true }],
+ introspect: ['Read the database schema', { text: ', under', field: 'schema' }],
+ },
+ },
+ },
+ ...withSavedCredential(
+ MSSQLBlock,
+ { id: 'credential', type: 'oauth-input', serviceId: 'mssql', title: 'SQL Server Connection' },
+ { required: true }
+ ),
+}
+
export const MSSQLBlockMeta = {
tags: ['data-analytics'],
url: 'https://www.microsoft.com/en-us/sql-server',
diff --git a/apps/sim/blocks/blocks/mysql.ts b/apps/sim/blocks/blocks/mysql.ts
index 3f9b9205ab8..18ebb35a8e4 100644
--- a/apps/sim/blocks/blocks/mysql.ts
+++ b/apps/sim/blocks/blocks/mysql.ts
@@ -2,10 +2,13 @@ import { ClipboardList, Database, Search, Server, Table, Wrench } from '@sim/emc
import { getErrorMessage } from '@sim/utils/errors'
import { MySQLIcon } from '@/components/icons'
import type { BlockConfig, BlockMeta } from '@/blocks/types'
-import { IntegrationType } from '@/blocks/types'
+import { AuthMode, IntegrationType } from '@/blocks/types'
+import { withSavedCredential } from '@/blocks/utils'
export const MySQLBlock: BlockConfig = {
type: 'mysql',
+ hideFromToolbar: true,
+ sunset: { status: 'legacy', replacedBy: 'mysql_v2' },
name: 'MySQL',
description: 'Connect to MySQL database',
longDescription: 'Query, insert, update, delete, and execute raw SQL.',
@@ -412,6 +415,46 @@ Return ONLY the SQL query - no explanations, no markdown, no extra text.`,
},
}
+/**
+ * MySQL on a saved connection: host, port, database, login, password, and
+ * transport-security settings come from the credential, never the block.
+ */
+export const MySQLV2Block: BlockConfig = {
+ ...MySQLBlock,
+ type: 'mysql_v2',
+ authMode: AuthMode.OAuth,
+ hideFromToolbar: false,
+ sunset: undefined,
+ canvasPresentation: {
+ defaultTitle: 'MySQL',
+ sentences: {
+ byOperation: {
+ query: [{ text: 'Run SELECT query', field: 'query', core: true }],
+ insert: [
+ { text: 'Insert', field: 'data', core: true },
+ { text: 'into', field: 'table', core: true },
+ ],
+ update: [
+ { text: 'Update rows in', field: 'table', core: true },
+ { text: ', where', field: 'where' },
+ { text: ', setting', field: 'data' },
+ ],
+ delete: [
+ { text: 'Delete rows from', field: 'table', core: true },
+ { text: ', where', field: 'where' },
+ ],
+ execute: [{ text: 'Execute raw SQL', field: 'query', core: true }],
+ introspect: ['Read the database schema'],
+ },
+ },
+ },
+ ...withSavedCredential(
+ MySQLBlock,
+ { id: 'credential', type: 'oauth-input', serviceId: 'mysql', title: 'MySQL Connection' },
+ { required: true }
+ ),
+}
+
export const MySQLBlockMeta = {
tags: ['data-analytics'],
url: 'https://www.mysql.com',
diff --git a/apps/sim/blocks/blocks/new_relic.ts b/apps/sim/blocks/blocks/new_relic.ts
index 58503bef61d..18783044c72 100644
--- a/apps/sim/blocks/blocks/new_relic.ts
+++ b/apps/sim/blocks/blocks/new_relic.ts
@@ -4,6 +4,7 @@ import type { BlockConfig, BlockMeta } from '@/blocks/types'
import { AuthMode, IntegrationType } from '@/blocks/types'
import { withSavedCredential } from '@/blocks/utils'
import type { NewRelicCustomAttributes } from '@/tools/new_relic/types'
+import { NEW_RELIC_REGIONS } from '@/tools/new_relic/utils'
function parseCustomAttributes(value: unknown): NewRelicCustomAttributes | undefined {
if (!value) return undefined
@@ -77,10 +78,7 @@ export const NewRelicBlock: BlockConfig = {
id: 'region',
title: 'Region',
type: 'dropdown',
- options: [
- { label: 'US', id: 'us' },
- { label: 'EU', id: 'eu' },
- ],
+ options: NEW_RELIC_REGIONS.options.map(({ value, label }) => ({ id: value, label })),
value: () => 'us',
},
{
diff --git a/apps/sim/blocks/blocks/onedrive.ts b/apps/sim/blocks/blocks/onedrive.ts
index 135ece84d96..0c315112db6 100644
--- a/apps/sim/blocks/blocks/onedrive.ts
+++ b/apps/sim/blocks/blocks/onedrive.ts
@@ -245,6 +245,20 @@ export const OneDriveBlock: BlockConfig = {
mode: 'advanced',
condition: { field: 'operation', value: ['create_file', 'upload'] },
},
+ {
+ id: 'conflictBehavior',
+ title: 'If File Exists',
+ type: 'dropdown',
+ options: [
+ { label: 'Default', id: '' },
+ { label: 'Replace existing file', id: 'replace' },
+ { label: 'Keep both (rename new file)', id: 'rename' },
+ { label: 'Fail', id: 'fail' },
+ ],
+ value: () => '',
+ mode: 'advanced',
+ condition: { field: 'operation', value: ['create_file', 'upload'] },
+ },
{
id: 'folderName',
title: 'Folder Name',
@@ -314,7 +328,15 @@ export const OneDriveBlock: BlockConfig = {
id: 'pageSize',
title: 'Results Per Page',
type: 'short-input',
- placeholder: 'Number of results (default: 100, max: 1000)',
+ placeholder: 'Number of results per page (Graph default: 200)',
+ condition: { field: 'operation', value: ['list', 'search'] },
+ },
+ {
+ id: 'orderBy',
+ title: 'Sort By',
+ type: 'short-input',
+ placeholder: 'e.g., name, lastModifiedDateTime desc',
+ mode: 'advanced',
condition: { field: 'operation', value: ['list', 'search'] },
},
{
@@ -549,6 +571,21 @@ export const OneDriveBlock: BlockConfig = {
placeholder: 'Select who can use the link',
condition: { field: 'operation', value: 'create_share_link' },
},
+ {
+ id: 'expirationDateTime',
+ title: 'Link Expiration',
+ type: 'short-input',
+ placeholder: '2026-12-31T23:59:59Z (leave empty for no expiration)',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'create_share_link' },
+ wandConfig: {
+ enabled: true,
+ prompt:
+ 'Generate an ISO 8601 UTC timestamp in yyyy-MM-ddTHH:mm:ssZ format for when the sharing link should expire, based on the user description. Return ONLY the timestamp string.',
+ placeholder: 'Describe when the link should expire (e.g., "in 7 days")...',
+ generationType: 'timestamp',
+ },
+ },
// Delete File Fields - File Selector (basic mode)
{
id: 'deleteFileSelector',
@@ -712,6 +749,9 @@ export const OneDriveBlock: BlockConfig = {
destinationFolderId: resolvedDestinationFolderId,
pageSize: rest.pageSize ? Number.parseInt(rest.pageSize as string, 10) : undefined,
mimeType: mimeType,
+ conflictBehavior: rest.conflictBehavior || undefined,
+ orderBy: rest.orderBy?.trim() || undefined,
+ expirationDateTime: rest.expirationDateTime?.trim() || undefined,
...(downloadFileName && { fileName: downloadFileName }),
}
},
@@ -726,6 +766,10 @@ export const OneDriveBlock: BlockConfig = {
content: { type: 'string', description: 'Text content to upload' },
mimeType: { type: 'string', description: 'MIME type of file to create' },
values: { type: 'json', description: 'Cell values for new Excel as JSON' },
+ conflictBehavior: {
+ type: 'string',
+ description: 'Name-conflict behavior for upload/create file: replace, rename, or fail',
+ },
// Folder canonical params (per-operation)
uploadFolderId: { type: 'string', description: 'Parent folder for upload/create file' },
createFolderParentId: { type: 'string', description: 'Parent folder for create folder' },
@@ -738,6 +782,7 @@ export const OneDriveBlock: BlockConfig = {
// List / Search operation inputs
query: { type: 'string', description: 'Search query' },
pageSize: { type: 'number', description: 'Results per page' },
+ orderBy: { type: 'string', description: 'OData $orderby expression for list or search' },
pageToken: {
type: 'string',
description: 'Continuation URL for the next page of list or search results',
@@ -757,6 +802,10 @@ export const OneDriveBlock: BlockConfig = {
type: 'string',
description: 'Who can use the link: anonymous, organization, or users',
},
+ expirationDateTime: {
+ type: 'string',
+ description: 'Sharing link expiration (yyyy-MM-ddTHH:mm:ssZ)',
+ },
// Get item operation inputs
getItemFileId: {
type: 'string',
@@ -769,12 +818,13 @@ export const OneDriveBlock: BlockConfig = {
fileId: { type: 'string', description: 'The ID of the deleted file' },
file: {
type: 'file',
- description: 'The OneDrive file object, including details such as id, name, size, and more.',
+ description:
+ 'The OneDrive file object (id, name, mimeType, webViewLink, size, createdTime, modifiedTime, parents, isFolder, childCount, eTag, parentReference {id, driveId, driveType, name, path, siteId}, createdBy {id, displayName}, lastModifiedBy {id, displayName})',
},
files: {
type: 'json',
description:
- 'An array of OneDrive file objects, each containing details such as id, name, size, and more.',
+ 'OneDrive file objects ([{id, name, mimeType, webViewLink, webContentLink, size, createdTime, modifiedTime, parents, isFolder, childCount, description, eTag, parentReference, createdBy, lastModifiedBy}])',
},
nextPageToken: {
type: 'string',
@@ -794,7 +844,8 @@ export const OneDriveBlock: BlockConfig = {
},
link: {
type: 'json',
- description: 'The created sharing link, including its type, scope, and URL',
+ description:
+ 'The created sharing link (type, scope, webUrl, webHtml, permissionId, roles, hasPassword, expirationDateTime)',
},
driveId: { type: 'string', description: 'The ID of the drive' },
driveType: {
diff --git a/apps/sim/blocks/blocks/outlook.test.ts b/apps/sim/blocks/blocks/outlook.test.ts
index e5e4eb8c812..d9e7b52d457 100644
--- a/apps/sim/blocks/blocks/outlook.test.ts
+++ b/apps/sim/blocks/blocks/outlook.test.ts
@@ -54,3 +54,43 @@ describe('OutlookBlock calendar operations', () => {
expect(declined?.sendResponse).toBe(false)
})
})
+
+/**
+ * The executor builds tool params as `{ ...inputs, ...transformedParams }`, so a value stored for
+ * another operation reaches the tool unless the mapper assigns that key explicitly.
+ */
+describe('OutlookBlock values stored for another operation', () => {
+ const mapParams = block.tools.config?.params as (
+ params: Record
+ ) => Record
+
+ it.each(['reply_outlook', 'reply_all_outlook', 'forward_outlook'])(
+ 'clears a Send cc/bcc on %s',
+ (operation) => {
+ const mapped = mapParams({
+ operation,
+ oauthCredential: 'cred-1',
+ messageId: 'msg-1',
+ cc: 'boss@example.com',
+ bcc: 'audit@example.com',
+ })
+ expect(mapped).toHaveProperty('cc', null)
+ expect(mapped).toHaveProperty('bcc', null)
+ }
+ )
+
+ it.each(['reply_outlook', 'reply_all_outlook', 'forward_outlook'])(
+ 'clears an Update Message importance on %s',
+ (operation) => {
+ const mapped = mapParams({ operation, oauthCredential: 'cred-1', importance: 'high' })
+ expect(mapped).toHaveProperty('importance', undefined)
+ }
+ )
+
+ it('clears an Update Message importance on send and draft', () => {
+ for (const operation of ['send_outlook', 'draft_outlook']) {
+ const mapped = mapParams({ operation, oauthCredential: 'cred-1', importance: 'high' })
+ expect(mapped).toHaveProperty('importance', null)
+ }
+ })
+})
diff --git a/apps/sim/blocks/blocks/outlook.ts b/apps/sim/blocks/blocks/outlook.ts
index c08265c1997..2f8a5c7d6a0 100644
--- a/apps/sim/blocks/blocks/outlook.ts
+++ b/apps/sim/blocks/blocks/outlook.ts
@@ -291,6 +291,51 @@ export const OutlookBlock: BlockConfig = {
mode: 'advanced',
required: false,
},
+ {
+ id: 'replyTo',
+ title: 'Reply-To',
+ type: 'short-input',
+ placeholder: 'Addresses replies should go to (comma-separated)',
+ condition: { field: 'operation', value: ['send_outlook', 'draft_outlook'] },
+ mode: 'advanced',
+ required: false,
+ },
+ {
+ id: 'mailImportance',
+ title: 'Importance',
+ type: 'dropdown',
+ options: [
+ { label: 'Default', id: '' },
+ { label: 'Low', id: 'low' },
+ { label: 'Normal', id: 'normal' },
+ { label: 'High', id: 'high' },
+ ],
+ value: () => '',
+ condition: { field: 'operation', value: ['send_outlook', 'draft_outlook'] },
+ mode: 'advanced',
+ required: false,
+ },
+ {
+ id: 'replyCc',
+ title: 'CC',
+ type: 'short-input',
+ placeholder: 'CC recipients (comma-separated)',
+ condition: { field: 'operation', value: ['reply_outlook', 'forward_outlook'] },
+ mode: 'advanced',
+ required: false,
+ },
+ {
+ id: 'replyBcc',
+ title: 'BCC',
+ type: 'short-input',
+ placeholder: 'BCC recipients (comma-separated)',
+ condition: {
+ field: 'operation',
+ value: ['reply_outlook', 'reply_all_outlook', 'forward_outlook'],
+ },
+ mode: 'advanced',
+ required: false,
+ },
{
id: 'folderSelector',
title: 'Folder',
@@ -940,6 +985,9 @@ Return ONLY the comma-separated email list - no explanations, no extra text.`,
calResponseType,
calComment,
calSendResponse,
+ mailImportance,
+ replyCc,
+ replyBcc,
...rest
} = params
@@ -961,6 +1009,17 @@ Return ONLY the comma-separated email list - no explanations, no extra text.`,
rest.folder = effectiveFolder || 'INBOX'
}
+ if (['send_outlook', 'draft_outlook'].includes(rest.operation)) {
+ rest.importance = mailImportance || null
+ }
+
+ // Assign explicitly so a cc/bcc/importance stored for another operation never reaches these tools
+ if (['reply_outlook', 'reply_all_outlook', 'forward_outlook'].includes(rest.operation)) {
+ rest.cc = rest.operation === 'reply_all_outlook' ? null : replyCc || null
+ rest.bcc = replyBcc || null
+ rest.importance = undefined
+ }
+
if (rest.operation === 'search_outlook' && searchQuery) {
rest.query = String(searchQuery).trim()
}
@@ -1123,6 +1182,13 @@ Return ONLY the comma-separated email list - no explanations, no extra text.`,
categories: { type: 'string', description: 'Comma-separated category names' },
flagStatus: { type: 'string', description: 'Follow-up flag status' },
importance: { type: 'string', description: 'Message importance level' },
+ replyTo: { type: 'string', description: 'Reply-To addresses for sent or drafted email' },
+ mailImportance: {
+ type: 'string',
+ description: 'Importance of a sent or drafted email (low, normal, high)',
+ },
+ replyCc: { type: 'string', description: 'CC recipients for a reply or forward' },
+ replyBcc: { type: 'string', description: 'BCC recipients for a reply, reply-all, or forward' },
calendarId: {
type: 'string',
description: 'Calendar to read from or write to (canonical param); blank = default calendar',
@@ -1164,7 +1230,7 @@ Return ONLY the comma-separated email list - no explanations, no extra text.`,
results: {
type: 'json',
description:
- 'Operation results. Calendar operations return the event(s): {id, subject, start, end, isAllDay, location, organizer, attendees, onlineMeeting, webLink, bodyPreview}',
+ 'Operation results. Read and Search return messages: {id, subject, bodyPreview, body, sender, from, toRecipients, ccRecipients, bccRecipients, replyTo, receivedDateTime, sentDateTime, hasAttachments, isRead, importance, internetMessageId, conversationId, parentFolderId, categories, flag, isDraft, webLink}. Calendar operations return the event(s): {id, subject, start, end, isAllDay, location, organizer, attendees, onlineMeeting, webLink, bodyPreview}',
},
status: { type: 'string', description: 'Email send status (sent)' },
timestamp: { type: 'string', description: 'Operation timestamp' },
diff --git a/apps/sim/blocks/blocks/postgresql.ts b/apps/sim/blocks/blocks/postgresql.ts
index 920632732fa..955b69f1a66 100644
--- a/apps/sim/blocks/blocks/postgresql.ts
+++ b/apps/sim/blocks/blocks/postgresql.ts
@@ -1,10 +1,13 @@
import { getErrorMessage } from '@sim/utils/errors'
import { PostgresIcon } from '@/components/icons'
import type { BlockConfig, BlockMeta } from '@/blocks/types'
-import { IntegrationType } from '@/blocks/types'
+import { AuthMode, IntegrationType } from '@/blocks/types'
+import { withSavedCredential } from '@/blocks/utils'
export const PostgreSQLBlock: BlockConfig = {
type: 'postgresql',
+ hideFromToolbar: true,
+ sunset: { status: 'legacy', replacedBy: 'postgresql_v2' },
name: 'PostgreSQL',
description: 'Connect to PostgreSQL database',
longDescription:
@@ -435,6 +438,51 @@ Return ONLY the SQL query - no explanations, no markdown, no extra text.`,
},
}
+/**
+ * PostgreSQL on a saved connection: host, port, database, login, password, and
+ * transport-security settings come from the credential, never the block.
+ */
+export const PostgreSQLV2Block: BlockConfig = {
+ ...PostgreSQLBlock,
+ type: 'postgresql_v2',
+ authMode: AuthMode.OAuth,
+ hideFromToolbar: false,
+ sunset: undefined,
+ canvasPresentation: {
+ defaultTitle: 'PostgreSQL',
+ sentences: {
+ byOperation: {
+ query: [{ text: 'Run SELECT query', field: 'query', core: true }],
+ insert: [
+ { text: 'Insert', field: 'data', core: true },
+ { text: 'into', field: 'table', core: true },
+ ],
+ update: [
+ { text: 'Update rows in', field: 'table', core: true },
+ { text: ', where', field: 'where' },
+ { text: ', setting', field: 'data' },
+ ],
+ delete: [
+ { text: 'Delete rows from', field: 'table', core: true },
+ { text: ', where', field: 'where' },
+ ],
+ execute: [{ text: 'Execute raw SQL', field: 'query', core: true }],
+ introspect: ['Read the database schema', { text: ', under', field: 'schema' }],
+ },
+ },
+ },
+ ...withSavedCredential(
+ PostgreSQLBlock,
+ {
+ id: 'credential',
+ type: 'oauth-input',
+ serviceId: 'postgresql',
+ title: 'PostgreSQL Connection',
+ },
+ { required: true }
+ ),
+}
+
export const PostgreSQLBlockMeta = {
tags: ['data-analytics'],
url: 'https://www.postgresql.org',
diff --git a/apps/sim/blocks/blocks/powerbi.ts b/apps/sim/blocks/blocks/powerbi.ts
index 928ac6a4ade..772887c7c7b 100644
--- a/apps/sim/blocks/blocks/powerbi.ts
+++ b/apps/sim/blocks/blocks/powerbi.ts
@@ -25,7 +25,7 @@ export const PowerBIBlock: BlockConfig = {
type: 'powerbi',
name: 'Power BI',
description: 'Query semantic models and manage reports and refreshes',
- longDescription: `Connect a home-tenant organizational account through the Power BI Microsoft connection to list named workspaces, inspect reports and semantic models, execute DAX queries, request standard refreshes, and read refresh history. Microsoft Graph connections cannot substitute for this delegated OAuth connection. Self-hosted installations use the shared Microsoft app registration and register /api/auth/oauth2/callback/microsoft-powerbi as a Web redirect URI. Workspace listing supports a result limit, offset, and OData filter; reports and semantic models return flat lists. Read-only models may contain only their ID and name, and paginated reports may omit their semantic-model ID. Missing optional fields are null and do not establish permission or feature availability. Keep results small with summaries or TOPN: provider JSON reads are limited to 20 MiB, and workflow tool responses have a stricter 10 MiB inline budget. My Workspace, cross-tenant guest configuration, Credential Groups, service principals, impersonation, enhanced refresh, report exports, dashboards, and triggers are outside this version.`,
+ longDescription: `Connect a home-tenant organizational account through the Power BI Microsoft connection, or run as an app registration with a Microsoft app credential in the global cloud, to list named workspaces, inspect reports and semantic models, execute DAX queries, request standard refreshes, and read refresh history. A Microsoft Graph connection cannot substitute for the Power BI connection or app credential. An app credential needs the Power BI tenant setting that allows service principals to use Power BI APIs and its service principal added to each workspace as a Member or Admin; DAX queries do not run as a service principal against semantic models with row-level security or SSO. Self-hosted installations use the shared Microsoft app registration and register /api/auth/oauth2/callback/microsoft-powerbi as a Web redirect URI. Workspace listing supports a result limit, offset, and OData filter; reports and semantic models return flat lists. Read-only models may contain only their ID and name, and paginated reports may omit their semantic-model ID. Missing optional fields are null and do not establish permission or feature availability. Keep results small with summaries or TOPN: provider JSON reads are limited to 20 MiB, and workflow tool responses have a stricter 10 MiB inline budget. My Workspace, cross-tenant guest configuration, Credential Groups, impersonation, enhanced refresh, report exports, dashboards, and triggers are outside this version.`,
docsLink: 'https://docs.sim.ai/integrations/powerbi',
category: 'tools',
integrationType: IntegrationType.Analytics,
@@ -355,7 +355,8 @@ export const PowerBIBlock: BlockConfig = {
},
reports: {
type: 'json',
- description: 'Reports with ID, name, report type, URLs, and optional semantic model ID',
+ description:
+ 'Reports with ID, name, report type, URLs, optional semantic model ID, format, isOwnedByMe, and app IDs when available',
condition: { field: 'operation', value: 'powerbi_list_reports' },
},
reportCount: {
@@ -365,7 +366,8 @@ export const PowerBIBlock: BlockConfig = {
},
report: {
type: 'json',
- description: 'Report details including ID, name, report type, URLs, and semantic model ID',
+ description:
+ 'Report details including ID, name, report type, URLs, semantic model ID, format, isOwnedByMe, and app IDs when available',
condition: { field: 'operation', value: 'powerbi_get_report' },
},
datasets: {
diff --git a/apps/sim/blocks/blocks/qdrant.ts b/apps/sim/blocks/blocks/qdrant.ts
index 476909faa04..292e8674d9f 100644
--- a/apps/sim/blocks/blocks/qdrant.ts
+++ b/apps/sim/blocks/blocks/qdrant.ts
@@ -1,9 +1,12 @@
import { QdrantIcon } from '@/components/icons'
import type { BlockConfig, BlockMeta } from '@/blocks/types'
import { AuthMode, IntegrationType } from '@/blocks/types'
+import { withSavedCredential } from '@/blocks/utils'
export const QdrantBlock: BlockConfig = {
type: 'qdrant',
+ hideFromToolbar: true,
+ sunset: { status: 'legacy', replacedBy: 'qdrant_v2' },
name: 'Qdrant',
description: 'Use Qdrant vector database',
authMode: AuthMode.ApiKey,
@@ -263,6 +266,21 @@ Return ONLY the JSON object.`,
},
}
+/** Every operation runs with a saved Qdrant API key and the instance URL it was verified against. */
+export const QdrantV2Block: BlockConfig = {
+ ...QdrantBlock,
+ type: 'qdrant_v2',
+ authMode: AuthMode.OAuth,
+ hideFromToolbar: false,
+ sunset: undefined,
+ ...withSavedCredential(
+ QdrantBlock,
+ { id: 'credential', type: 'oauth-input', serviceId: 'qdrant', title: 'Qdrant Credential' },
+ // The URL shows per operation and the key in every one; one credential carries both.
+ { condition: null, required: true }
+ ),
+}
+
export const QdrantBlockMeta = {
tags: ['vector-search', 'knowledge-base'],
url: 'https://qdrant.tech',
diff --git a/apps/sim/blocks/blocks/railway.ts b/apps/sim/blocks/blocks/railway.ts
index deb8c760d6a..3f3d397bc69 100644
--- a/apps/sim/blocks/blocks/railway.ts
+++ b/apps/sim/blocks/blocks/railway.ts
@@ -1,9 +1,12 @@
import { RailwayIcon } from '@/components/icons'
import type { BlockConfig, BlockMeta } from '@/blocks/types'
import { AuthMode, IntegrationType } from '@/blocks/types'
+import { withSavedCredential } from '@/blocks/utils'
export const RailwayBlock: BlockConfig = {
type: 'railway',
+ hideFromToolbar: true,
+ sunset: { status: 'legacy', replacedBy: 'railway_v2' },
name: 'Railway',
description: 'Manage Railway projects, services, deployments, and variables',
longDescription:
@@ -956,6 +959,20 @@ export const RailwayBlock: BlockConfig = {
},
}
+/** Every operation runs with a saved Railway token and its type. */
+export const RailwayV2Block: BlockConfig = {
+ ...RailwayBlock,
+ type: 'railway_v2',
+ authMode: AuthMode.OAuth,
+ hideFromToolbar: false,
+ sunset: undefined,
+ ...withSavedCredential(
+ RailwayBlock,
+ { id: 'credential', type: 'oauth-input', serviceId: 'railway', title: 'Railway Credential' },
+ { required: true }
+ ),
+}
+
export const RailwayBlockMeta = {
tags: ['cloud', 'ci-cd'],
url: 'https://railway.com',
diff --git a/apps/sim/blocks/blocks/rds.ts b/apps/sim/blocks/blocks/rds.ts
index a8b2d199ed8..89799071952 100644
--- a/apps/sim/blocks/blocks/rds.ts
+++ b/apps/sim/blocks/blocks/rds.ts
@@ -268,9 +268,10 @@ Output:
{
"name": "John Doe",
"email": "john@example.com",
- "is_premium": true,
- "created_at": "NOW()"
-}`,
+ "is_premium": true
+}
+
+Values are bound as literal parameters, so SQL expressions such as NOW() are not evaluated: they are stored as text in text columns and fail on other column types. Leave out columns that should take a database default.`,
placeholder: 'Describe the data you want to insert...',
generationType: 'json-object',
},
@@ -298,16 +299,17 @@ Return ONLY a valid JSON object containing the fields to update. Do not include
1. Only include fields that need to be updated
2. Use appropriate data types (strings in quotes, numbers without, booleans as true/false)
3. Use snake_case for field names
-4. Consider including updated_at field if appropriate
+4. Use literal values only; SQL expressions such as NOW() are not evaluated
### EXAMPLE
User: "Update the customer to inactive and clear their subscription"
Output:
{
"is_active": false,
- "subscription_id": null,
- "updated_at": "NOW()"
-}`,
+ "subscription_id": null
+}
+
+Values are bound as literal parameters, so SQL expressions such as NOW() are stored as text.`,
placeholder: 'Describe the fields you want to update...',
generationType: 'json-object',
},
@@ -380,6 +382,26 @@ Return ONLY the JSON object.`,
generationType: 'json-object',
},
},
+ {
+ id: 'parameters',
+ title: 'Query Parameters (JSON)',
+ type: 'code',
+ placeholder: '{\n "status": "active",\n "id": 5\n}',
+ condition: { field: 'operation', value: ['query', 'execute'] },
+ mode: 'advanced',
+ wandConfig: {
+ enabled: true,
+ prompt: `Generate the parameter values for an Amazon RDS Data API SQL statement based on the user's description.
+Each key is a placeholder name used in the SQL as :name, and each value is the value to bind.
+Example: {"status": "active", "id": 5}
+To cast a string to a specific type, use an array of entries with a typeHint of DATE, DECIMAL, JSON, TIME, TIMESTAMP, or UUID:
+[{"name": "created", "value": "2024-01-15 10:00:00", "typeHint": "TIMESTAMP"}]
+
+Return ONLY valid JSON - no explanations, no markdown code blocks.`,
+ placeholder: 'Describe the values for each :placeholder...',
+ generationType: 'json-value',
+ },
+ },
{
id: 'schema',
title: 'Schema Name',
@@ -430,7 +452,7 @@ Return ONLY the JSON object.`,
}
},
params: (params) => {
- const { operation, data, conditions, schema, engine, ...rest } = params
+ const { operation, data, conditions, schema, engine, parameters, ...rest } = params
// Parse JSON fields
const parseJson = (value: unknown, fieldName: string) => {
@@ -469,6 +491,10 @@ Return ONLY the JSON object.`,
if (parsedData !== undefined) result.data = parsedData
if (schema) result.schema = schema
if (engine) result.engine = engine
+ if (operation === 'query' || operation === 'execute') {
+ const parsedParameters = parseJson(parameters, 'parameters')
+ if (parsedParameters !== undefined) result.parameters = parsedParameters
+ }
return result
},
@@ -485,6 +511,10 @@ Return ONLY the JSON object.`,
table: { type: 'string', description: 'Table name' },
query: { type: 'string', description: 'SQL query to execute' },
data: { type: 'json', description: 'Data for insert/update operations' },
+ parameters: {
+ type: 'json',
+ description: 'Values for :name placeholders in query/execute SQL',
+ },
conditions: { type: 'json', description: 'Conditions for update/delete (e.g., {"id": 1})' },
schema: { type: 'string', description: 'Schema to introspect (for introspect operation)' },
engine: {
@@ -503,7 +533,12 @@ Return ONLY the JSON object.`,
},
rowCount: {
type: 'number',
- description: 'Number of rows affected by the operation',
+ description: 'Number of rows returned by a query, or affected by a write',
+ },
+ generatedFields: {
+ type: 'array',
+ description:
+ 'Values generated by an insert or execute, such as auto-increment IDs (Aurora MySQL only)',
},
engine: {
type: 'string',
diff --git a/apps/sim/blocks/blocks/router.ts b/apps/sim/blocks/blocks/router.ts
index 259e6d464b0..a978d261f18 100644
--- a/apps/sim/blocks/blocks/router.ts
+++ b/apps/sim/blocks/blocks/router.ts
@@ -2,6 +2,7 @@ import { ConnectIcon } from '@/components/icons'
import { getModelFallbackSubBlock, MODEL_FALLBACK_INPUTS } from '@/blocks/model-fallbacks'
import { AuthMode, type BlockConfig } from '@/blocks/types'
import {
+ BEDROCK_SAVED_CREDENTIAL_INPUTS,
getModelOptions,
getProviderCredentialSubBlocks,
getSerializedModelProviderId,
@@ -163,7 +164,7 @@ export const RouterBlock: BlockConfig = {
defaultValue: 'claude-sonnet-5-5',
options: getModelOptions,
},
- ...getProviderCredentialSubBlocks(),
+ ...getProviderCredentialSubBlocks({ bedrockSavedCredential: true }),
getModelFallbackSubBlock(),
{
id: 'temperature',
@@ -200,6 +201,7 @@ export const RouterBlock: BlockConfig = {
prompt: { type: 'string', description: 'Routing prompt content' },
model: { type: 'string', description: 'AI model to use' },
...PROVIDER_CREDENTIAL_INPUTS,
+ ...BEDROCK_SAVED_CREDENTIAL_INPUTS,
...MODEL_FALLBACK_INPUTS,
temperature: {
type: 'number',
@@ -254,7 +256,7 @@ export const RouterV2Block: BlockConfig = {
defaultValue: 'claude-sonnet-5-5',
options: getModelOptions,
},
- ...getProviderCredentialSubBlocks(),
+ ...getProviderCredentialSubBlocks({ bedrockSavedCredential: true }),
getModelFallbackSubBlock(),
],
tools: {
@@ -275,6 +277,7 @@ export const RouterV2Block: BlockConfig = {
routes: { type: 'json', description: 'Route definitions with descriptions' },
model: { type: 'string', description: 'AI model to use' },
...PROVIDER_CREDENTIAL_INPUTS,
+ ...BEDROCK_SAVED_CREDENTIAL_INPUTS,
...MODEL_FALLBACK_INPUTS,
},
outputs: {
diff --git a/apps/sim/blocks/blocks/s3.ts b/apps/sim/blocks/blocks/s3.ts
index d6227633fba..7c3bee8f9c5 100644
--- a/apps/sim/blocks/blocks/s3.ts
+++ b/apps/sim/blocks/blocks/s3.ts
@@ -63,6 +63,14 @@ function s3ToolParams(params: BlockToolParams) {
content: params.content,
contentType: params.contentType,
acl: params.acl,
+ metadata: params.objectMetadata,
+ storageClass: params.storageClass,
+ serverSideEncryption: params.serverSideEncryption,
+ sseKmsKeyId:
+ typeof params.serverSideEncryption === 'string' &&
+ params.serverSideEncryption.startsWith('aws:kms')
+ ? params.sseKmsKeyId
+ : undefined,
}
}
@@ -93,6 +101,8 @@ function s3ToolParams(params: BlockToolParams) {
prefix: params.prefix,
maxKeys: params.maxKeys ? Number.parseInt(params.maxKeys as string, 10) : undefined,
continuationToken: params.continuationToken,
+ delimiter: params.delimiter,
+ startAfter: params.startAfter,
}
case 'delete_object':
@@ -463,6 +473,69 @@ export const S3Block: BlockConfig = {
condition: { field: 'operation', value: 'put_object' },
mode: 'advanced',
},
+ {
+ id: 'objectMetadata',
+ title: 'Object Metadata',
+ type: 'code',
+ language: 'json',
+ placeholder: '{"department": "finance", "source": "sim"}',
+ condition: { field: 'operation', value: 'put_object' },
+ mode: 'advanced',
+ wandConfig: {
+ enabled: true,
+ prompt:
+ 'Generate a flat JSON object of S3 user-defined object metadata from the description. Keys are lowercase names without the x-amz-meta- prefix and every value is a string. Return ONLY the JSON object.',
+ placeholder: 'Describe the metadata to attach...',
+ generationType: 'json-object',
+ },
+ },
+ {
+ id: 'storageClass',
+ title: 'Storage Class',
+ type: 'dropdown',
+ options: [
+ { label: 'Bucket default', id: '' },
+ { label: 'Standard', id: 'STANDARD' },
+ { label: 'Intelligent-Tiering', id: 'INTELLIGENT_TIERING' },
+ { label: 'Standard-IA', id: 'STANDARD_IA' },
+ { label: 'One Zone-IA', id: 'ONEZONE_IA' },
+ { label: 'Glacier Instant Retrieval', id: 'GLACIER_IR' },
+ { label: 'Glacier Flexible Retrieval', id: 'GLACIER' },
+ { label: 'Glacier Deep Archive', id: 'DEEP_ARCHIVE' },
+ { label: 'Reduced Redundancy', id: 'REDUCED_REDUNDANCY' },
+ ],
+ value: () => '',
+ placeholder: 'Select storage class (default: Standard)',
+ condition: { field: 'operation', value: 'put_object' },
+ mode: 'advanced',
+ },
+ {
+ id: 'serverSideEncryption',
+ title: 'Server-Side Encryption',
+ type: 'dropdown',
+ options: [
+ { label: 'Bucket default', id: '' },
+ { label: 'SSE-S3 (AES256)', id: 'AES256' },
+ { label: 'SSE-KMS (aws:kms)', id: 'aws:kms' },
+ { label: 'DSSE-KMS (aws:kms:dsse)', id: 'aws:kms:dsse' },
+ ],
+ value: () => '',
+ placeholder: 'Select encryption (default: bucket setting)',
+ condition: { field: 'operation', value: 'put_object' },
+ mode: 'advanced',
+ },
+ {
+ id: 'sseKmsKeyId',
+ title: 'KMS Key ID',
+ type: 'short-input',
+ placeholder: 'KMS key ID or ARN (for aws:kms; default: aws/s3 managed key)',
+ condition: {
+ field: 'operation',
+ value: 'put_object',
+ and: { field: 'serverSideEncryption', value: ['aws:kms', 'aws:kms:dsse'] },
+ },
+ mode: 'advanced',
+ },
{
id: 's3Uri',
@@ -496,6 +569,22 @@ export const S3Block: BlockConfig = {
condition: { field: 'operation', value: 'list_objects' },
mode: 'advanced',
},
+ {
+ id: 'delimiter',
+ title: 'Delimiter',
+ type: 'short-input',
+ placeholder: 'e.g., / to group keys into folders (commonPrefixes)',
+ condition: { field: 'operation', value: 'list_objects' },
+ mode: 'advanced',
+ },
+ {
+ id: 'startAfter',
+ title: 'Start After Key',
+ type: 'short-input',
+ placeholder: 'List keys after this one (e.g., folder/file-100.txt)',
+ condition: { field: 'operation', value: 'list_objects' },
+ mode: 'advanced',
+ },
{
id: 'objectKey',
@@ -719,6 +808,10 @@ export const S3Block: BlockConfig = {
content: { type: 'string', description: 'Text content to upload' },
contentType: { type: 'string', description: 'Content-Type header' },
acl: { type: 'string', description: 'Access control list' },
+ objectMetadata: { type: 'json', description: 'User-defined object metadata (x-amz-meta-*)' },
+ storageClass: { type: 'string', description: 'Storage class for the uploaded object' },
+ serverSideEncryption: { type: 'string', description: 'Server-side encryption algorithm' },
+ sseKmsKeyId: { type: 'string', description: 'KMS key ID for SSE-KMS' },
// Download inputs
s3Uri: { type: 'string', description: 'S3 object URL' },
getObjectRegion: { type: 'string', description: 'Optional AWS region override for downloads' },
@@ -726,6 +819,8 @@ export const S3Block: BlockConfig = {
prefix: { type: 'string', description: 'Prefix filter' },
maxKeys: { type: 'number', description: 'Maximum results' },
continuationToken: { type: 'string', description: 'Pagination token' },
+ delimiter: { type: 'string', description: 'Delimiter that groups keys into common prefixes' },
+ startAfter: { type: 'string', description: 'Key to start listing after' },
// Copy inputs
sourceBucket: { type: 'string', description: 'Source bucket name' },
sourceKey: { type: 'string', description: 'Source object key' },
@@ -750,7 +845,15 @@ export const S3Block: BlockConfig = {
description: 'S3 URI (s3://bucket/key) for use with other AWS services',
},
file: { type: 'file', description: 'Downloaded file stored in execution files' },
- objects: { type: 'json', description: 'List of objects (for list operation)' },
+ objects: {
+ type: 'json',
+ description:
+ 'List of objects (for list operation): [{key, size, lastModified, etag, storageClass}]',
+ },
+ commonPrefixes: {
+ type: 'json',
+ description: 'Folder-like prefixes grouped by the delimiter (for list operation)',
+ },
buckets: { type: 'json', description: 'List of buckets (for list buckets operation)' },
deleted: { type: 'json', description: 'Deletion status (boolean) or deleted objects (array)' },
errors: { type: 'json', description: 'Failed deletions (for batch delete operation)' },
diff --git a/apps/sim/blocks/blocks/secrets_manager.ts b/apps/sim/blocks/blocks/secrets_manager.ts
index ca994d14c9a..c17b40be430 100644
--- a/apps/sim/blocks/blocks/secrets_manager.ts
+++ b/apps/sim/blocks/blocks/secrets_manager.ts
@@ -1,8 +1,19 @@
+import { getErrorMessage } from '@sim/utils/errors'
import { SecretsManagerIcon } from '@/components/icons'
import type { BlockConfig, BlockMeta } from '@/blocks/types'
import { AuthMode, IntegrationType } from '@/blocks/types'
import { withSavedCredential } from '@/blocks/utils'
+/** Parses a JSON field typed into the block, naming the field when it is invalid. */
+function parseJsonField(value: unknown, fieldName: string): unknown {
+ if (typeof value !== 'string') return value
+ try {
+ return JSON.parse(value)
+ } catch (error) {
+ throw new Error(`Invalid JSON in ${fieldName}: ${getErrorMessage(error)}`)
+ }
+}
+
export const SecretsManagerBlock: BlockConfig = {
type: 'secrets_manager',
hideFromToolbar: true,
@@ -154,6 +165,24 @@ export const SecretsManagerBlock: BlockConfig = {
required: false,
mode: 'advanced',
},
+ {
+ id: 'kmsKeyId',
+ title: 'KMS Key ID',
+ type: 'short-input',
+ placeholder: 'alias/my-key (defaults to aws/secretsmanager)',
+ condition: { field: 'operation', value: ['create_secret', 'update_secret'] },
+ required: false,
+ mode: 'advanced',
+ },
+ {
+ id: 'createTags',
+ title: 'Tags',
+ type: 'code',
+ placeholder: '[{"key":"env","value":"prod"}]',
+ condition: { field: 'operation', value: 'create_secret' },
+ required: false,
+ mode: 'advanced',
+ },
{
id: 'versionId',
title: 'Version ID',
@@ -190,6 +219,61 @@ export const SecretsManagerBlock: BlockConfig = {
required: false,
mode: 'advanced',
},
+ {
+ id: 'listFilters',
+ title: 'Filters',
+ type: 'code',
+ placeholder: '[{"key":"name","values":["prod/"]}]',
+ condition: { field: 'operation', value: 'list_secrets' },
+ required: false,
+ mode: 'advanced',
+ wandConfig: {
+ enabled: true,
+ prompt: `Generate an AWS Secrets Manager ListSecrets filters JSON array from the user's description.
+Each item is {"key": , "values": [strings]}. Values match by prefix; prefix a value with ! to negate it.
+
+Return ONLY the JSON array - no explanations, no markdown code blocks.`,
+ placeholder: 'Describe which secrets to list...',
+ },
+ },
+ {
+ id: 'sortBy',
+ title: 'Sort By',
+ type: 'dropdown',
+ options: [
+ { label: 'Default (created date)', id: '' },
+ { label: 'Created Date', id: 'created-date' },
+ { label: 'Last Accessed Date', id: 'last-accessed-date' },
+ { label: 'Last Changed Date', id: 'last-changed-date' },
+ { label: 'Name', id: 'name' },
+ ],
+ value: () => '',
+ condition: { field: 'operation', value: 'list_secrets' },
+ required: false,
+ mode: 'advanced',
+ },
+ {
+ id: 'sortOrder',
+ title: 'Sort Order',
+ type: 'dropdown',
+ options: [
+ { label: 'Default (ascending)', id: '' },
+ { label: 'Ascending', id: 'asc' },
+ { label: 'Descending', id: 'desc' },
+ ],
+ value: () => '',
+ condition: { field: 'operation', value: 'list_secrets' },
+ required: false,
+ mode: 'advanced',
+ },
+ {
+ id: 'includePlannedDeletion',
+ title: 'Include Secrets Scheduled for Deletion',
+ type: 'switch',
+ condition: { field: 'operation', value: 'list_secrets' },
+ required: false,
+ mode: 'advanced',
+ },
{
id: 'recoveryWindowInDays',
title: 'Recovery Window (Days)',
@@ -359,16 +443,27 @@ export const SecretsManagerBlock: BlockConfig = {
if (!Number.isNaN(parsed)) result.maxResults = parsed
}
if (rest.nextToken) result.nextToken = rest.nextToken
+ if (rest.listFilters) {
+ result.filters = parseJsonField(rest.listFilters, 'Filters')
+ }
+ if (rest.sortBy) result.sortBy = rest.sortBy
+ if (rest.sortOrder) result.sortOrder = rest.sortOrder
+ if (rest.includePlannedDeletion === true || rest.includePlannedDeletion === 'true') {
+ result.includePlannedDeletion = true
+ }
break
case 'create_secret':
result.name = rest.name
result.secretValue = rest.secretValue
if (rest.description) result.description = rest.description
+ result.kmsKeyId = rest.kmsKeyId || undefined
+ result.tags = rest.createTags ? parseJsonField(rest.createTags, 'Tags') : undefined
break
case 'update_secret':
result.secretId = rest.secretId
result.secretValue = rest.secretValue
if (rest.description) result.description = rest.description
+ result.kmsKeyId = rest.kmsKeyId || undefined
break
case 'delete_secret':
result.secretId = rest.secretId
@@ -383,12 +478,11 @@ export const SecretsManagerBlock: BlockConfig = {
break
case 'tag_resource':
result.secretId = rest.secretId
- result.tags = typeof rest.tags === 'string' ? JSON.parse(rest.tags) : rest.tags
+ result.tags = parseJsonField(rest.tags, 'Tags')
break
case 'untag_resource':
result.secretId = rest.secretId
- result.tagKeys =
- typeof rest.tagKeys === 'string' ? JSON.parse(rest.tagKeys) : rest.tagKeys
+ result.tagKeys = parseJsonField(rest.tagKeys, 'Tag Keys')
break
case 'restore_secret':
result.secretId = rest.secretId
@@ -428,6 +522,21 @@ export const SecretsManagerBlock: BlockConfig = {
versionStage: { type: 'string', description: 'Version stage (e.g., AWSCURRENT)' },
maxResults: { type: 'number', description: 'Maximum number of results to return' },
nextToken: { type: 'string', description: 'Pagination token' },
+ listFilters: {
+ type: 'json',
+ description: 'ListSecrets filters, as an array of {key, values}',
+ },
+ sortBy: { type: 'string', description: 'Field to sort listed secrets by' },
+ sortOrder: { type: 'string', description: 'Sort order for listed secrets (asc or desc)' },
+ includePlannedDeletion: {
+ type: 'boolean',
+ description: 'Include secrets scheduled for deletion when listing',
+ },
+ kmsKeyId: { type: 'string', description: 'KMS key used to encrypt the secret' },
+ createTags: {
+ type: 'json',
+ description: 'Tags for a new secret, as an array of {key, value} pairs',
+ },
recoveryWindowInDays: { type: 'number', description: 'Days before permanent deletion' },
forceDelete: { type: 'string', description: 'Force immediate deletion' },
tags: { type: 'json', description: 'Tags to attach, as an array of {key, value} pairs' },
@@ -452,6 +561,10 @@ export const SecretsManagerBlock: BlockConfig = {
type: 'string',
description: 'The decrypted secret value',
},
+ secretBinary: {
+ type: 'string',
+ description: 'Base64-encoded decrypted binary secret value, when stored as SecretBinary',
+ },
arn: {
type: 'string',
description: 'ARN of the secret',
@@ -466,7 +579,8 @@ export const SecretsManagerBlock: BlockConfig = {
},
secrets: {
type: 'json',
- description: 'List of secrets',
+ description:
+ 'List of secrets [{name, arn, description, kmsKeyId, rotationEnabled, rotationLambdaARN, rotationRules, owningService, primaryRegion, createdDate, lastChangedDate, lastAccessedDate, lastRotatedDate, nextRotationDate, deletedDate, tags, secretVersionsToStages}]',
},
count: {
type: 'number',
@@ -509,6 +623,18 @@ export const SecretsManagerBlock: BlockConfig = {
type: 'string',
description: 'Date the secret was last rotated',
},
+ lastChangedDate: {
+ type: 'string',
+ description: 'Date the secret was last changed',
+ },
+ lastAccessedDate: {
+ type: 'string',
+ description: 'Date the secret was last accessed',
+ },
+ versionIdsToStages: {
+ type: 'json',
+ description: 'Map of version IDs to their staging labels',
+ },
nextRotationDate: {
type: 'string',
description: 'Date the secret is next scheduled to rotate',
diff --git a/apps/sim/blocks/blocks/ses.ts b/apps/sim/blocks/blocks/ses.ts
index 99cc025b3eb..ed55f6f6a02 100644
--- a/apps/sim/blocks/blocks/ses.ts
+++ b/apps/sim/blocks/blocks/ses.ts
@@ -324,6 +324,46 @@ export const SESBlock: BlockConfig = {
required: false,
mode: 'advanced',
},
+ {
+ id: 'templateReplyToAddresses',
+ title: 'Reply-To Addresses',
+ type: 'short-input',
+ placeholder: 'replyto@example.com',
+ condition: { field: 'operation', value: ['send_templated_email', 'send_bulk_email'] },
+ required: false,
+ mode: 'advanced',
+ },
+ {
+ id: 'fromName',
+ title: 'From Name',
+ type: 'short-input',
+ placeholder: 'Acme Support',
+ condition: {
+ field: 'operation',
+ value: ['send_email', 'send_templated_email', 'send_bulk_email'],
+ },
+ required: false,
+ mode: 'advanced',
+ },
+ {
+ id: 'emailTags',
+ title: 'Email Tags (JSON)',
+ type: 'code',
+ language: 'json',
+ placeholder: '[{"name": "campaign", "value": "welcome"}]',
+ condition: {
+ field: 'operation',
+ value: ['send_email', 'send_templated_email', 'send_bulk_email'],
+ },
+ required: false,
+ mode: 'advanced',
+ wandConfig: {
+ enabled: true,
+ prompt:
+ 'Generate an Amazon SES message tag list as a JSON array of objects, each with a "name" and a "value". Names and values may contain only letters, digits, underscores, and hyphens, up to 256 characters. Return ONLY the JSON array.',
+ generationType: 'json-array',
+ },
+ },
{
id: 'defaultTemplateData',
title: 'Default Template Data (JSON)',
@@ -634,6 +674,28 @@ export const SESBlock: BlockConfig = {
params: (params) => {
const { operation, pageSize, ...rest } = params
+ const parseEmailTags = (value: unknown) => {
+ if (value === undefined || value === null || value === '') return undefined
+ if (typeof value !== 'string') return value
+ if (!value.trim()) return undefined
+ let parsed: unknown
+ try {
+ parsed = JSON.parse(value)
+ } catch {
+ throw new Error('Email tags must be a valid JSON array of {"name", "value"} objects')
+ }
+ if (!Array.isArray(parsed)) {
+ throw new Error('Email tags must be a valid JSON array of {"name", "value"} objects')
+ }
+ return parsed
+ }
+
+ const applySendOptions = (target: Record, replyToAddresses: unknown) => {
+ target.fromName = rest.fromName || undefined
+ target.replyToAddresses = replyToAddresses || undefined
+ target.emailTags = parseEmailTags(rest.emailTags)
+ }
+
const connectionConfig = {
region: rest.region,
accessKeyId: rest.accessKeyId,
@@ -705,8 +767,8 @@ export const SESBlock: BlockConfig = {
if (rest.bodyText) result.bodyText = rest.bodyText
if (rest.ccAddresses) result.ccAddresses = rest.ccAddresses
if (rest.bccAddresses) result.bccAddresses = rest.bccAddresses
- if (rest.replyToAddresses) result.replyToAddresses = rest.replyToAddresses
if (rest.configurationSetName) result.configurationSetName = rest.configurationSetName
+ applySendOptions(result, rest.replyToAddresses)
break
case 'send_templated_email':
result.fromAddress = rest.fromAddress
@@ -716,6 +778,7 @@ export const SESBlock: BlockConfig = {
if (rest.ccAddresses) result.ccAddresses = rest.ccAddresses
if (rest.bccAddresses) result.bccAddresses = rest.bccAddresses
if (rest.configurationSetName) result.configurationSetName = rest.configurationSetName
+ applySendOptions(result, rest.templateReplyToAddresses)
break
case 'send_bulk_email':
result.fromAddress = rest.fromAddress
@@ -723,6 +786,7 @@ export const SESBlock: BlockConfig = {
result.destinations = rest.destinations
if (rest.defaultTemplateData) result.defaultTemplateData = rest.defaultTemplateData
if (rest.configurationSetName) result.configurationSetName = rest.configurationSetName
+ applySendOptions(result, rest.templateReplyToAddresses)
break
case 'list_identities':
if (pageSize != null) {
@@ -783,6 +847,15 @@ export const SESBlock: BlockConfig = {
ccAddresses: { type: 'string', description: 'Comma-separated CC email addresses' },
bccAddresses: { type: 'string', description: 'Comma-separated BCC email addresses' },
replyToAddresses: { type: 'string', description: 'Comma-separated reply-to addresses' },
+ templateReplyToAddresses: {
+ type: 'string',
+ description: 'Comma-separated reply-to addresses for templated and bulk sends',
+ },
+ fromName: { type: 'string', description: 'Friendly sender name shown with the From address' },
+ emailTags: {
+ type: 'json',
+ description: 'Message tags as a JSON array of {name, value} objects',
+ },
defaultTemplateData: {
type: 'string',
description: 'Default JSON template data for bulk sends',
@@ -949,6 +1022,17 @@ export const SESBlock: BlockConfig = {
description: 'Emails sent in the last 24 hours (get_account)',
condition: { field: 'operation', value: 'get_account' },
},
+ productionAccessEnabled: {
+ type: 'boolean',
+ description:
+ 'Whether the account has production access, false in the SES sandbox (get_account)',
+ condition: { field: 'operation', value: 'get_account' },
+ },
+ enforcementStatus: {
+ type: 'string',
+ description: 'Account sending status: HEALTHY, PROBATION, or SHUTDOWN (get_account)',
+ condition: { field: 'operation', value: 'get_account' },
+ },
templateName: {
type: 'string',
description: 'Template name (get_template)',
diff --git a/apps/sim/blocks/blocks/sharepoint.ts b/apps/sim/blocks/blocks/sharepoint.ts
index 55c215fe308..7769a061467 100644
--- a/apps/sim/blocks/blocks/sharepoint.ts
+++ b/apps/sim/blocks/blocks/sharepoint.ts
@@ -1114,6 +1114,14 @@ export const SharepointV2Block: BlockConfig = {
condition: { field: 'operation', value: 'sharepoint_list_sites' },
mode: 'advanced',
},
+ {
+ id: 'searchQuery',
+ title: 'Site Search',
+ type: 'short-input',
+ placeholder: 'Keywords to search sites (ignored when Group ID or a site is set)',
+ condition: { field: 'operation', value: 'sharepoint_list_sites' },
+ mode: 'advanced',
+ },
{
id: 'listSelector',
title: 'List',
@@ -1187,6 +1195,64 @@ export const SharepointV2Block: BlockConfig = {
condition: { field: 'operation', value: 'sharepoint_get_list' },
mode: 'advanced',
},
+ {
+ id: 'filter',
+ title: 'Item Filter',
+ type: 'short-input',
+ placeholder: "OData filter, e.g. fields/Status eq 'Active'",
+ condition: {
+ field: 'operation',
+ value: 'sharepoint_get_list',
+ and: { field: 'includeColumns', value: 'true', not: true },
+ },
+ mode: 'advanced',
+ wandConfig: {
+ enabled: true,
+ prompt: `Generate a Microsoft Graph OData $filter expression for SharePoint list items based on the user's description.
+
+Reference list columns as fields/. Supported operators: eq, ne, lt, gt, le, ge, and startswith(). Quote string values with single quotes.
+
+Examples:
+- fields/Status eq 'Active'
+- fields/Quantity lt 600
+- startswith(fields/Title, 'Q3')
+
+Return ONLY the filter expression - no explanations, no quotes around the whole expression, no extra text.`,
+ placeholder: 'Describe which items to return...',
+ },
+ },
+ {
+ id: 'top',
+ title: 'Items Per Page',
+ type: 'short-input',
+ placeholder: 'Maximum items per page (1-5000)',
+ condition: {
+ field: 'operation',
+ value: 'sharepoint_get_list',
+ and: { field: 'includeColumns', value: 'true', not: true },
+ },
+ mode: 'advanced',
+ },
+ {
+ id: 'selectFields',
+ title: 'Fields to Return',
+ type: 'short-input',
+ placeholder: 'Comma-separated column names, e.g. Title,Status,DueDate',
+ condition: {
+ field: 'operation',
+ value: 'sharepoint_get_list',
+ and: { field: 'includeColumns', value: 'true', not: true },
+ },
+ mode: 'advanced',
+ },
+ {
+ id: 'itemSelectFields',
+ title: 'Fields to Return',
+ type: 'short-input',
+ placeholder: 'Comma-separated column names, e.g. Title,Status,DueDate',
+ condition: { field: 'operation', value: 'sharepoint_get_list_item' },
+ mode: 'advanced',
+ },
{
id: 'listDisplayName',
title: 'List Display Name',
@@ -1375,6 +1441,7 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
maxPages,
driveId,
driveItemId,
+ top,
...rest
} = params
@@ -1411,7 +1478,18 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
includeItems: coerceBoolean(includeItems),
listItemFields: parseJsonObject(listItemFields),
maxPages: maxPages ? Number.parseInt(String(maxPages), 10) : undefined,
+ top: cleanString(top) ? Number(cleanString(top)) : undefined,
+ }
+
+ if (result.includeColumns === true && rest.operation === 'sharepoint_get_list') {
+ result.filter = undefined
+ result.top = undefined
+ result.selectFields = undefined
+ }
+ if (rest.operation === 'sharepoint_get_list_item') {
+ result.selectFields = cleanString(result.itemSelectFields)
}
+ result.itemSelectFields = undefined
if (columnDefinitions && rest.operation === 'sharepoint_create_list') {
result.pageContent = columnDefinitions
@@ -1435,7 +1513,15 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
maxPages: { type: 'number', description: 'Maximum pages to return' },
nextPageUrl: { type: 'string', description: 'Microsoft Graph @odata.nextLink URL' },
groupId: { type: 'string', description: 'Microsoft 365 group ID' },
+ searchQuery: { type: 'string', description: 'Keywords to search sites by' },
listId: { type: 'string', description: 'List ID' },
+ filter: { type: 'string', description: 'OData $filter for list items' },
+ top: { type: 'number', description: 'Maximum list items per page' },
+ selectFields: { type: 'string', description: 'Comma-separated item field names to return' },
+ itemSelectFields: {
+ type: 'string',
+ description: 'Comma-separated field names to return (get list item)',
+ },
includeColumns: { type: 'boolean', description: 'Include columns in response' },
includeItems: { type: 'boolean', description: 'Include items in response' },
listDisplayName: { type: 'string', description: 'List display name' },
@@ -1464,7 +1550,8 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
},
page: {
type: 'json',
- description: 'SharePoint page object (id, name, title, webUrl, pageLayout, description)',
+ description:
+ 'SharePoint page object (id, name, title, webUrl, pageLayout, description, publishingState, createdBy, lastModifiedBy)',
},
pages: {
type: 'json',
@@ -1484,12 +1571,21 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
type: 'json',
description: 'Array of SharePoint lists (id, displayName, name, webUrl, list)',
},
- item: { type: 'json', description: 'SharePoint list item with fields' },
- items: { type: 'json', description: 'Array of SharePoint list items with fields' },
+ item: {
+ type: 'json',
+ description:
+ 'SharePoint list item (id, fields, webUrl, createdDateTime, lastModifiedDateTime, createdBy, lastModifiedBy)',
+ },
+ items: {
+ type: 'json',
+ description:
+ 'Array of SharePoint list items (id, fields, webUrl, createdDateTime, lastModifiedDateTime, createdBy, lastModifiedBy)',
+ },
deleted: { type: 'boolean', description: 'Whether the item/page/file was deleted' },
driveItem: {
type: 'json',
- description: 'SharePoint drive item metadata (id, name, size, webUrl, file/folder facet)',
+ description:
+ 'SharePoint drive item metadata (id, name, size, webUrl, file/folder facet, parentReference, createdBy, lastModifiedBy)',
},
file: {
type: 'json',
diff --git a/apps/sim/blocks/blocks/splunk.ts b/apps/sim/blocks/blocks/splunk.ts
index 7019008f421..0c8a45511ff 100644
--- a/apps/sim/blocks/blocks/splunk.ts
+++ b/apps/sim/blocks/blocks/splunk.ts
@@ -1,6 +1,7 @@
import { SplunkIcon } from '@/components/icons'
import type { BlockConfig, BlockMeta } from '@/blocks/types'
import { AuthMode, IntegrationType } from '@/blocks/types'
+import { withSavedCredential } from '@/blocks/utils'
/**
* Normalize a Splunk toggle. A dropdown supplies the strings `'true'`/`'false'`,
@@ -41,6 +42,8 @@ function assignSplunkNumber(target: Record, key: string, value:
export const SplunkBlock: BlockConfig = {
type: 'splunk',
+ hideFromToolbar: true,
+ sunset: { status: 'legacy', replacedBy: 'splunk_v2' },
name: 'Splunk',
description: 'Run SPL searches and manage saved searches and alerts in Splunk',
authMode: AuthMode.ApiKey,
@@ -685,6 +688,20 @@ Examples:
},
}
+/** Every operation runs with a saved Splunk authentication token and its management URL. */
+export const SplunkV2Block: BlockConfig = {
+ ...SplunkBlock,
+ type: 'splunk_v2',
+ authMode: AuthMode.OAuth,
+ hideFromToolbar: false,
+ sunset: undefined,
+ ...withSavedCredential(
+ SplunkBlock,
+ { id: 'credential', type: 'oauth-input', serviceId: 'splunk', title: 'Splunk Credential' },
+ { required: true }
+ ),
+}
+
export const SplunkBlockMeta = {
tags: ['monitoring', 'data-analytics'],
url: 'https://www.splunk.com',
diff --git a/apps/sim/blocks/blocks/sts.ts b/apps/sim/blocks/blocks/sts.ts
index 415051e257b..d520fbe56eb 100644
--- a/apps/sim/blocks/blocks/sts.ts
+++ b/apps/sim/blocks/blocks/sts.ts
@@ -222,6 +222,15 @@ export const STSBlock: BlockConfig = {
required: false,
mode: 'advanced',
},
+ {
+ id: 'sourceIdentity',
+ title: 'Source Identity',
+ type: 'short-input',
+ placeholder: 'alice@example.com',
+ condition: { field: 'operation', value: 'assume_role' },
+ required: false,
+ mode: 'advanced',
+ },
{
id: 'externalId',
title: 'External ID',
@@ -308,6 +317,7 @@ export const STSBlock: BlockConfig = {
if (rest.tokenCode) result.tokenCode = rest.tokenCode
if (rest.policyArns) result.policyArns = rest.policyArns
if (rest.transitiveTagKeys) result.transitiveTagKeys = rest.transitiveTagKeys
+ result.sourceIdentity = rest.sourceIdentity || undefined
if (rest.tags) {
const rows = rest.tags
if (typeof rows === 'string') {
@@ -394,6 +404,10 @@ export const STSBlock: BlockConfig = {
description: 'Comma-separated tag keys that propagate through role chaining',
},
externalId: { type: 'string', description: 'External ID for cross-account access' },
+ sourceIdentity: {
+ type: 'string',
+ description: 'Source identity to set on the assumed role session',
+ },
serialNumber: { type: 'string', description: 'MFA device serial number' },
tokenCode: { type: 'string', description: 'MFA token code' },
targetAccessKeyId: { type: 'string', description: 'Access key ID to look up' },
diff --git a/apps/sim/blocks/blocks/textract.ts b/apps/sim/blocks/blocks/textract.ts
index 7cfea118646..9fc0c6a3ec2 100644
--- a/apps/sim/blocks/blocks/textract.ts
+++ b/apps/sim/blocks/blocks/textract.ts
@@ -1,3 +1,4 @@
+import { isRecordLike } from '@sim/utils/object'
import { TextractIcon } from '@/components/icons'
import {
AuthMode,
@@ -240,6 +241,72 @@ function resolveDocumentParam(value: unknown): { file?: object; filePath?: strin
return {}
}
+interface TextractQueryParam {
+ Text: string
+ Alias?: string
+ Pages?: string[]
+}
+
+/** Whether text is a JSON object, which is never a valid query list or question. */
+function parsesAsJsonObject(text: string): boolean {
+ if (!text.startsWith('{')) return false
+ try {
+ const parsed: unknown = JSON.parse(text)
+ return isRecordLike(parsed)
+ } catch {
+ return false
+ }
+}
+
+/**
+ * Accepts a JSON array of query strings or `{Text, Alias, Pages}` objects, or
+ * one plain-text question per line.
+ */
+function parseTextractQueries(value: unknown): TextractQueryParam[] {
+ if (value === undefined || value === null) return []
+ let raw: unknown = value
+ if (typeof value === 'string') {
+ const trimmed = value.trim()
+ if (!trimmed) return []
+ if (trimmed.startsWith('[')) {
+ try {
+ raw = JSON.parse(trimmed)
+ } catch {
+ throw new Error('Queries must be a JSON array or one question per line')
+ }
+ } else {
+ if (parsesAsJsonObject(trimmed)) {
+ throw new Error('Queries must be a JSON array or one question per line')
+ }
+ raw = trimmed.split('\n')
+ }
+ }
+ if (!Array.isArray(raw)) {
+ throw new Error('Queries must be a JSON array or one question per line')
+ }
+ return raw.flatMap((entry): TextractQueryParam[] => {
+ if (typeof entry === 'string') {
+ const text = entry.trim()
+ return text ? [{ Text: text }] : []
+ }
+ const record =
+ typeof entry === 'object' && entry !== null ? (entry as Record) : {}
+ const text = typeof record.Text === 'string' ? record.Text.trim() : ''
+ if (!text) {
+ throw new Error('Each query must be a question string or an object with a non-empty "Text"')
+ }
+ return [
+ {
+ Text: text,
+ ...(typeof record.Alias === 'string' && record.Alias.trim()
+ ? { Alias: record.Alias.trim() }
+ : {}),
+ ...(Array.isArray(record.Pages) ? { Pages: record.Pages.map(String) } : {}),
+ },
+ ]
+ })
+}
+
interface AwsAccessKeys {
accessKeyId: string
secretAccessKey: string
@@ -314,6 +381,11 @@ function textractToolParams(params: BlockToolParams, accessKeys: Partial 0) {
+ featureTypes.push('QUERIES')
+ parameters.queries = queries
+ }
if (featureTypes.length > 0) parameters.featureTypes = featureTypes
return parameters
@@ -475,6 +547,15 @@ export const TextractV2Block: BlockConfig = {
type: 'switch' as SubBlockType,
condition: { field: 'operation', value: 'analyze_document' },
},
+ {
+ id: 'queries',
+ title: 'Queries',
+ type: 'long-input' as SubBlockType,
+ placeholder:
+ 'One question per line (e.g., What is the policy number?), or a JSON array of {"Text", "Alias", "Pages"}',
+ condition: { field: 'operation', value: 'analyze_document' },
+ mode: 'advanced' as const,
+ },
],
tools: {
access: ['textract_parser_v2', 'textract_analyze_expense', 'textract_analyze_id'],
@@ -504,6 +585,10 @@ export const TextractV2Block: BlockConfig = {
extractForms: { type: 'boolean', description: 'Extract form key-value pairs' },
detectSignatures: { type: 'boolean', description: 'Detect signatures' },
analyzeLayout: { type: 'boolean', description: 'Analyze document layout' },
+ queries: {
+ type: 'string',
+ description: 'Natural-language queries (one per line or a JSON array), for analyze_document',
+ },
region: { type: 'string', description: 'AWS region' },
accessKeyId: { type: 'string', description: 'AWS Access Key ID' },
secretAccessKey: { type: 'string', description: 'AWS Secret Access Key' },
@@ -511,7 +596,8 @@ export const TextractV2Block: BlockConfig = {
outputs: {
blocks: {
type: 'json',
- description: 'Array of detected blocks (PAGE, LINE, WORD, TABLE, CELL, KEY_VALUE_SET, etc.)',
+ description:
+ 'Array of detected blocks (PAGE, LINE, WORD, TABLE, CELL, KEY_VALUE_SET, QUERY, QUERY_RESULT, etc.)',
condition: { field: 'operation', value: 'analyze_document' },
},
expenseDocuments: {
@@ -534,6 +620,18 @@ export const TextractV2Block: BlockConfig = {
type: 'string',
description: 'Version of the Textract model used for processing',
},
+ jobStatus: {
+ type: 'string',
+ description: 'Async job status: SUCCEEDED or PARTIAL_SUCCESS (multi-page mode only)',
+ },
+ statusMessage: {
+ type: 'string',
+ description: 'Why an async job only partially succeeded (multi-page mode only)',
+ },
+ warnings: {
+ type: 'json',
+ description: 'Async job warnings: [{errorCode, pages}] (multi-page mode only)',
+ },
},
}
diff --git a/apps/sim/blocks/blocks/translate.test.ts b/apps/sim/blocks/blocks/translate.test.ts
new file mode 100644
index 00000000000..598dbcc5663
--- /dev/null
+++ b/apps/sim/blocks/blocks/translate.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from 'vitest'
+import { TranslateBlock } from '@/blocks/blocks/translate'
+
+const toolParams = (params: Record) => {
+ const build = TranslateBlock.tools.config?.params
+ if (!build) throw new Error('TranslateBlock.tools.config.params is missing')
+ return build({ context: 'Hello', targetLanguage: 'French', ...params })
+}
+
+describe('Translate Bedrock credential', () => {
+ it('sends the saved AWS credential with a Bedrock model call', () => {
+ expect(
+ toolParams({
+ model: 'bedrock/my-inference-profile',
+ bedrockAuthMode: 'credential',
+ bedrockCredential: 'aws-cred',
+ })
+ ).toMatchObject({ model: 'bedrock/my-inference-profile', bedrockCredential: 'aws-cred' })
+ })
+
+ it('refuses a Bedrock model call set to a saved AWS credential with none selected', () => {
+ expect(() =>
+ toolParams({
+ model: 'bedrock/my-inference-profile',
+ bedrockAuthMode: 'credential',
+ bedrockCredential: '',
+ })
+ ).toThrow('Select an AWS credential for Bedrock')
+ })
+
+ it('ignores a leftover Bedrock credential choice once the model is not on Bedrock', () => {
+ const params = toolParams({ model: 'gpt-4o', bedrockAuthMode: 'credential' })
+
+ expect(params).toMatchObject({ model: 'gpt-4o' })
+ expect(params.bedrockCredential).toBeUndefined()
+ })
+})
diff --git a/apps/sim/blocks/blocks/translate.ts b/apps/sim/blocks/blocks/translate.ts
index edd95be15cc..11ed90308b6 100644
--- a/apps/sim/blocks/blocks/translate.ts
+++ b/apps/sim/blocks/blocks/translate.ts
@@ -1,10 +1,13 @@
import { TranslateIcon } from '@/components/icons'
import { AuthMode, type BlockConfig, IntegrationType } from '@/blocks/types'
import {
+ BEDROCK_SAVED_CREDENTIAL_INPUTS,
getModelOptions,
getProviderCredentialSubBlocks,
+ getSelectedBedrockCredential,
PROVIDER_CREDENTIAL_INPUTS,
} from '@/blocks/utils'
+import { findProviderFromModel } from '@/providers/models'
const getTranslationPrompt = (targetLanguage: string) =>
`Translate the following text into ${targetLanguage || 'English'}. Output ONLY the translated text with no additional commentary, explanations, or notes.`
@@ -52,7 +55,7 @@ export const TranslateBlock: BlockConfig = {
required: true,
options: getModelOptions,
},
- ...getProviderCredentialSubBlocks(),
+ ...getProviderCredentialSubBlocks({ bedrockSavedCredential: true }),
{
id: 'systemPrompt',
title: 'System Prompt',
@@ -80,6 +83,11 @@ export const TranslateBlock: BlockConfig = {
bedrockAccessKeyId: params.bedrockAccessKeyId,
bedrockSecretKey: params.bedrockSecretKey,
bedrockRegion: params.bedrockRegion,
+ bedrockCredential:
+ typeof params.model === 'string' &&
+ findProviderFromModel(params.model.trim()) === 'bedrock'
+ ? getSelectedBedrockCredential(params)
+ : undefined,
}),
},
},
@@ -87,6 +95,7 @@ export const TranslateBlock: BlockConfig = {
context: { type: 'string', description: 'Text to translate' },
targetLanguage: { type: 'string', description: 'Target language' },
...PROVIDER_CREDENTIAL_INPUTS,
+ ...BEDROCK_SAVED_CREDENTIAL_INPUTS,
systemPrompt: { type: 'string', description: 'Translation instructions' },
},
outputs: {
diff --git a/apps/sim/blocks/blocks/trello.ts b/apps/sim/blocks/blocks/trello.ts
index 9a69478c0cb..2e1d4598243 100644
--- a/apps/sim/blocks/blocks/trello.ts
+++ b/apps/sim/blocks/blocks/trello.ts
@@ -495,6 +495,159 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`,
value: 'trello_update_card',
},
},
+ {
+ id: 'start',
+ title: 'Start Date',
+ type: 'short-input',
+ placeholder: 'YYYY-MM-DD or ISO 8601 timestamp',
+ mode: 'advanced',
+ condition: {
+ field: 'operation',
+ value: ['trello_create_card', 'trello_update_card'],
+ },
+ wandConfig: {
+ enabled: true,
+ prompt: `Generate a date or timestamp based on the user's description.
+The timestamp should be in ISO 8601 format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ.
+Examples:
+- "today" -> Calculate today's date in YYYY-MM-DD format
+- "next Monday" -> Calculate the next Monday in YYYY-MM-DD format
+
+Return ONLY the date/timestamp string - no explanations, no extra text.`,
+ placeholder: 'Describe the start date (e.g. "next Monday")...',
+ generationType: 'timestamp',
+ },
+ },
+ {
+ id: 'updatePos',
+ title: 'Position',
+ type: 'short-input',
+ placeholder: 'top, bottom, or a positive float',
+ mode: 'advanced',
+ condition: {
+ field: 'operation',
+ value: 'trello_update_card',
+ },
+ },
+ {
+ id: 'updateLabelIds',
+ title: 'Replace Label IDs',
+ type: 'short-input',
+ placeholder: 'Comma-separated label IDs (replaces existing labels)',
+ mode: 'advanced',
+ condition: {
+ field: 'operation',
+ value: 'trello_update_card',
+ },
+ wandConfig: {
+ enabled: true,
+ prompt:
+ 'Generate a comma-separated list of Trello label IDs. Return ONLY the comma-separated values - no explanations, no extra text.',
+ placeholder: 'Describe the label IDs to set...',
+ },
+ },
+ {
+ id: 'updateMemberIds',
+ title: 'Replace Member IDs',
+ type: 'short-input',
+ placeholder: 'Comma-separated member IDs (replaces existing members)',
+ mode: 'advanced',
+ condition: {
+ field: 'operation',
+ value: 'trello_update_card',
+ },
+ wandConfig: {
+ enabled: true,
+ prompt:
+ 'Generate a comma-separated list of Trello member IDs. Return ONLY the comma-separated values - no explanations, no extra text.',
+ placeholder: 'Describe the member IDs to set...',
+ },
+ },
+ {
+ id: 'moveCardToBoardId',
+ title: 'Move to Board ID',
+ type: 'short-input',
+ placeholder: 'Board ID to move the card to (set Move to List ID too)',
+ mode: 'advanced',
+ condition: {
+ field: 'operation',
+ value: 'trello_update_card',
+ },
+ },
+ {
+ id: 'cardSince',
+ title: 'Cards Since',
+ type: 'short-input',
+ placeholder: 'ISO 8601 timestamp or card ID',
+ mode: 'advanced',
+ condition: {
+ field: 'operation',
+ value: 'trello_list_cards',
+ },
+ wandConfig: {
+ enabled: true,
+ prompt: `Generate a date or timestamp based on the user's description.
+The timestamp should be in ISO 8601 format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ.
+Examples:
+- "1 week ago" -> Calculate the date 1 week ago in YYYY-MM-DD format
+
+Return ONLY the date/timestamp string - no explanations, no extra text.`,
+ placeholder: 'Describe the start of the range (e.g. "1 week ago")...',
+ generationType: 'timestamp',
+ },
+ },
+ {
+ id: 'cardBefore',
+ title: 'Cards Before',
+ type: 'short-input',
+ placeholder: 'ISO 8601 timestamp or card ID',
+ mode: 'advanced',
+ condition: {
+ field: 'operation',
+ value: 'trello_list_cards',
+ },
+ wandConfig: {
+ enabled: true,
+ prompt: `Generate a date or timestamp based on the user's description.
+The timestamp should be in ISO 8601 format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ.
+Examples:
+- "today" -> Calculate today's date in YYYY-MM-DD format
+
+Return ONLY the date/timestamp string - no explanations, no extra text.`,
+ placeholder: 'Describe the end of the range (e.g. "today")...',
+ generationType: 'timestamp',
+ },
+ },
+ {
+ id: 'includeChecklists',
+ title: 'Include Checklists',
+ type: 'switch',
+ mode: 'advanced',
+ condition: {
+ field: 'operation',
+ value: 'trello_get_card',
+ },
+ },
+ {
+ id: 'includeAttachments',
+ title: 'Include Attachments',
+ type: 'switch',
+ mode: 'advanced',
+ condition: {
+ field: 'operation',
+ value: 'trello_get_card',
+ },
+ },
+ {
+ id: 'includeMembers',
+ title: 'Include Members',
+ type: 'switch',
+ mode: 'advanced',
+ condition: {
+ field: 'operation',
+ value: 'trello_get_card',
+ },
+ },
{
id: 'filter',
title: 'Action Filter',
@@ -862,6 +1015,38 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`,
value: 'trello_search',
},
},
+ {
+ id: 'searchCardsPage',
+ title: 'Card Results Page',
+ type: 'short-input',
+ placeholder: 'Page of card results (0-100, default 0)',
+ mode: 'advanced',
+ condition: {
+ field: 'operation',
+ value: 'trello_search',
+ },
+ },
+ {
+ id: 'searchBoardsLimit',
+ title: 'Board Result Limit',
+ type: 'short-input',
+ placeholder: 'Maximum number of boards to return (1-1000, default 10)',
+ mode: 'advanced',
+ condition: {
+ field: 'operation',
+ value: 'trello_search',
+ },
+ },
+ {
+ id: 'searchPartial',
+ title: 'Partial Word Match',
+ type: 'switch',
+ mode: 'advanced',
+ condition: {
+ field: 'operation',
+ value: ['trello_search'],
+ },
+ },
],
tools: {
access: [
@@ -927,6 +1112,8 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`,
boardId,
listId,
filter: getTrimmedString(params.cardFilter),
+ since: getTrimmedString(params.cardSince),
+ before: getTrimmedString(params.cardBefore),
}
}
@@ -943,6 +1130,9 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`,
idBoards: parseStringArray(params.searchBoardIds),
modelTypes: getTrimmedString(params.searchModelTypes),
cardsLimit: parseOptionalNumberInput(params.searchCardsLimit, 'cardsLimit'),
+ cardsPage: parseOptionalNumberInput(params.searchCardsPage, 'cardsPage'),
+ boardsLimit: parseOptionalNumberInput(params.searchBoardsLimit, 'boardsLimit'),
+ partial: parseOptionalBooleanInput(params.searchPartial),
}
}
@@ -966,6 +1156,7 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`,
pos: getTrimmedString(params.pos),
due: getTrimmedString(params.due),
dueComplete: parseOptionalBooleanInput(params.dueComplete),
+ start: getTrimmedString(params.start),
labelIds: parseStringArray(params.labelIds),
memberIds: parseStringArray(params.memberIds),
}
@@ -987,6 +1178,11 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`,
idList: getTrimmedString(params.idList),
due: getTrimmedString(params.due),
dueComplete: parseOptionalBooleanInput(params.dueComplete),
+ start: getTrimmedString(params.start),
+ pos: getTrimmedString(params.updatePos),
+ idBoard: getTrimmedString(params.moveCardToBoardId),
+ labelIds: parseStringArray(params.updateLabelIds),
+ memberIds: parseStringArray(params.updateMemberIds),
}
}
@@ -1122,6 +1318,9 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`,
return {
...baseParams,
cardId,
+ includeChecklists: parseOptionalBooleanInput(params.includeChecklists),
+ includeAttachments: parseOptionalBooleanInput(params.includeAttachments),
+ includeMembers: parseOptionalBooleanInput(params.includeMembers),
}
}
@@ -1358,6 +1557,38 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`,
description: 'Board IDs to restrict the search to, as an array or comma-separated string',
},
searchCardsLimit: { type: 'number', description: 'Maximum number of cards to return' },
+ searchCardsPage: { type: 'number', description: 'Page of card search results (0-100)' },
+ searchBoardsLimit: { type: 'number', description: 'Maximum number of boards to return' },
+ searchPartial: { type: 'boolean', description: 'Match the start of words in the query' },
+ start: { type: 'string', description: 'Card start date in ISO 8601 format' },
+ updatePos: {
+ type: 'string',
+ description: 'New card position (top, bottom, or positive float)',
+ },
+ updateLabelIds: {
+ type: 'json',
+ description: 'Label IDs that replace the card labels, as an array or comma-separated string',
+ },
+ updateMemberIds: {
+ type: 'json',
+ description:
+ 'Member IDs that replace the card members, as an array or comma-separated string',
+ },
+ moveCardToBoardId: { type: 'string', description: 'Board ID to move the card to' },
+ cardSince: {
+ type: 'string',
+ description: 'Only list cards created since this date or card ID',
+ },
+ cardBefore: {
+ type: 'string',
+ description: 'Only list cards created before this date or card ID',
+ },
+ includeChecklists: {
+ type: 'boolean',
+ description: 'Include checklists and items on a fetched card',
+ },
+ includeAttachments: { type: 'boolean', description: 'Include attachments on a fetched card' },
+ includeMembers: { type: 'boolean', description: 'Include member objects on a fetched card' },
},
outputs: {
lists: {
@@ -1367,12 +1598,12 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`,
cards: {
type: 'json',
description:
- 'Cards (id, name, desc, url, idBoard, idList, closed, labelIds, labels, due, dueComplete)',
+ 'Cards (id, name, desc, url, shortUrl, shortLink, idShort, idBoard, idList, closed, pos, labelIds, labels, idMembers, idChecklists, due, start, dueComplete, dateLastActivity, badges)',
},
card: {
type: 'json',
description:
- 'Created, updated, or fetched card (id, name, desc, url, idBoard, idList, closed, labelIds, labels, due, dueComplete)',
+ 'Created, updated, or fetched card (id, name, desc, url, shortUrl, shortLink, idShort, idBoard, idList, closed, pos, labelIds, labels, idMembers, idChecklists, due, start, dueComplete, dateLastActivity, badges, checklists, attachments, members)',
},
board: {
type: 'json',
diff --git a/apps/sim/blocks/registry-maps.ts b/apps/sim/blocks/registry-maps.ts
index 78fea85728d..1128f5710e1 100644
--- a/apps/sim/blocks/registry-maps.ts
+++ b/apps/sim/blocks/registry-maps.ts
@@ -7,7 +7,7 @@ import { AgiloftBlock, AgiloftBlockMeta } from '@/blocks/blocks/agiloft'
import { AhrefsBlock, AhrefsBlockMeta, AhrefsV2Block } from '@/blocks/blocks/ahrefs'
import { AirtableBlock, AirtableBlockMeta } from '@/blocks/blocks/airtable'
import { AlgoliaBlock, AlgoliaBlockMeta, AlgoliaV2Block } from '@/blocks/blocks/algolia'
-import { AmplitudeBlock, AmplitudeBlockMeta } from '@/blocks/blocks/amplitude'
+import { AmplitudeBlock, AmplitudeBlockMeta, AmplitudeV2Block } from '@/blocks/blocks/amplitude'
import { ApiBlock } from '@/blocks/blocks/api'
import { ApiTriggerBlock } from '@/blocks/blocks/api_trigger'
import { ApifyBlock, ApifyBlockMeta, ApifyV2Block } from '@/blocks/blocks/apify'
@@ -21,6 +21,7 @@ import { AttioBlock, AttioBlockMeta } from '@/blocks/blocks/attio'
import {
AzureDataExplorerBlock,
AzureDataExplorerBlockMeta,
+ AzureDataExplorerV2Block,
} from '@/blocks/blocks/azure_data_explorer'
import {
AzureDevOpsBlock,
@@ -40,7 +41,7 @@ import {
import { BufferBlock, BufferBlockMeta, BufferV2Block } from '@/blocks/blocks/buffer'
import { CalComBlock, CalComBlockMeta } from '@/blocks/blocks/calcom'
import { CalendlyBlock, CalendlyBlockMeta, CalendlyV2Block } from '@/blocks/blocks/calendly'
-import { CbInsightsBlock, CbInsightsBlockMeta } from '@/blocks/blocks/cbinsights'
+import { CbInsightsBlock, CbInsightsBlockMeta, CbInsightsV2Block } from '@/blocks/blocks/cbinsights'
import { ChatTriggerBlock } from '@/blocks/blocks/chat_trigger'
import { CheckrBlock, CheckrBlockMeta, CheckrV2Block } from '@/blocks/blocks/checkr'
import { CirclebackBlock, CirclebackBlockMeta, CirclebackV2Block } from '@/blocks/blocks/circleback'
@@ -235,6 +236,7 @@ import {
KalshiBlockMeta,
KalshiV2Block,
KalshiV2BlockMeta,
+ KalshiV3Block,
} from '@/blocks/blocks/kalshi'
import { KetchBlock, KetchBlockMeta } from '@/blocks/blocks/ketch'
import { KnowledgeBlock } from '@/blocks/blocks/knowledge'
@@ -296,8 +298,8 @@ import { ModalBlock, ModalBlockMeta } from '@/blocks/blocks/modal'
import { MondayBlock, MondayBlockMeta } from '@/blocks/blocks/monday'
import { MongoDBBlock, MongoDBBlockMeta } from '@/blocks/blocks/mongodb'
import { MothershipBlock } from '@/blocks/blocks/mothership'
-import { MSSQLBlock, MSSQLBlockMeta } from '@/blocks/blocks/mssql'
-import { MySQLBlock, MySQLBlockMeta } from '@/blocks/blocks/mysql'
+import { MSSQLBlock, MSSQLBlockMeta, MSSQLV2Block } from '@/blocks/blocks/mssql'
+import { MySQLBlock, MySQLBlockMeta, MySQLV2Block } from '@/blocks/blocks/mysql'
import { Neo4jBlock, Neo4jBlockMeta } from '@/blocks/blocks/neo4j'
import { NetSuiteBlock, NetSuiteBlockMeta } from '@/blocks/blocks/netsuite'
import { NeverBounceBlock, NeverBounceBlockMeta } from '@/blocks/blocks/neverbounce'
@@ -337,18 +339,18 @@ import {
PlanetScaleV2Block,
} from '@/blocks/blocks/planetscale'
import { PolymarketBlock, PolymarketBlockMeta } from '@/blocks/blocks/polymarket'
-import { PostgreSQLBlock, PostgreSQLBlockMeta } from '@/blocks/blocks/postgresql'
+import { PostgreSQLBlock, PostgreSQLBlockMeta, PostgreSQLV2Block } from '@/blocks/blocks/postgresql'
import { PostHogBlock, PostHogBlockMeta } from '@/blocks/blocks/posthog'
import { PowerBIBlock, PowerBIBlockMeta } from '@/blocks/blocks/powerbi'
import { ProfoundBlock, ProfoundBlockMeta, ProfoundV2Block } from '@/blocks/blocks/profound'
import { ProspeoBlock, ProspeoBlockMeta } from '@/blocks/blocks/prospeo'
import { PulseBlock, PulseBlockMeta, PulseV2Block, PulseV3Block } from '@/blocks/blocks/pulse'
-import { QdrantBlock, QdrantBlockMeta } from '@/blocks/blocks/qdrant'
+import { QdrantBlock, QdrantBlockMeta, QdrantV2Block } from '@/blocks/blocks/qdrant'
import { QuartrBlock, QuartrBlockMeta, QuartrV2Block } from '@/blocks/blocks/quartr'
import { QuickBooksBlock, QuickBooksBlockMeta } from '@/blocks/blocks/quickbooks'
import { QuiverBlock, QuiverBlockMeta, QuiverV2Block, QuiverV3Block } from '@/blocks/blocks/quiver'
import { RabbitmqBlock, RabbitmqBlockMeta } from '@/blocks/blocks/rabbitmq'
-import { RailwayBlock, RailwayBlockMeta } from '@/blocks/blocks/railway'
+import { RailwayBlock, RailwayBlockMeta, RailwayV2Block } from '@/blocks/blocks/railway'
import { RampBlock, RampBlockMeta } from '@/blocks/blocks/ramp'
import { RB2BBlock, RB2BBlockMeta, RB2BV2Block } from '@/blocks/blocks/rb2b'
import { RDSBlock, RDSBlockMeta, RDSV2Block } from '@/blocks/blocks/rds'
@@ -402,7 +404,7 @@ import { SlackBlock, SlackBlockMeta, SlackV2Block, SlackV2BlockMeta } from '@/bl
import { SmartleadBlock, SmartleadBlockMeta, SmartleadV2Block } from '@/blocks/blocks/smartlead'
import { SmtpBlock, SmtpBlockMeta } from '@/blocks/blocks/smtp'
import { SnowflakeBlock, SnowflakeBlockMeta } from '@/blocks/blocks/snowflake'
-import { SplunkBlock, SplunkBlockMeta } from '@/blocks/blocks/splunk'
+import { SplunkBlock, SplunkBlockMeta, SplunkV2Block } from '@/blocks/blocks/splunk'
import { SportmonksBlock, SportmonksBlockMeta, SportmonksV2Block } from '@/blocks/blocks/sportmonks'
import { SpotifyBlock, SpotifyBlockMeta } from '@/blocks/blocks/spotify'
import { SQSBlock, SQSBlockMeta, SQSV2Block } from '@/blocks/blocks/sqs'
@@ -503,6 +505,7 @@ export const BLOCK_REGISTRY: Record = {
algolia: AlgoliaBlock,
algolia_v2: AlgoliaV2Block,
amplitude: AmplitudeBlock,
+ amplitude_v2: AmplitudeV2Block,
api: ApiBlock,
api_trigger: ApiTriggerBlock,
apify: ApifyBlock,
@@ -519,6 +522,7 @@ export const BLOCK_REGISTRY: Record = {
athena_v2: AthenaV2Block,
attio: AttioBlock,
azure_data_explorer: AzureDataExplorerBlock,
+ azure_data_explorer_v2: AzureDataExplorerV2Block,
azure_devops: AzureDevOpsBlock,
azure_devops_v2: AzureDevOpsV2Block,
bitbucket: BitbucketBlock,
@@ -537,6 +541,7 @@ export const BLOCK_REGISTRY: Record = {
calendly: CalendlyBlock,
calendly_v2: CalendlyV2Block,
cbinsights: CbInsightsBlock,
+ cbinsights_v2: CbInsightsV2Block,
chat_trigger: ChatTriggerBlock,
checkr: CheckrBlock,
checkr_v2: CheckrV2Block,
@@ -725,6 +730,7 @@ export const BLOCK_REGISTRY: Record = {
jupyter_v3: JupyterV3Block,
kalshi: KalshiBlock,
kalshi_v2: KalshiV2Block,
+ kalshi_v3: KalshiV3Block,
ketch: KetchBlock,
knowledge: KnowledgeBlock,
lambda: LambdaBlock,
@@ -781,7 +787,9 @@ export const BLOCK_REGISTRY: Record = {
mongodb: MongoDBBlock,
mothership: MothershipBlock,
mssql: MSSQLBlock,
+ mssql_v2: MSSQLV2Block,
mysql: MySQLBlock,
+ mysql_v2: MySQLV2Block,
neo4j: Neo4jBlock,
netsuite: NetSuiteBlock,
new_relic: NewRelicBlock,
@@ -817,6 +825,7 @@ export const BLOCK_REGISTRY: Record = {
planetscale_v2: PlanetScaleV2Block,
polymarket: PolymarketBlock,
postgresql: PostgreSQLBlock,
+ postgresql_v2: PostgreSQLV2Block,
posthog: PostHogBlock,
powerbi: PowerBIBlock,
profound: ProfoundBlock,
@@ -826,6 +835,7 @@ export const BLOCK_REGISTRY: Record = {
pulse_v2: PulseV2Block,
pulse_v3: PulseV3Block,
qdrant: QdrantBlock,
+ qdrant_v2: QdrantV2Block,
quartr: QuartrBlock,
quartr_v2: QuartrV2Block,
quickbooks: QuickBooksBlock,
@@ -834,6 +844,7 @@ export const BLOCK_REGISTRY: Record = {
quiver_v3: QuiverV3Block,
rabbitmq: RabbitmqBlock,
railway: RailwayBlock,
+ railway_v2: RailwayV2Block,
ramp: RampBlock,
rb2b: RB2BBlock,
rb2b_v2: RB2BV2Block,
@@ -897,6 +908,7 @@ export const BLOCK_REGISTRY: Record = {
smtp: SmtpBlock,
snowflake: SnowflakeBlock,
splunk: SplunkBlock,
+ splunk_v2: SplunkV2Block,
sportmonks: SportmonksBlock,
sportmonks_v2: SportmonksV2Block,
spotify: SpotifyBlock,
diff --git a/apps/sim/blocks/utils.test.ts b/apps/sim/blocks/utils.test.ts
index c5c354a315f..5f038bd3fb2 100644
--- a/apps/sim/blocks/utils.test.ts
+++ b/apps/sim/blocks/utils.test.ts
@@ -59,9 +59,12 @@ vi.mock('@/stores/providers/store', () => ({
vi.mock('@/lib/oauth/utils', () => oauthUtilsMock)
import { S3Icon } from '@/components/icons'
+import { evaluateSubBlockCondition } from '@/lib/workflows/subblocks/visibility'
import type { BlockConfig } from '@/blocks/types'
import {
getApiKeyCondition,
+ getProviderCredentialSubBlocks,
+ getSelectedBedrockCredential,
getSerializedModelProviderId,
parseOptionalBooleanInput,
parseOptionalJsonInput,
@@ -102,6 +105,96 @@ describe('requiresProviderFamilyCredentials', () => {
})
})
+describe('Bedrock credential fields', () => {
+ const BEDROCK_FIELDS = [
+ 'bedrockAuthMode',
+ 'bedrockCredential',
+ 'bedrockManualCredential',
+ 'bedrockAccessKeyId',
+ 'bedrockSecretKey',
+ 'bedrockRegion',
+ ]
+
+ const visibleBedrockFields = (
+ values: Record,
+ subBlocks = getProviderCredentialSubBlocks({ bedrockSavedCredential: true })
+ ) =>
+ subBlocks
+ .filter((subBlock) => BEDROCK_FIELDS.includes(subBlock.id))
+ .filter((subBlock) => evaluateSubBlockCondition(subBlock.condition, values))
+ .map((subBlock) => subBlock.id)
+
+ it('keeps showing access keys, and no credential picker, on a block saved before auth modes', () => {
+ expect(visibleBedrockFields({ model: 'bedrock/my-inference-profile' })).toEqual([
+ 'bedrockAuthMode',
+ 'bedrockAccessKeyId',
+ 'bedrockSecretKey',
+ 'bedrockRegion',
+ ])
+ })
+
+ it('swaps the access keys for the AWS credential picker when a saved credential is chosen', () => {
+ expect(
+ visibleBedrockFields({ model: 'bedrock/my-inference-profile', bedrockAuthMode: 'credential' })
+ ).toEqual(['bedrockAuthMode', 'bedrockCredential', 'bedrockManualCredential', 'bedrockRegion'])
+ })
+
+ it('shows no Bedrock field for a model of another provider', () => {
+ expect(visibleBedrockFields({ model: 'gpt-4o', bedrockAuthMode: 'credential' })).toEqual([])
+ })
+
+ it('offers no saved credential, and keeps the access keys, for a block that cannot forward one', () => {
+ expect(
+ visibleBedrockFields(
+ { model: 'bedrock/my-inference-profile', bedrockAuthMode: 'credential' },
+ getProviderCredentialSubBlocks()
+ )
+ ).toEqual(['bedrockAccessKeyId', 'bedrockSecretKey', 'bedrockRegion'])
+ })
+})
+
+describe('getSelectedBedrockCredential', () => {
+ it('returns the trimmed saved credential a block signs Bedrock calls with', () => {
+ expect(
+ getSelectedBedrockCredential({ bedrockAuthMode: 'credential', bedrockCredential: ' cred-1 ' })
+ ).toBe('cred-1')
+ })
+
+ it('refuses a block set to a saved credential whose ID resolves blank', () => {
+ expect(() =>
+ getSelectedBedrockCredential({ bedrockAuthMode: 'credential', bedrockCredential: '' })
+ ).toThrow('Select an AWS credential for Bedrock')
+ expect(() => getSelectedBedrockCredential({ bedrockAuthMode: 'credential' })).toThrow(
+ 'Select an AWS credential for Bedrock'
+ )
+ })
+
+ it('returns nothing for a block on access keys or saved before auth modes', () => {
+ expect(
+ getSelectedBedrockCredential({ bedrockAuthMode: 'access_keys', bedrockCredential: 'cred-1' })
+ ).toBeUndefined()
+ expect(getSelectedBedrockCredential({})).toBeUndefined()
+ })
+
+ it('signs with a credential exactly when the block shows and serializes the credential picker', () => {
+ const picker = getProviderCredentialSubBlocks({ bedrockSavedCredential: true }).find(
+ (subBlock) => subBlock.id === 'bedrockCredential'
+ )
+ const cases: Record[] = [
+ { bedrockCredential: 'cred-1' },
+ { bedrockAuthMode: '', bedrockCredential: 'cred-1' },
+ { bedrockAuthMode: 'access_keys', bedrockCredential: 'cred-1' },
+ { bedrockAuthMode: 'credential', bedrockCredential: 'cred-1' },
+ ]
+ for (const values of cases) {
+ const withModel = { model: 'bedrock/my-inference-profile', ...values }
+ expect(getSelectedBedrockCredential(values) !== undefined, JSON.stringify(values)).toBe(
+ evaluateSubBlockCondition(picker?.condition, withModel)
+ )
+ }
+ })
+})
+
describe('getApiKeyCondition / shouldRequireApiKeyForModel', () => {
const evaluateCondition = (model: string): boolean => {
const conditionFn = getApiKeyCondition()
diff --git a/apps/sim/blocks/utils.ts b/apps/sim/blocks/utils.ts
index 03671ec002f..68cbe8cbf92 100644
--- a/apps/sim/blocks/utils.ts
+++ b/apps/sim/blocks/utils.ts
@@ -62,6 +62,7 @@ interface SavedCredentialPicker {
type: 'oauth-input'
serviceId: SavedCredentialService
title: string
+ credentialKind?: 'service-account' | 'any'
}
interface SavedCredentialOptions {
@@ -160,7 +161,7 @@ export function withSavedCredential(
0,
{
...picker,
- credentialKind: 'service-account',
+ credentialKind: picker.credentialKind ?? 'service-account',
canonicalParamId: 'oauthCredential',
mode: 'basic',
// 'AWS Credential' → 'Select AWS credential': sentence case, brand casing kept.
@@ -511,13 +512,145 @@ function getModelProviderCondition(...providerIds: ProviderId[]) {
}
}
+/**
+ * Whether a block's Bedrock call signs with a saved AWS credential. The one rule
+ * behind both the fields a block shows and serializes and the credential its
+ * call uses: only an explicit `bedrockAuthMode: 'credential'` selects one, so a
+ * block saved before the mode existed (no `bedrockAuthMode`) keeps its access keys.
+ */
+function usesSavedBedrockCredential(values: { bedrockAuthMode?: unknown } | undefined): boolean {
+ return values?.bedrockAuthMode === 'credential'
+}
+
+/**
+ * Bedrock fields shown for one AWS authentication mode, decided by
+ * {@link usesSavedBedrockCredential}. Like {@link getModelProviderCondition}, the
+ * answer is folded into a single `model` clause.
+ */
+function getBedrockAuthModeCondition(mode: 'access_keys' | 'credential') {
+ return (values?: Record) => {
+ const model = typeof values?.model === 'string' ? values.model : ''
+ return buildModelVisibilityCondition(
+ model,
+ findProviderFromModel(model.trim()) === 'bedrock' &&
+ usesSavedBedrockCredential(values) === (mode === 'credential')
+ )
+ }
+}
+
+/**
+ * The saved AWS credential ID a block's Bedrock call signs with, or undefined
+ * when the block uses access keys (see {@link usesSavedBedrockCredential}). A
+ * block set to a saved credential whose ID resolves blank (an empty reference)
+ * is refused, so its call never falls back to inline keys or the deployment's
+ * own AWS credentials.
+ */
+export function getSelectedBedrockCredential(values: {
+ bedrockAuthMode?: unknown
+ bedrockCredential?: unknown
+}): string | undefined {
+ if (!usesSavedBedrockCredential(values)) return undefined
+ const credentialId =
+ typeof values.bedrockCredential === 'string' ? values.bedrockCredential.trim() : ''
+ if (credentialId) return credentialId
+ throw new Error(
+ 'Select an AWS credential for Bedrock, or switch AWS Authentication to access keys'
+ )
+}
+
+/**
+ * The Bedrock fields of {@link getProviderCredentialSubBlocks}. With
+ * `savedCredential`, an AWS Authentication choice swaps the access keys for a
+ * saved AWS credential; without it the block keeps its access keys alone.
+ */
+function getBedrockCredentialSubBlocks(savedCredential: boolean): SubBlockConfig[] {
+ const accessKeyCondition = savedCredential
+ ? getBedrockAuthModeCondition('access_keys')
+ : getModelProviderCondition('bedrock')
+ const savedCredentialSubBlocks: SubBlockConfig[] = [
+ {
+ id: 'bedrockAuthMode',
+ title: 'AWS Authentication',
+ type: 'dropdown',
+ options: [
+ { label: 'Access keys', id: 'access_keys' },
+ { label: 'Saved AWS credential', id: 'credential' },
+ ],
+ value: () => 'access_keys',
+ connectionDroppable: false,
+ condition: getModelProviderCondition('bedrock'),
+ },
+ {
+ id: 'bedrockCredential',
+ title: 'AWS Credential',
+ type: 'oauth-input',
+ serviceId: 'aws',
+ canonicalParamId: 'bedrockCredential',
+ credentialKind: 'service-account',
+ mode: 'basic',
+ placeholder: 'Select AWS credential',
+ required: true,
+ condition: getBedrockAuthModeCondition('credential'),
+ },
+ {
+ id: 'bedrockManualCredential',
+ title: 'AWS Credential',
+ type: 'short-input',
+ canonicalParamId: 'bedrockCredential',
+ mode: 'advanced',
+ placeholder: 'Enter credential ID',
+ required: true,
+ condition: getBedrockAuthModeCondition('credential'),
+ },
+ ]
+ return [
+ ...(savedCredential ? savedCredentialSubBlocks : []),
+ {
+ id: 'bedrockAccessKeyId',
+ title: 'AWS Access Key ID',
+ type: 'short-input',
+ password: true,
+ placeholder: 'Enter your AWS Access Key ID',
+ connectionDroppable: false,
+ required: true,
+ hideWhenEnvSet: 'NEXT_PUBLIC_BEDROCK_DEFAULT_CREDENTIALS',
+ condition: accessKeyCondition,
+ },
+ {
+ id: 'bedrockSecretKey',
+ title: 'AWS Secret Access Key',
+ type: 'short-input',
+ password: true,
+ placeholder: 'Enter your AWS Secret Access Key',
+ connectionDroppable: false,
+ required: true,
+ hideWhenEnvSet: 'NEXT_PUBLIC_BEDROCK_DEFAULT_CREDENTIALS',
+ condition: accessKeyCondition,
+ },
+ {
+ id: 'bedrockRegion',
+ title: 'AWS Region',
+ type: 'short-input',
+ placeholder: 'us-east-1',
+ connectionDroppable: false,
+ condition: getModelProviderCondition('bedrock'),
+ },
+ ]
+}
+
/**
* Returns the standard provider credential subblocks used by LLM-based blocks.
* This includes: Vertex AI OAuth, API Key, Azure (OpenAI + Anthropic), Vertex AI config, and Bedrock config.
*
+ * Pass `bedrockSavedCredential` only from a block that forwards
+ * {@link getSelectedBedrockCredential} to its Bedrock call and spreads
+ * {@link BEDROCK_SAVED_CREDENTIAL_INPUTS}; any other block keeps Bedrock access keys only.
+ *
* Usage: Spread into your block's subBlocks array after block-specific fields
*/
-export function getProviderCredentialSubBlocks(): SubBlockConfig[] {
+export function getProviderCredentialSubBlocks(
+ options: { bedrockSavedCredential?: boolean } = {}
+): SubBlockConfig[] {
return [
{
id: 'vertexCredential',
@@ -590,36 +723,7 @@ export function getProviderCredentialSubBlocks(): SubBlockConfig[] {
required: true,
condition: getModelProviderCondition('vertex'),
},
- {
- id: 'bedrockAccessKeyId',
- title: 'AWS Access Key ID',
- type: 'short-input',
- password: true,
- placeholder: 'Enter your AWS Access Key ID',
- connectionDroppable: false,
- required: true,
- hideWhenEnvSet: 'NEXT_PUBLIC_BEDROCK_DEFAULT_CREDENTIALS',
- condition: getModelProviderCondition('bedrock'),
- },
- {
- id: 'bedrockSecretKey',
- title: 'AWS Secret Access Key',
- type: 'short-input',
- password: true,
- placeholder: 'Enter your AWS Secret Access Key',
- connectionDroppable: false,
- required: true,
- hideWhenEnvSet: 'NEXT_PUBLIC_BEDROCK_DEFAULT_CREDENTIALS',
- condition: getModelProviderCondition('bedrock'),
- },
- {
- id: 'bedrockRegion',
- title: 'AWS Region',
- type: 'short-input',
- placeholder: 'us-east-1',
- connectionDroppable: false,
- condition: getModelProviderCondition('bedrock'),
- },
+ ...getBedrockCredentialSubBlocks(options.bedrockSavedCredential ?? false),
]
}
@@ -643,6 +747,23 @@ export const PROVIDER_CREDENTIAL_INPUTS = {
bedrockRegion: { type: 'string', description: 'AWS region for Bedrock' },
} as const
+/**
+ * Input definitions for a saved AWS credential on Bedrock. Spread only by a
+ * block that also passes `bedrockSavedCredential` to
+ * {@link getProviderCredentialSubBlocks} and forwards the credential to its call.
+ */
+export const BEDROCK_SAVED_CREDENTIAL_INPUTS = {
+ bedrockAuthMode: {
+ type: 'string',
+ description: "How Bedrock authenticates: 'access_keys' (default) or 'credential'",
+ },
+ bedrockCredential: {
+ type: 'string',
+ description:
+ "Saved AWS credential ID for Bedrock, used instead of access keys when bedrockAuthMode is 'credential'",
+ },
+} as const
+
/**
* Create a versioned tool selector from an existing tool selector.
*
diff --git a/apps/sim/bootstrap.ts b/apps/sim/bootstrap.ts
index be7987c8f54..5b2e7d6cee2 100644
--- a/apps/sim/bootstrap.ts
+++ b/apps/sim/bootstrap.ts
@@ -1,6 +1,8 @@
/**
- * Container entrypoint. Hydrates `process.env` from the runtime secret, resolves an AWS
- * Marketplace Enterprise license into `ENTERPRISE_ENABLED` on Marketplace installs, and drops
+ * Container entrypoint. Hydrates `process.env` from the runtime secret, refuses to start on a
+ * malformed `AWS_MARKETPLACE_PRODUCTS` (so a bad deploy rolls back instead of failing buyers and
+ * checkouts later), resolves an AWS Marketplace Enterprise license into `ENTERPRISE_ENABLED` on
+ * Marketplace installs, and drops
* Next's route cache when an earlier build wrote it, before loading the Next.js standalone server,
* so application modules that read env at import time see the full configuration. See
* `@sim/runtime-secrets`.
@@ -8,9 +10,13 @@
import { fileURLToPath } from 'node:url'
import { loadRuntimeSecrets } from '@sim/runtime-secrets'
import { applyAwsMarketplaceContainerLicense } from '@/lib/billing/aws-marketplace/container-license'
+import { parseAwsMarketplaceProducts } from '@/lib/billing/aws-marketplace/products'
import { clearRouteCacheFromOtherBuild } from '@/lib/core/utils/route-cache'
await loadRuntimeSecrets()
+if (process.env.AWS_MARKETPLACE_PRODUCTS) {
+ parseAwsMarketplaceProducts(process.env.AWS_MARKETPLACE_PRODUCTS)
+}
await applyAwsMarketplaceContainerLicense()
await clearRouteCacheFromOtherBuild(fileURLToPath(new URL('./.next', import.meta.url)))
// `server.js` is the Next standalone build artifact, a sibling of this file in
diff --git a/apps/sim/components/computer-use/activity.test.tsx b/apps/sim/components/computer-use/activity.test.tsx
index ebda9e89236..be79032b95f 100644
--- a/apps/sim/components/computer-use/activity.test.tsx
+++ b/apps/sim/components/computer-use/activity.test.tsx
@@ -1,12 +1,10 @@
/** @vitest-environment jsdom */
import { act } from 'react'
import type { ComputerUseActivity as Activity, ComputerUseStatus } from '@sim/desktop-bridge'
-import { createDeferred } from '@sim/testing/helpers/deferred'
import { libDesktopMock, libDesktopMockFns } from '@sim/testing/mocks/lib-desktop.mock'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { ComputerUseActivity } from '@/components/computer-use/activity'
-import { FeatureFlagsProvider } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
const native = vi.hoisted(() => ({
getActivity: vi.fn<() => Promise>(),
@@ -39,22 +37,7 @@ it.each(['pending', 'failed'] as const)(
native.getStatus.mockImplementation(() =>
state === 'failed' ? Promise.reject(new Error('unavailable')) : new Promise(() => {})
)
- await act(async () =>
- root.render(
-
-
-
- )
- )
+ await act(async () => root.render())
const listener = native.onActivity.mock.calls[0]?.[0]
if (!listener) throw new Error('Activity listener not registered')
await act(async () =>
@@ -74,48 +57,3 @@ it.each(['pending', 'failed'] as const)(
expect(container.querySelector('button')).toBeNull()
}
)
-
-it.each(['active', 'completed'] as const)(
- 'loads an existing %s action without waking the helper when rollout is off',
- async (state) => {
- const snapshot = createDeferred()
- native.getActivity.mockReturnValue(snapshot.promise)
- await act(async () =>
- root.render(
-
-
-
- )
- )
- if (state === 'completed') {
- const listener = native.onActivity.mock.calls[0]?.[0]
- if (!listener) throw new Error('Activity listener not registered')
- await act(async () => listener(null))
- }
- await act(async () =>
- snapshot.resolve({
- toolCallId: 'existing',
- scopeId: 'chat',
- action: 'click',
- startedAt: Date.now(),
- })
- )
- expect(native.getStatus).not.toHaveBeenCalled()
- const stop = container.querySelector('button')
- if (state === 'completed') expect(stop).toBeNull()
- else {
- expect(stop?.textContent).toContain('Stop')
- await act(async () => stop?.click())
- expect(native.cancel).toHaveBeenCalledOnce()
- }
- }
-)
diff --git a/apps/sim/components/computer-use/activity.tsx b/apps/sim/components/computer-use/activity.tsx
index 44502d4c9f9..384d8d0764f 100644
--- a/apps/sim/components/computer-use/activity.tsx
+++ b/apps/sim/components/computer-use/activity.tsx
@@ -19,7 +19,7 @@ const ACTION_LABELS: Record = {
perform_action: 'Interacting',
}
-/** Remains visible while a native action is active, even if rollout or device access is revoked. */
+/** Remains visible while a native action is active, even if the permission group or device access revokes it. */
export function ComputerUseActivity() {
const { activeAction: activity } = useComputerUseStatus()
if (!activity) return null
diff --git a/apps/sim/connectors/auth.test.ts b/apps/sim/connectors/auth.test.ts
index ad6d02739d4..3349e3b56b7 100644
--- a/apps/sim/connectors/auth.test.ts
+++ b/apps/sim/connectors/auth.test.ts
@@ -1,7 +1,15 @@
import { describe, expect, it } from 'vitest'
-import { isConnectorCredentialTypeAllowed } from '@/connectors/auth'
+import {
+ connectorHasAuthSource,
+ connectorUsesCredential,
+ getConnectorCredentialService,
+ isConnectorCredentialTypeAllowed,
+} from '@/connectors/auth'
import { confluenceConnectorMeta } from '@/connectors/confluence/meta'
+import { databricksConnectorMeta } from '@/connectors/databricks/meta'
+import { githubConnectorMeta } from '@/connectors/github/meta'
import { googleDriveConnectorMeta } from '@/connectors/google-drive/meta'
+import { s3ConnectorMeta } from '@/connectors/s3/meta'
describe('connector credential eligibility', () => {
it.each([confluenceConnectorMeta, googleDriveConnectorMeta])(
@@ -24,3 +32,47 @@ describe('connector credential eligibility', () => {
}
)
})
+
+describe('connector auth source', () => {
+ const s3Auth = s3ConnectorMeta.auth
+ const keyOnlyAuth = databricksConnectorMeta.auth
+
+ it('keeps a stored-key S3 connector on its key', () => {
+ const row = { credentialId: null, encryptedApiKey: 'encrypted-key' }
+ expect(connectorUsesCredential(s3Auth, row)).toBe(false)
+ expect(connectorHasAuthSource(s3Auth, row)).toBe(true)
+ })
+
+ it('authenticates an S3 connector that names a saved credential with that credential', () => {
+ const row = { credentialId: 'credential-1', encryptedApiKey: null }
+ expect(connectorUsesCredential(s3Auth, row)).toBe(true)
+ expect(connectorHasAuthSource(s3Auth, row)).toBe(true)
+ })
+
+ it('treats an S3 connector whose credential was removed as needing reconnection', () => {
+ expect(connectorHasAuthSource(s3Auth, { credentialId: null, encryptedApiKey: null })).toBe(
+ false
+ )
+ })
+
+ it('never routes a key-only connector to a credential it does not accept', () => {
+ const row = { credentialId: 'credential-1', encryptedApiKey: 'encrypted-key' }
+ expect(connectorUsesCredential(keyOnlyAuth, row)).toBe(false)
+ expect(getConnectorCredentialService(keyOnlyAuth)).toBeUndefined()
+ })
+
+ it('keeps an OAuth connector with a token alternative on the token it stored', () => {
+ expect(
+ connectorUsesCredential(githubConnectorMeta.auth, {
+ credentialId: null,
+ encryptedApiKey: 'encrypted-token',
+ })
+ ).toBe(false)
+ expect(
+ connectorUsesCredential(githubConnectorMeta.auth, {
+ credentialId: 'credential-1',
+ encryptedApiKey: null,
+ })
+ ).toBe(true)
+ })
+})
diff --git a/apps/sim/connectors/auth.ts b/apps/sim/connectors/auth.ts
index adf4dc9e4aa..8d74d5a8b59 100644
--- a/apps/sim/connectors/auth.ts
+++ b/apps/sim/connectors/auth.ts
@@ -1,4 +1,5 @@
-import type { ConnectorAuthConfig } from '@/connectors/types'
+import type { OAuthService } from '@/lib/oauth/types'
+import type { ConnectorAuthConfig, ConnectorConfigField } from '@/connectors/types'
/** The same selected-source requirements drive credential filtering, validation, and token minting. */
export function getConnectorRequiredScopes(
@@ -25,6 +26,36 @@ export function isConnectorCredentialTypeAllowed(
)
}
+/** The service whose saved credentials can authenticate this connector, if any. */
+export function getConnectorCredentialService(auth: ConnectorAuthConfig): OAuthService | undefined {
+ return auth.mode === 'oauth' ? auth.provider : auth.savedCredential?.provider
+}
+
+/**
+ * Whether a connector row authenticates with its saved credential rather than a stored key.
+ *
+ * An OAuth connector uses its credential unless it also takes a token and the row stores
+ * only that. A key connector uses a credential only when it declares a `savedCredential`
+ * alternative and the row names one; creation and access updates never bind a credential to
+ * a row that stores a key, so such a row keeps using its key.
+ */
+export function connectorUsesCredential(
+ auth: ConnectorAuthConfig,
+ connector: { credentialId: string | null; encryptedApiKey: string | null }
+): boolean {
+ if (auth.mode === 'apiKey') return Boolean(auth.savedCredential && connector.credentialId)
+ return !(auth.apiKey && !connector.credentialId && connector.encryptedApiKey)
+}
+
+/** How a connector row authenticates, as recorded in audit metadata. */
+export function connectorAuthModeLabel(
+ authMode: ConnectorAuthConfig['mode'],
+ usesCredential: boolean
+): 'apiKey' | 'oauth' | 'savedCredential' {
+ if (!usesCredential) return 'apiKey'
+ return authMode === 'apiKey' ? 'savedCredential' : 'oauth'
+}
+
/**
* Whether a workspace-mode connector row still carries something to authenticate with. A
* connector whose credential was removed keeps its documents but has no token source, so a
@@ -34,9 +65,16 @@ export function connectorHasAuthSource(
auth: ConnectorAuthConfig,
connector: { credentialId: string | null; encryptedApiKey: string | null }
): boolean {
- const apiKeyConfig = getConnectorApiKeyConfig(auth)
- if (apiKeyConfig && connector.encryptedApiKey) return true
- return auth.mode === 'apiKey' ? apiKeyConfig?.optional === true : Boolean(connector.credentialId)
+ if (connectorUsesCredential(auth, connector)) return Boolean(connector.credentialId)
+ return Boolean(connector.encryptedApiKey) || getConnectorApiKeyConfig(auth)?.optional === true
+}
+
+/** Whether a config field applies to how the connector authenticates: key-only fields drop out with a saved credential. */
+export function isConnectorFieldForAuth(
+ field: Pick,
+ authenticatesWithKey: boolean
+): boolean {
+ return authenticatesWithKey || !field.apiKeyOnly
}
/** Workspace token input supported by a connector, independent of its member OAuth method. */
diff --git a/apps/sim/connectors/aws-session.ts b/apps/sim/connectors/aws-session.ts
new file mode 100644
index 00000000000..e0289d5b84f
--- /dev/null
+++ b/apps/sim/connectors/aws-session.ts
@@ -0,0 +1,44 @@
+/**
+ * The AWS keys a saved AWS credential resolved to for one connector run.
+ *
+ * A saved AWS credential has no bearer token: the server resolves it to a key
+ * pair (and, for a role, an STS session token) for the knowledge base's own
+ * scope. The resolver seeds them into the run's `syncContext` under one slot,
+ * the same way it seeds a credential's bound site, so a connector reads them
+ * from one place and never from tool or user input. Server-only state: the
+ * slot is never persisted or logged.
+ */
+export interface ConnectorAwsSession {
+ accessKeyId: string
+ secretAccessKey: string
+ /** Present for STS sessions (assumed and keyless roles); absent for saved long-lived keys. */
+ sessionToken?: string
+}
+
+const AWS_SESSION_CONTEXT_KEY = 'credentialAwsSession'
+
+/** The `syncContext` entries that carry a resolved AWS session to a connector. */
+export function awsSessionSyncContext(session: ConnectorAwsSession): Record {
+ return {
+ [AWS_SESSION_CONTEXT_KEY]: {
+ accessKeyId: session.accessKeyId,
+ secretAccessKey: session.secretAccessKey,
+ ...(session.sessionToken ? { sessionToken: session.sessionToken } : {}),
+ },
+ }
+}
+
+/** The AWS session seeded into this run, or undefined when the connector signs with inline keys. */
+export function readAwsSession(
+ syncContext: Record | undefined
+): ConnectorAwsSession | undefined {
+ const value = syncContext?.[AWS_SESSION_CONTEXT_KEY]
+ if (typeof value !== 'object' || value === null) return undefined
+ const { accessKeyId, secretAccessKey, sessionToken } = value as Record
+ if (typeof accessKeyId !== 'string' || !accessKeyId) return undefined
+ if (typeof secretAccessKey !== 'string' || !secretAccessKey) return undefined
+ if (sessionToken !== undefined && (typeof sessionToken !== 'string' || !sessionToken)) {
+ return undefined
+ }
+ return { accessKeyId, secretAccessKey, ...(sessionToken ? { sessionToken } : {}) }
+}
diff --git a/apps/sim/connectors/s3/meta.ts b/apps/sim/connectors/s3/meta.ts
index 49367964ba4..de40b65b44b 100644
--- a/apps/sim/connectors/s3/meta.ts
+++ b/apps/sim/connectors/s3/meta.ts
@@ -13,6 +13,7 @@ export const s3ConnectorMeta: ConnectorMeta = {
mode: 'apiKey',
label: 'Secret Access Key',
placeholder: 'Enter your AWS Secret Access Key',
+ savedCredential: { provider: 'aws', label: 'Saved AWS credential' },
},
configFields: [
@@ -22,6 +23,7 @@ export const s3ConnectorMeta: ConnectorMeta = {
type: 'short-input',
placeholder: 'e.g. AKIAIOSFODNN7EXAMPLE',
required: true,
+ apiKeyOnly: true,
},
{
id: 'region',
@@ -30,7 +32,7 @@ export const s3ConnectorMeta: ConnectorMeta = {
placeholder: 'e.g. us-east-1 (use auto for Cloudflare R2)',
required: true,
description:
- 'AWS region for the bucket. For Cloudflare R2 use "auto"; for MinIO use the region the server is configured with (commonly us-east-1).',
+ 'AWS region for the bucket. For Cloudflare R2 use "auto"; for MinIO use the region the server is configured with (commonly us-east-1). A saved AWS credential needs a commercial or GovCloud AWS region.',
},
{
id: 'bucket',
@@ -47,6 +49,7 @@ export const s3ConnectorMeta: ConnectorMeta = {
required: false,
description:
'S3-compatible endpoint for Cloudflare R2, MinIO, etc. Leave empty for AWS S3. Uses path-style addressing. Plain http:// is only allowed for localhost.',
+ apiKeyOnly: true,
},
{
id: 'prefix',
diff --git a/apps/sim/connectors/s3/s3.test.ts b/apps/sim/connectors/s3/s3.test.ts
new file mode 100644
index 00000000000..6dc93080fb6
--- /dev/null
+++ b/apps/sim/connectors/s3/s3.test.ts
@@ -0,0 +1,167 @@
+/**
+ * Pins the S3 connector's two signing paths. Inline keys sign exactly as they
+ * always have; a saved AWS credential (seeded into `syncContext` by the token
+ * resolver) signs with its STS session token and only ever for Amazon S3 — a
+ * custom S3-compatible endpoint would otherwise receive the customer's AWS
+ * session.
+ */
+import { knowledgeDocumentsUtilsMock } from '@sim/testing/mocks/knowledge-documents-utils.mock'
+import {
+ knowledgeSecureFetchMock,
+ knowledgeSecureFetchMockFns,
+} from '@sim/testing/mocks/knowledge-secure-fetch.mock'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/lib/knowledge/documents/utils', () => knowledgeDocumentsUtilsMock)
+vi.mock('@/lib/knowledge/documents/secure-fetch.server', () => knowledgeSecureFetchMock)
+
+import { awsSessionSyncContext } from '@/connectors/aws-session'
+import { s3Connector } from '@/connectors/s3/s3'
+
+const mockSecureFetchWithRetry = knowledgeSecureFetchMockFns.mockSecureFetchWithRetry
+
+const SESSION = {
+ accessKeyId: 'ASIAIOSFODNN7EXAMPLE',
+ secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
+ sessionToken: 'IQoJb3JpZ2luX2VjEXAMPLE+token/with=chars',
+}
+
+const EMPTY_LISTING =
+ 'false'
+
+function recordRequests() {
+ const requests: { url: string; headers: Record }[] = []
+ mockSecureFetchWithRetry.mockImplementation(
+ async (url: string, init: { headers: Record }) => {
+ requests.push({ url, headers: init.headers })
+ return new Response(EMPTY_LISTING, { status: 200 })
+ }
+ )
+ return requests
+}
+
+describe('s3Connector signing', () => {
+ beforeEach(() => {
+ mockSecureFetchWithRetry.mockReset()
+ vi.useFakeTimers({ toFake: ['Date'] })
+ vi.setSystemTime(new Date('2026-11-06T12:00:00Z'))
+ })
+
+ it('signs a saved credential session token into the request, as SigV4 requires', async () => {
+ const requests = recordRequests()
+
+ await s3Connector.listDocuments(
+ 'credential-handle',
+ { region: 'us-east-1', bucket: 'docs-bucket', prefix: 'kb/' },
+ undefined,
+ { ...awsSessionSyncContext(SESSION) }
+ )
+
+ expect(requests).toHaveLength(1)
+ const [request] = requests
+ expect(request.url).toBe(
+ 'https://docs-bucket.s3.us-east-1.amazonaws.com/?encoding-type=url&list-type=2&max-keys=1000&prefix=kb%2F'
+ )
+ expect(request.headers['X-Amz-Security-Token']).toBe(SESSION.sessionToken)
+ // Reference value from @smithy/signature-v4 (the AWS SDK signer) for the same request.
+ expect(request.headers.Authorization).toBe(
+ 'AWS4-HMAC-SHA256 Credential=ASIAIOSFODNN7EXAMPLE/20261106/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-security-token, Signature=dfe6577a8c0c32916977121ba18620aeca7758bf39d0230a02d29183ff49677a'
+ )
+ })
+
+ it('never sends a saved AWS credential to a custom endpoint', async () => {
+ recordRequests()
+
+ const validation = await s3Connector.validateConfig(
+ 'credential-handle',
+ { region: 'us-east-1', bucket: 'docs', endpoint: 'https://attacker.example.com' },
+ { ...awsSessionSyncContext(SESSION) }
+ )
+
+ expect(validation.valid).toBe(false)
+ expect(validation.error).toMatch(/custom endpoint/i)
+ expect(mockSecureFetchWithRetry).not.toHaveBeenCalled()
+ })
+
+ it('refuses a non-AWS region with a saved AWS credential', async () => {
+ recordRequests()
+
+ const validation = await s3Connector.validateConfig(
+ 'credential-handle',
+ { accessKeyId: 'AKIAIOSFODNN7EXAMPLE', region: 'auto', bucket: 'docs' },
+ { ...awsSessionSyncContext(SESSION) }
+ )
+
+ expect(validation.valid).toBe(false)
+ expect(validation.error).toMatch(/AWS region/i)
+ expect(mockSecureFetchWithRetry).not.toHaveBeenCalled()
+ })
+
+ it.each(['cn-north-1', 'us-iso-east-1', 'eu-isoe-west-1'])(
+ 'refuses %s, whose S3 host is not under amazonaws.com, with a saved AWS credential',
+ async (region) => {
+ recordRequests()
+
+ const validation = await s3Connector.validateConfig(
+ 'credential-handle',
+ { region, bucket: 'docs' },
+ { ...awsSessionSyncContext(SESSION) }
+ )
+
+ expect(validation.valid).toBe(false)
+ expect(validation.error).toMatch(/commercial or GovCloud/i)
+ expect(mockSecureFetchWithRetry).not.toHaveBeenCalled()
+ }
+ )
+
+ it('signs a saved AWS credential for a GovCloud bucket', async () => {
+ const requests = recordRequests()
+
+ await s3Connector.listDocuments(
+ '',
+ { region: 'us-gov-west-1', bucket: 'docs-bucket' },
+ undefined,
+ { ...awsSessionSyncContext(SESSION) }
+ )
+
+ expect(requests[0]?.url).toMatch(/^https:\/\/docs-bucket\.s3\.us-gov-west-1\.amazonaws\.com\//)
+ })
+
+ it('keeps signing inline keys without a session token', async () => {
+ const requests = recordRequests()
+
+ await s3Connector.listDocuments(
+ SESSION.secretAccessKey,
+ {
+ accessKeyId: SESSION.accessKeyId,
+ region: 'us-east-1',
+ bucket: 'docs-bucket',
+ prefix: 'kb/',
+ },
+ undefined,
+ {}
+ )
+
+ const [request] = requests
+ expect(request.headers['X-Amz-Security-Token']).toBeUndefined()
+ // Reference value from @smithy/signature-v4 for the same request without a session token.
+ expect(request.headers.Authorization).toBe(
+ 'AWS4-HMAC-SHA256 Credential=ASIAIOSFODNN7EXAMPLE/20261106/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=bceba1b42847dc164dfe175c63bc088449c244e6e91cce4ecd6d9b06f6508b0d'
+ )
+ })
+
+ it('ignores an inline access key ID once a saved credential signs', async () => {
+ const requests = recordRequests()
+
+ await s3Connector.getDocument(
+ 'credential-handle',
+ { accessKeyId: 'AKIAIOSFODNN7EXAMPLE', region: 'us-east-1', bucket: 'docs-bucket' },
+ 'notes/readme.md',
+ { ...awsSessionSyncContext(SESSION) }
+ )
+
+ expect(requests[0].headers.Authorization).toContain(
+ `Credential=${SESSION.accessKeyId}/20261106/us-east-1/s3/aws4_request`
+ )
+ })
+})
diff --git a/apps/sim/connectors/s3/s3.ts b/apps/sim/connectors/s3/s3.ts
index 5e07d171339..d9686d10353 100644
--- a/apps/sim/connectors/s3/s3.ts
+++ b/apps/sim/connectors/s3/s3.ts
@@ -7,6 +7,7 @@ import { isHosted } from '@/lib/core/config/env-flags'
import { decodeTextBuffer } from '@/lib/file-parsers/utils'
import { secureFetchWithRetry } from '@/lib/knowledge/documents/secure-fetch.server'
import { VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
+import { readAwsSession } from '@/connectors/aws-session'
import { s3ConnectorMeta } from '@/connectors/s3/meta'
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
import {
@@ -52,6 +53,13 @@ const BUCKET_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{1,62}$/
/** Accepted region shape — AWS region codes plus Cloudflare R2's `auto`. */
const REGION_PATTERN = /^[A-Za-z0-9-]{1,64}$/
+/**
+ * A commercial or GovCloud AWS region code (`us-east-1`, `us-gov-west-1`): the only regions a
+ * saved AWS credential signs for, because their S3 hosts are all `s3..amazonaws.com`.
+ * China (`cn-*`, under `amazonaws.com.cn`) and the isolated partitions use other domains.
+ */
+const AWS_REGION_PATTERN = /^(?!cn-)[a-z]{2}(-gov)?-[a-z]+-\d+$/
+
/**
* Default set of file extensions considered safely text-extractable. Objects
* with any other extension (or no extension) are skipped, since their content
@@ -115,6 +123,8 @@ interface S3Endpoint {
interface S3Context {
accessKeyId: string
secretAccessKey: string
+ /** The STS session token of a saved AWS credential's role session; signed into every request. */
+ sessionToken?: string
region: string
bucket: string
endpoint?: S3Endpoint
@@ -206,20 +216,29 @@ function parseEndpoint(raw: string): S3Endpoint {
}
/**
- * Resolves AWS credentials and the target bucket from the connector's
- * sourceConfig and the encrypted secret (delivered as accessToken). When an
- * `endpoint` is configured it is parsed/validated into an {@link S3Endpoint} so
- * the connector targets an S3-compatible store via path-style addressing.
+ * Resolves AWS credentials and the target bucket.
+ *
+ * With a saved AWS credential, the server seeds its resolved keys into
+ * `syncContext` and those sign every request; the inline `accessKeyId` and the
+ * access token are not read. A saved credential signs only for Amazon S3 in an
+ * AWS region: a custom endpoint would hand the customer's AWS session to
+ * whatever host it names, so it is refused rather than ignored.
+ *
+ * Otherwise the inline key pair comes from `sourceConfig.accessKeyId` and the
+ * encrypted secret (delivered as accessToken), and an `endpoint` is parsed into
+ * an {@link S3Endpoint} so the connector targets an S3-compatible store via
+ * path-style addressing.
*/
-function resolveContext(accessToken: string, sourceConfig: Record): S3Context {
- const accessKeyId = ((sourceConfig.accessKeyId as string) ?? '').trim()
+function resolveContext(
+ accessToken: string,
+ sourceConfig: Record,
+ syncContext: Record | undefined
+): S3Context {
const region = ((sourceConfig.region as string) ?? '').trim()
const bucket = ((sourceConfig.bucket as string) ?? '').trim()
- const secretAccessKey = (accessToken ?? '').trim()
const rawEndpoint = ((sourceConfig.endpoint as string) ?? '').trim()
+ const savedSession = readAwsSession(syncContext)
- if (!accessKeyId) throw new Error('Missing AWS Access Key ID')
- if (!secretAccessKey) throw new Error('Missing AWS Secret Access Key')
if (!region) throw new Error('Missing AWS region')
if (!bucket) throw new Error('Missing S3 bucket name')
if (!REGION_PATTERN.test(region)) {
@@ -231,6 +250,25 @@ function resolveContext(accessToken: string, sourceConfig: Record
): Promise => {
- const ctx = resolveContext(accessToken, sourceConfig)
+ const ctx = resolveContext(accessToken, sourceConfig, syncContext)
const prefix = ((sourceConfig.prefix as string) ?? '').trim()
const allowedExtensions = resolveExtensions(sourceConfig.extensions)
@@ -602,9 +649,10 @@ export const s3Connector: ConnectorConfig = {
getDocument: async (
accessToken: string,
sourceConfig: Record,
- externalId: string
+ externalId: string,
+ syncContext?: Record
): Promise => {
- const ctx = resolveContext(accessToken, sourceConfig)
+ const ctx = resolveContext(accessToken, sourceConfig, syncContext)
const key = externalId
if (hasDotSegment(key)) {
@@ -680,11 +728,12 @@ export const s3Connector: ConnectorConfig = {
validateConfig: async (
accessToken: string,
- sourceConfig: Record
+ sourceConfig: Record,
+ syncContext?: Record
): Promise<{ valid: boolean; error?: string }> => {
let ctx: S3Context
try {
- ctx = resolveContext(accessToken, sourceConfig)
+ ctx = resolveContext(accessToken, sourceConfig, syncContext)
} catch (error) {
return { valid: false, error: getErrorMessage(error, 'Invalid configuration') }
}
diff --git a/apps/sim/connectors/types.ts b/apps/sim/connectors/types.ts
index 92dc23b7e44..22cb3c18dc1 100644
--- a/apps/sim/connectors/types.ts
+++ b/apps/sim/connectors/types.ts
@@ -60,6 +60,16 @@ export type ConnectorAuthConfig =
* connector receives an empty access token.
*/
optional?: boolean
+ /**
+ * A saved credential that may authenticate a workspace crawl instead of
+ * the key, such as a saved AWS credential for Amazon S3. Additive: a
+ * connector row with a stored key keeps using it, and one that names a
+ * credential resolves it for the knowledge base's own scope. Fields
+ * marked {@link ConnectorConfigField.apiKeyOnly} describe the key and
+ * do not apply to a credential. Only AWS resolves to signing keys today;
+ * another provider needs its own resolution in the connector access token.
+ */
+ savedCredential?: { provider: 'aws'; label: string }
}
/** A group in an external directory, named as the connector's ACLs name it. */
@@ -335,6 +345,12 @@ export interface ConnectorConfigField {
hideInAdminMode?: true
/** Only applies to a central crawl that mirrors source permissions. */
showInAdminModeOnly?: true
+ /**
+ * Describes the connector's own API key (an access key ID, a custom host the
+ * key signs for), so it is hidden, not required, and not sent when the
+ * connector authenticates with its `savedCredential` instead.
+ */
+ apiKeyOnly?: true
options?: { label: string; id: string }[]
/** Selector key from the selector registry (used when type is 'selector') */
diff --git a/apps/sim/content/changelog/saved-credentials-and-access/index.mdx b/apps/sim/content/changelog/saved-credentials-and-access/index.mdx
new file mode 100644
index 00000000000..b47a843521d
--- /dev/null
+++ b/apps/sim/content/changelog/saved-credentials-and-access/index.mdx
@@ -0,0 +1,38 @@
+---
+slug: saved-credentials-and-access
+title: 'Connect once and control how credentials are used'
+description: 'Sim workflows can reuse saved credentials instead of repeated keys. Keyless cloud connections and read-only API keys give teams more control.'
+date: '2026-11-10T20:00:00Z'
+updated: '2026-11-12T05:00:30Z'
+authors: [waleed]
+tags: [Workflows, Security]
+ogImage: /changelog/credentials-gateway-v1.jpg
+ogAlt: 'Monochrome illustration of two stone gateways overlooking mountain ridges'
+technical: false
+draft: false
+release:
+ versions: [v0.9.53, v0.9.54]
+---
+
+
+
+Save a connection once and select it in your workflow blocks. Keys and connection details stay in one place instead of being copied into every block, and you choose who can use the saved credential.
+
+Introduced in v0.9.53 and expanded in v0.9.54, saved credentials cover more services that previously asked for keys directly. Existing blocks keep their original settings. To use a saved connection, replace a legacy block with its current version and select the credential.
+
+### Connect without a long-lived key
+
+In v0.9.53, supported cloud services can trust Sim to obtain temporary access. Your cloud administrator configures the trust and permissions; self-hosted deployments also need workload identity configured. See the [AWS](https://docs.sim.ai/integrations/aws-credentials) and [Databricks](https://docs.sim.ai/integrations/databricks-credentials) guides.
+
+### Give scripts only the access they need
+
+Create a **Read only** API key with an expiration date under **Sim API keys** in account or workspace settings. Introduced in v0.9.53 and generally available in v0.9.54, these keys can inspect information without changing workspace resources or starting, resuming, or cancelling workflows.
+
+Allowed reads can still consume credits or call connected services. Existing keys retain full access. [API key access and expiration](https://docs.sim.ai/api-reference/authentication#key-access).
+
+Technical releases: [v0.9.53](https://github.com/simstudioai/sim/releases/tag/v0.9.53), [v0.9.54](https://github.com/simstudioai/sim/releases/tag/v0.9.54).
diff --git a/apps/sim/content/changelog/saved-credentials-and-access/production.md b/apps/sim/content/changelog/saved-credentials-and-access/production.md
new file mode 100644
index 00000000000..618f0a9918d
--- /dev/null
+++ b/apps/sim/content/changelog/saved-credentials-and-access/production.md
@@ -0,0 +1,49 @@
+# Connect once and control how credentials are used
+
+Edition identity: `manual-saved-credentials-and-access`. Canonical: https://www.sim.ai/changelog/saved-credentials-and-access.
+
+## Date and scope
+
+Retrospective edition dated 2026-11-10, using the Pacific calendar day of the release anchoring this story. This is an editorial date for the catch-up, not a claim that this article was already public then. The updated timestamp records the current review; later additions name their actual release. Browser coverage is anchored to the expanded v0.9.44 experience. Existing published article dates and RSS identities remain unchanged.
+
+Reviewed the complete change inventory since v0.9.32 through v0.9.54, then inspected substantive candidates and their released behavior. Five focused stories cover Search, browser/desktop work, credentials/access, workflow management, and media creation. Short supporting notes explain practical changes. The editorial reference was Gumloop’s direct headlines and compact grouped improvements; no competitor claims or copy were reused.
+
+Routine maintenance, refactors, dependency/CI work and marketing-library posts remain in technical history. Files pagination preserves existing search/filter capabilities and has no measured speed claim, so it is not presented as a new feature. People remains gated/reverted; scheduled-job runtime has no public creation path; Marketplace rollout was not established. Model and integration counts do not displace stronger stories. No forced padding.
+
+## Sources
+
+| Source | Change reviewed | Published release |
+| --- | --- | --- |
+| [#9763](https://github.com/simstudioai/sim/pull/9763) | feat(credentials): saved credentials and credential-only block versions, with keyless AWS/Databricks OIDC | v0.9.53 |
+| [#9840](https://github.com/simstudioai/sim/pull/9840) | feat(credentials): saved credentials for 17 more blocks, GitLab tokens, and keyless hardening | v0.9.53 |
+| [#9849](https://github.com/simstudioai/sim/pull/9849) | feat(credentials): saved credentials for 21 token-and-host blocks on one host-aware check factory | v0.9.53 |
+| [#9869](https://github.com/simstudioai/sim/pull/9869) | feat(credentials): keyless Snowflake, Microsoft app-only and Google keyless, GitHub and Bitbucket tokens, DocuSign JWT | v0.9.53 |
+| [#9875](https://github.com/simstudioai/sim/pull/9875) | feat(credentials): set up keyless credentials from the v2 API, CLI and Sim MCP | v0.9.53 |
+| [#9855](https://github.com/simstudioai/sim/pull/9855) | feat(api-keys): read-only API keys and expiry at creation | v0.9.53 |
+| [#9879](https://github.com/simstudioai/sim/pull/9879) | chore(api-keys): remove the api-key-read-only flag | v0.9.54 |
+| [#9911](https://github.com/simstudioai/sim/pull/9911) | feat(credentials): audit fixes and non-OAuth auth for CrowdStrike, Datadog, Gong, PlanetScale, SailPoint, ServiceNow, Zendesk, ZoomInfo, Shopify, Atlassian, Linear and Twilio | v0.9.54 |
+
+Release membership uses explicit PR references. Every listed merge commit is an ancestor of the successfully deployed v0.9.54 production release. Current code, live documentation, availability and access restrictions were reviewed. No new provider write, paid generation, keyless cloud setup or workflow execution is claimed. The canonical article URL supplies a new RSS GUID; no technical-release GUID is reused.
+
+## Media
+
+Original generated editorial illustration, 1672 × 941. Prompt: “Use case: stylized-concept. Create an original editorial illustration for a Sim changelog article about reusable credentials and carefully controlled access. Wide 16:9 composition. A quiet monumental pale concrete gateway with two offset rectangular openings, viewed obliquely across a rocky plateau, distant mountain ridges visible through the opening. Architectural clarity, soft cloudy daylight, painterly graphite and gouache texture, strictly grayscale, restrained mid-gray contrast, fine natural texture. Sophisticated calm landscape like a hand-painted architectural study. No people, no locks or keys as icons, no text, no logos, no interface, no color, no borders. Single complete image.”
+
+This is conceptual art, not product UI or evidence of an execution. Independently verified behavior comes from the source changes and live product/docs.
+
+Final immutable asset: `/changelog/credentials-gateway-v1.jpg`. The shared media frame, radius and lightbox are unchanged. Original files and generation provenance are preserved privately. No credentials, private workspace content, performance claims or generated product controls appear in the asset.
+
+## Review
+
+- [x] Claim/source/release and access review completed.
+- [x] Media inspected at native dimensions.
+- [x] Revised article desktop/mobile layout, links, metadata and discovery checked.
+- [x] Required local repository gates completed; test suites are left to CI.
+- [ ] Both current-head reviews and CI completed.
+- [ ] Actual staging deployment and hosted QA completed.
+
+## Announcement draft
+
+Sim workflows can reuse saved credentials instead of repeated keys. Keyless cloud connections and read-only API keys give teams more control. https://www.sim.ai/changelog/saved-credentials-and-access
+
+Distribution remains manual and requires verification of the production article. Staging publication alone does not establish production publication.
diff --git a/apps/sim/content/changelog/search-across-sources/index.mdx b/apps/sim/content/changelog/search-across-sources/index.mdx
index c9a7d5a42a2..7d74dc93bd5 100644
--- a/apps/sim/content/changelog/search-across-sources/index.mdx
+++ b/apps/sim/content/changelog/search-across-sources/index.mdx
@@ -1,8 +1,9 @@
---
slug: search-across-sources
title: 'More relevant results across your connected sources'
-description: 'Sim Search surfaces closer matches across connected sources. Also: sign out other devices and edit more of your Excel workbooks.'
+description: 'Sim Search surfaces closer matches across connected sources. This update also expands editing for Microsoft Excel workbooks.'
date: '2026-10-19T16:00:00Z'
+updated: '2026-11-12T03:30:00Z'
authors: [waleed]
tags: [Search, Workspace]
ogImage: /changelog/search-clear-path-v1.jpg
@@ -26,7 +27,7 @@ Introduced in v0.9.25, this applies to keyword searches sorted by relevance in S
### Also in v0.9.26
-- **Sign out other devices** in Settings → General ends your other sessions while keeping your current session and connected apps; sign-out can take up to five minutes.
+- **Sign out other devices** was added to Settings → General in this release. The control was later removed in v0.9.42 and is no longer available there.
### Also in v0.9.27
diff --git a/apps/sim/content/changelog/search-across-sources/production.md b/apps/sim/content/changelog/search-across-sources/production.md
index 38f94340ec4..3330fefbc1f 100644
--- a/apps/sim/content/changelog/search-across-sources/production.md
+++ b/apps/sim/content/changelog/search-across-sources/production.md
@@ -52,3 +52,7 @@ Final asset: `/changelog/search-clear-path-v1.jpg`, 1672 × 941, optimized JPEG
**Email:** Find more relevant results across the sources you use in Sim. Keyword searches compare matches across apps, including short terms such as AI and VM. You can also sign out other devices from General settings and use expanded Excel editing with a Microsoft business account. Read the update: https://www.sim.ai/changelog/search-across-sources
Do not distribute before the production article is verified. The article carries the full access and reconnection requirements.
+
+## November 11 historical correction
+
+Source #9595, explicitly released in v0.9.42, removed Sign out other devices from General settings. The article now states that current availability, with a substantive updated date. Its original publication date, slug, media and canonical RSS identity are preserved. The original v0.9.26 release claim remains historical rather than presenting a removed control as available.
diff --git a/apps/sim/content/changelog/search-actions-and-browser/index.mdx b/apps/sim/content/changelog/search-actions-and-browser/index.mdx
new file mode 100644
index 00000000000..d6a567e4059
--- /dev/null
+++ b/apps/sim/content/changelog/search-actions-and-browser/index.mdx
@@ -0,0 +1,34 @@
+---
+slug: search-actions-and-browser
+title: 'Find answers and take action without leaving Search'
+description: 'Sim Search turns findings into reviewed actions. Connect multiple accounts, cite web sources, and share useful destinations with short team links.'
+date: '2026-10-30T20:00:00Z'
+updated: '2026-11-12T05:00:30Z'
+authors: [waleed]
+tags: [Search, Workspace]
+ogImage: /changelog/search-action-review-v1.jpg
+ogAlt: 'Search Assistant showing two Jira issue proposals with Edit, Allow, and Cancel controls'
+technical: false
+draft: false
+release:
+ versions: [v0.9.37, v0.9.43, v0.9.45]
+---
+
+
+
+Find the information you need, then act on it in the same conversation. Search Assistant can draft an email or create a Jira issue using your connected account. Writes ask for review by default: check the destination and content, edit supported fields, then allow or cancel.
+
+Introduced in v0.9.37, actions follow your account permissions and organization’s source restrictions. Set **Allow**, **Ask**, or **Block** for supported tools under Integrations. Your organization must enable Search and the relevant apps. [Review actions](https://docs.sim.ai/search/ask-questions#review-actions).
+
+### More ways to find and share
+
+- **v0.9.43 — Web citations:** When enabled, Assistant can fill gaps with public web results and cite them alongside your connected sources.
+- **v0.9.43 — Team shortcuts:** Create or copy a short `sim/` link from a Search result, Assistant citation, or file’s sharing dialog when Links is enabled for your organization. [Share a team link](https://docs.sim.ai/search/sim-links).
+- **v0.9.45 — Multiple accounts:** Search across up to five accounts per supported app without disconnecting one to use another; some providers still allow one. [Connect accounts](https://docs.sim.ai/search/connect-your-account).
+
+Technical releases: [v0.9.37](https://github.com/simstudioai/sim/releases/tag/v0.9.37), [v0.9.43](https://github.com/simstudioai/sim/releases/tag/v0.9.43), [v0.9.45](https://github.com/simstudioai/sim/releases/tag/v0.9.45).
diff --git a/apps/sim/content/changelog/search-actions-and-browser/production.md b/apps/sim/content/changelog/search-actions-and-browser/production.md
new file mode 100644
index 00000000000..b3fa662dc57
--- /dev/null
+++ b/apps/sim/content/changelog/search-actions-and-browser/production.md
@@ -0,0 +1,44 @@
+# Find answers and take action without leaving Search
+
+Edition identity: `manual-search-actions-and-browser`. Canonical: https://www.sim.ai/changelog/search-actions-and-browser.
+
+## Date and scope
+
+Retrospective edition dated 2026-10-30, using the Pacific calendar day of the release anchoring this story. This is an editorial date for the catch-up, not a claim that this article was already public then. The updated timestamp records the current review; later additions name their actual release. Browser coverage is anchored to the expanded v0.9.44 experience. Existing published article dates and RSS identities remain unchanged.
+
+Reviewed the complete change inventory since v0.9.32 through v0.9.54, then inspected substantive candidates and their released behavior. Five focused stories cover Search, browser/desktop work, credentials/access, workflow management, and media creation. Short supporting notes explain practical changes. The editorial reference was Gumloop’s direct headlines and compact grouped improvements; no competitor claims or copy were reused.
+
+Routine maintenance, refactors, dependency/CI work and marketing-library posts remain in technical history. Files pagination preserves existing search/filter capabilities and has no measured speed claim, so it is not presented as a new feature. People remains gated/reverted; scheduled-job runtime has no public creation path; Marketplace rollout was not established. Model and integration counts do not displace stronger stories. No forced padding.
+
+## Sources
+
+| Source | Change reviewed | Published release |
+| --- | --- | --- |
+| [#9428](https://github.com/simstudioai/sim/pull/9428) | feat(search): unify organization search and reviewed assistant actions | v0.9.37 |
+| [#9451](https://github.com/simstudioai/sim/pull/9451) | fix(search): inline action review and unify account menus | v0.9.37 |
+| [#9647](https://github.com/simstudioai/sim/pull/9647) | improvement(search): cite public web results like connected sources in Search Assistant | v0.9.43 |
+| [#9648](https://github.com/simstudioai/sim/pull/9648) | feat(sim-links): create or copy a Sim link from search results and file shares | v0.9.43 |
+| [#9716](https://github.com/simstudioai/sim/pull/9716) | feat(search): connect multiple accounts per integration | v0.9.45 |
+
+Release membership uses explicit PR references. Every listed merge commit is an ancestor of the successfully deployed v0.9.54 production release. Current code, live documentation, availability and access restrictions were reviewed. No new provider write, paid generation, keyless cloud setup or workflow execution is claimed. The canonical article URL supplies a new RSS GUID; no technical-release GUID is reused.
+
+## Media
+
+Existing real product capture from the Search action-review documentation introduced with #9428, using sample data. Reused from `apps/docs/public/static/search/assistant-action-review.png`, 1440 × 1000, with aspect ratio preserved. It shows review proposals, not successful Jira delivery. The original feature QA used controlled provider transports; no new provider write was performed for this article.
+
+Final immutable asset: `/changelog/search-action-review-v1.jpg`. The shared media frame, radius and lightbox are unchanged. Original files and generation provenance are preserved privately. No credentials, private workspace content, performance claims or generated product controls appear in the asset.
+
+## Review
+
+- [x] Claim/source/release and access review completed.
+- [x] Media inspected at native dimensions.
+- [x] Revised article desktop/mobile layout, links, metadata and discovery checked.
+- [x] Required local repository gates completed; test suites are left to CI.
+- [ ] Both current-head reviews and CI completed.
+- [ ] Actual staging deployment and hosted QA completed.
+
+## Announcement draft
+
+Sim Search turns findings into reviewed actions. Connect multiple accounts, cite web sources, and share useful destinations with short team links. https://www.sim.ai/changelog/search-actions-and-browser
+
+Distribution remains manual and requires verification of the production article. Staging publication alone does not establish production publication.
diff --git a/apps/sim/content/changelog/sim-in-your-browser/index.mdx b/apps/sim/content/changelog/sim-in-your-browser/index.mdx
new file mode 100644
index 00000000000..628e6068e41
--- /dev/null
+++ b/apps/sim/content/changelog/sim-in-your-browser/index.mdx
@@ -0,0 +1,40 @@
+---
+slug: sim-in-your-browser
+title: 'Ask Sim about the page you’re reading'
+description: 'Bring pages and selected passages into Sim from Chrome. Add context from several tabs, dictate a draft, and open your team’s shared shortcuts.'
+date: '2026-11-04T20:00:00Z'
+updated: '2026-11-12T05:00:30Z'
+authors: [waleed]
+tags: [Chat, Workspace]
+ogImage: /changelog/browser-open-window-v1.jpg
+ogAlt: 'Monochrome illustration of a stone observatory opening onto a misty mountain valley'
+technical: false
+draft: false
+release:
+ versions: [v0.9.37, v0.9.39, v0.9.44]
+---
+
+
+
+Ask a question about the page in front of you without copying it into another tab. The [Sim extension for Chrome](https://docs.sim.ai/browser-extension), introduced in v0.9.37 and expanded in v0.9.44, brings page text or a selected passage into your conversation.
+
+Add another page for context, dictate a question, and edit the draft before sending. You control which pages are attached. Chat requires a paid Sim plan or available prepaid credits.
+
+With Links enabled for your organization, type a shared `go/` or `sim/` shortcut in Chrome’s address bar to open a team destination.
+
+### Also in Sim Desktop
+
+These improvements shipped in v0.9.39:
+
+- **Finish passkey sign-in:** On macOS, continue a website’s sign-in in an installed Chrome-family browser, then bring its session back to Sim.
+- **Familiar browser controls:** Use keyboard shortcuts to switch tabs and move back or forward, save or copy images from their menu, and watch fullscreen pages inside the browser panel.
+- **Update when you’re ready:** Restart to install a downloaded update in one click when idle, with a warning if work could be interrupted; your app windows reopen afterward.
+
+[Download Sim Desktop and see the setup guide](https://docs.sim.ai/desktop).
+
+Technical releases: [v0.9.37](https://github.com/simstudioai/sim/releases/tag/v0.9.37), [v0.9.39](https://github.com/simstudioai/sim/releases/tag/v0.9.39), [v0.9.44](https://github.com/simstudioai/sim/releases/tag/v0.9.44).
diff --git a/apps/sim/content/changelog/sim-in-your-browser/production.md b/apps/sim/content/changelog/sim-in-your-browser/production.md
new file mode 100644
index 00000000000..b8afa3b980b
--- /dev/null
+++ b/apps/sim/content/changelog/sim-in-your-browser/production.md
@@ -0,0 +1,46 @@
+# Ask Sim about the page you’re reading
+
+Edition identity: `manual-sim-in-your-browser`. Canonical: https://www.sim.ai/changelog/sim-in-your-browser.
+
+## Date and scope
+
+Retrospective edition dated 2026-11-04, using the Pacific calendar day of the release anchoring this story. This is an editorial date for the catch-up, not a claim that this article was already public then. The updated timestamp records the current review; later additions name their actual release. Browser coverage is anchored to the expanded v0.9.44 experience. Existing published article dates and RSS identities remain unchanged.
+
+Reviewed the complete change inventory since v0.9.32 through v0.9.54, then inspected substantive candidates and their released behavior. Five focused stories cover Search, browser/desktop work, credentials/access, workflow management, and media creation. Short supporting notes explain practical changes. The editorial reference was Gumloop’s direct headlines and compact grouped improvements; no competitor claims or copy were reused.
+
+Routine maintenance, refactors, dependency/CI work and marketing-library posts remain in technical history. Files pagination preserves existing search/filter capabilities and has no measured speed claim, so it is not presented as a new feature. People remains gated/reverted; scheduled-job runtime has no public creation path; Marketplace rollout was not established. Model and integration counts do not displace stronger stories. No forced padding.
+
+## Sources
+
+| Source | Change reviewed | Published release |
+| --- | --- | --- |
+| [#9454](https://github.com/simstudioai/sim/pull/9454) | feat(extension): bring Sim chat and page context to Chrome | v0.9.37 |
+| [#9515](https://github.com/simstudioai/sim/pull/9515) | feat(desktop): continue a passkey sign-in in your browser and bring the session back | v0.9.39 |
+| [#9512](https://github.com/simstudioai/sim/pull/9512) | improvement(desktop): bring agent browser menus, keys and fullscreen in line with Chrome | v0.9.39 |
+| [#9505](https://github.com/simstudioai/sim/pull/9505) | improvement(desktop): install updates on one click, asking only about real in-flight work | v0.9.39 |
+| [#9533](https://github.com/simstudioai/sim/pull/9533) | fix(desktop): ask before an update restart from the menu, count uploads and downloads as running work, and harden sign-in prompts | v0.9.39 |
+| [#9547](https://github.com/simstudioai/sim/pull/9547) | fix(desktop): address release review — count app downloads and unchecked terminals before an update restart, send the page referrer on Save Image, and tighten sign-in and people edge cases | v0.9.39 |
+| [#9683](https://github.com/simstudioai/sim/pull/9683) | improvement(extension): simplify page access and enable voice input | v0.9.44 |
+| [#9689](https://github.com/simstudioai/sim/pull/9689) | improvement(extension): allow more pages within a shared content budget | v0.9.44 |
+| [#9698](https://github.com/simstudioai/sim/pull/9698) | improvement(links): make go/ and sim/ work with no setup | v0.9.44 |
+
+Release membership uses explicit PR references. Every listed merge commit is an ancestor of the successfully deployed v0.9.54 production release. Current code, live documentation, availability and access restrictions were reviewed. No new provider write, paid generation, keyless cloud setup or workflow execution is claimed. The canonical article URL supplies a new RSS GUID; no technical-release GUID is reused.
+
+## Media
+
+Original generated editorial illustration of an open stone observatory and mountain valley, 1672 × 941. Built-in image generation; no product UI, text, logos or invented results. The artwork expresses taking a workspace beyond a window and does not prove extension or desktop behavior. The full prompt and original output are retained with the art provenance. Final asset: `/changelog/browser-open-window-v1.jpg`. Shared media frame and lightbox unchanged.
+
+## Review
+
+- [x] Claim/source/release and access review completed.
+- [x] Media inspected at native dimensions.
+- [x] Revised article desktop/mobile layout, links, metadata and discovery checked.
+- [x] Required local repository gates completed; test suites are left to CI.
+- [ ] Both current-head reviews and CI completed.
+- [ ] Actual staging deployment and hosted QA completed.
+
+## Announcement draft
+
+Bring pages and selected passages into Sim from Chrome. Add context from several tabs, dictate a draft, and open your team’s shared shortcuts. https://www.sim.ai/changelog/sim-in-your-browser
+
+Distribution remains manual and requires verification of the production article. Staging publication alone does not establish production publication.
diff --git a/apps/sim/content/changelog/video-from-your-image/index.mdx b/apps/sim/content/changelog/video-from-your-image/index.mdx
new file mode 100644
index 00000000000..b44601a77e8
--- /dev/null
+++ b/apps/sim/content/changelog/video-from-your-image/index.mdx
@@ -0,0 +1,35 @@
+---
+slug: video-from-your-image
+title: 'Start a video from your own image'
+description: 'Sim can use your image as the starting frame for supported fal.ai videos, with more image and video model choices in workflow generators.'
+date: '2026-11-07T20:00:00Z'
+updated: '2026-11-12T05:00:30Z'
+authors: [waleed]
+tags: [Workflows, Media]
+ogImage: /changelog/video-still-water-v1.jpg
+ogAlt: 'Monochrome illustration of a sailboat crossing still water beneath drifting mountain fog'
+technical: false
+draft: false
+release:
+ versions: [v0.9.43, v0.9.45, v0.9.47, v0.9.48]
+---
+
+
+
+Start with an image, then describe what happens next. In v0.9.48, Video Generator can use an uploaded image or a file from an earlier step as the starting frame for supported fal.ai models.
+
+Choose a compatible model to reveal **Reference Image**. Duration, resolution, aspect ratio, and audio options follow that model’s capabilities. Models without start-frame support remain text-to-video only.
+
+Use your own fal.ai key, or Sim’s hosted access with an eligible paid plan or prepaid credits. Generation is billed by usage. Send the result to another step or [save it to workspace Files](https://docs.sim.ai/files/generating#save-an-existing-file).
+
+### More creative options
+
+- **v0.9.43:** Image Generator and Video Generator expose each supported model’s settings, so you can choose the format and controls your task needs.
+- **v0.9.47:** [Higgsfield](/integrations/higgsfield) joins both generators. Custom models accept a prompt only; custom fal.ai endpoints require your own key. [Higgsfield setup](https://docs.sim.ai/integrations/higgsfield).
+
+Technical releases: [v0.9.43](https://github.com/simstudioai/sim/releases/tag/v0.9.43), [v0.9.45](https://github.com/simstudioai/sim/releases/tag/v0.9.45), [v0.9.47](https://github.com/simstudioai/sim/releases/tag/v0.9.47), [v0.9.48](https://github.com/simstudioai/sim/releases/tag/v0.9.48).
diff --git a/apps/sim/content/changelog/video-from-your-image/production.md b/apps/sim/content/changelog/video-from-your-image/production.md
new file mode 100644
index 00000000000..a5917d8d222
--- /dev/null
+++ b/apps/sim/content/changelog/video-from-your-image/production.md
@@ -0,0 +1,47 @@
+# Start a video from your own image
+
+Edition identity: `2026-11-09`. Canonical: https://www.sim.ai/changelog/video-from-your-image.
+
+## Date and scope
+
+Retrospective edition dated 2026-11-07, using the Pacific calendar day of the release anchoring this story. This is an editorial date for the catch-up, not a claim that this article was already public then. The updated timestamp records the current review; later additions name their actual release. Browser coverage is anchored to the expanded v0.9.44 experience. Existing published article dates and RSS identities remain unchanged.
+
+Reviewed the complete change inventory since v0.9.32 through v0.9.54, then inspected substantive candidates and their released behavior. Five focused stories cover Search, browser/desktop work, credentials/access, workflow management, and media creation. Short supporting notes explain practical changes. The editorial reference was Gumloop’s direct headlines and compact grouped improvements; no competitor claims or copy were reused.
+
+Routine maintenance, refactors, dependency/CI work and marketing-library posts remain in technical history. Files pagination preserves existing search/filter capabilities and has no measured speed claim, so it is not presented as a new feature. People remains gated/reverted; scheduled-job runtime has no public creation path; Marketplace rollout was not established. Model and integration counts do not displace stronger stories. No forced padding.
+
+## Sources
+
+| Source | Change reviewed | Published release |
+| --- | --- | --- |
+| [#9761](https://github.com/simstudioai/sim/pull/9761) | feat(video): accept a start frame image for fal.ai video models | v0.9.48 |
+| [#9787](https://github.com/simstudioai/sim/pull/9787) | fix(video): read the active start frame input when serializing video settings | v0.9.48 |
+| [#9640](https://github.com/simstudioai/sim/pull/9640) | feat(video): expand the fal text-to-video model catalog | v0.9.43 |
+| [#9653](https://github.com/simstudioai/sim/pull/9653) | feat(image-generator): refresh provider model catalogs and validate requests | v0.9.43 |
+| [#9756](https://github.com/simstudioai/sim/pull/9756) | feat(media): add Higgsfield to video and image generators, allow typed models | v0.9.47 |
+| [#9741](https://github.com/simstudioai/sim/pull/9741) | fix(billing): withhold hosted Fal.ai video generation from free-plan payers | v0.9.45 |
+
+Release membership uses explicit PR references. Every listed merge commit is an ancestor of the successfully deployed v0.9.54 production release. Current code, live documentation, availability and access restrictions were reviewed. No new provider write, paid generation, keyless cloud setup or workflow execution is claimed. The canonical article URL supplies a new RSS GUID; no technical-release GUID is reused.
+
+## Media
+
+Original generated editorial illustration, 1672 × 941. Prompt: “Use case: stylized-concept. Original editorial illustration for a Sim changelog article about turning a still reference image into a video. Wide 16:9 composition. A quiet coastal inlet with a small sailboat, a broad luminous bank of fog drifting between distant layered mountains and low rocky islands, subtle ripple arcs across still water. Hand-painted graphite and gouache look with delicate paper grain, strictly grayscale, soft silver light, restrained contrast, cinematic wide framing, quiet motion suggested through water and cloud shapes. No words, no logos, no interface, no film frames or equipment, no decorative border. Single complete image.”
+
+This is conceptual art, not product UI or evidence of an execution. Independently verified behavior comes from the source changes and live product/docs.
+
+Final immutable asset: `/changelog/video-still-water-v1.jpg`. The shared media frame, radius and lightbox are unchanged. Original files and generation provenance are preserved privately. No credentials, private workspace content, performance claims or generated product controls appear in the asset.
+
+## Review
+
+- [x] Claim/source/release and access review completed.
+- [x] Media inspected at native dimensions.
+- [x] Revised article desktop/mobile layout, links, metadata and discovery checked.
+- [x] Required local repository gates completed; test suites are left to CI.
+- [ ] Both current-head reviews and CI completed.
+- [ ] Actual staging deployment and hosted QA completed.
+
+## Announcement draft
+
+Sim can use your image as the starting frame for supported fal.ai videos, with more image and video model choices in workflow generators. https://www.sim.ai/changelog/video-from-your-image
+
+Distribution remains manual and requires verification of the production article. Staging publication alone does not establish production publication.
diff --git a/apps/sim/content/changelog/workflow-run-controls/index.mdx b/apps/sim/content/changelog/workflow-run-controls/index.mdx
new file mode 100644
index 00000000000..e3ed5943306
--- /dev/null
+++ b/apps/sim/content/changelog/workflow-run-controls/index.mdx
@@ -0,0 +1,41 @@
+---
+slug: workflow-run-controls
+title: 'See which workflows need your attention'
+description: 'Sim makes paused runs and schedules easier to inspect from the CLI or API, and lets you run an earlier deployed version without changing the active one.'
+date: '2026-11-11T20:00:00Z'
+updated: '2026-11-12T05:00:30Z'
+authors: [waleed]
+tags: [Workflows, Tables]
+ogImage: /changelog/workflow-paths-v1.jpg
+ogAlt: 'Monochrome illustration of two roads meeting around a mountain ridge'
+technical: false
+draft: false
+release:
+ versions: [v0.9.37, v0.9.47, v0.9.53, v0.9.54]
+---
+
+
+
+Find the work waiting on a person without opening every workflow. In v0.9.54, the [Sim CLI](https://docs.sim.ai/cli/workflows) and API can list paused runs across a workspace, including the review or input each run needs. Details follow your access permissions.
+
+You can also [list workspace schedules](https://docs.sim.ai/cli/schedules) to see their timing and health together. Change timing in the workflow and redeploy it.
+
+### Keep work moving
+
+- **v0.9.37 — Long runs:** Set a wait limit when starting a workflow through the CLI or API; if it’s still running, it continues in the background and you can check back later, within your plan’s execution limits.
+- **v0.9.53 — Earlier versions:** Run a previous deployed version through the API without changing the active version; this requires Write access. [Run a deployed version](https://docs.sim.ai/workflows/deployment/api).
+- **v0.9.53 — Trigger controls:** Inspect trigger health or disable a deployed trigger through the CLI or API; disabling lasts until the next deployment or version activation and leaves running work alone.
+
+### Work with files
+
+- **v0.9.47:** New CSV columns keep leading-zero ZIP codes and SKUs as text.
+- **v0.9.53:** Mistral Parser V3 can extract images from an uploaded or referenced PDF as files for later steps, and return tables as Markdown or HTML. [Parser guide](https://docs.sim.ai/integrations/mistral_parse).
+
+The legacy `simstudio` CLI and Python and TypeScript SDKs were retired in v0.9.53. Use the current Sim CLI or HTTP API.
+
+Technical releases: [v0.9.37](https://github.com/simstudioai/sim/releases/tag/v0.9.37), [v0.9.47](https://github.com/simstudioai/sim/releases/tag/v0.9.47), [v0.9.53](https://github.com/simstudioai/sim/releases/tag/v0.9.53), [v0.9.54](https://github.com/simstudioai/sim/releases/tag/v0.9.54).
diff --git a/apps/sim/content/changelog/workflow-run-controls/production.md b/apps/sim/content/changelog/workflow-run-controls/production.md
new file mode 100644
index 00000000000..d305422b04a
--- /dev/null
+++ b/apps/sim/content/changelog/workflow-run-controls/production.md
@@ -0,0 +1,51 @@
+# See which workflows need your attention
+
+Edition identity: `manual-workflow-run-controls`. Canonical: https://www.sim.ai/changelog/workflow-run-controls.
+
+## Date and scope
+
+Retrospective edition dated 2026-11-11, using the Pacific calendar day of the release anchoring this story. This is an editorial date for the catch-up, not a claim that this article was already public then. The updated timestamp records the current review; later additions name their actual release. Browser coverage is anchored to the expanded v0.9.44 experience. Existing published article dates and RSS identities remain unchanged.
+
+Reviewed the complete change inventory since v0.9.32 through v0.9.54, then inspected substantive candidates and their released behavior. Five focused stories cover Search, browser/desktop work, credentials/access, workflow management, and media creation. Short supporting notes explain practical changes. The editorial reference was Gumloop’s direct headlines and compact grouped improvements; no competitor claims or copy were reused.
+
+Routine maintenance, refactors, dependency/CI work and marketing-library posts remain in technical history. Files pagination preserves existing search/filter capabilities and has no measured speed claim, so it is not presented as a new feature. People remains gated/reverted; scheduled-job runtime has no public creation path; Marketplace rollout was not established. Model and integration counts do not displace stronger stories. No forced padding.
+
+## Sources
+
+| Source | Change reviewed | Published release |
+| --- | --- | --- |
+| [#9854](https://github.com/simstudioai/sim/pull/9854) | feat(api): run a pinned deployment version and expose paused run points in v2 | v0.9.53 |
+| [#9853](https://github.com/simstudioai/sim/pull/9853) | feat(api): workflow trigger health and enablement through the v2 API | v0.9.53 |
+| [#9905](https://github.com/simstudioai/sim/pull/9905) | feat(api): workflow outline, workspace schedules list, clearer v2 errors, and catalog value types | v0.9.54 |
+| [#9906](https://github.com/simstudioai/sim/pull/9906) | feat(api): failing block on polled runs, deployed input schema, paused-runs list, and applied limits | v0.9.54 |
+| [#9856](https://github.com/simstudioai/sim/pull/9856) | feat(mistral): extract PDF images as files and expose table format in Mistral Parser | v0.9.53 |
+| [#9872](https://github.com/simstudioai/sim/pull/9872) | fix(mistral): say V3 parses an uploaded or referenced PDF, and bound Max Images at 100 | v0.9.53 |
+| [#9754](https://github.com/simstudioai/sim/pull/9754) | fix(tables): keep leading-zero codes as text when inferring CSV column types | v0.9.47 |
+| [#9848](https://github.com/simstudioai/sim/pull/9848) | chore(sdk): remove the TypeScript and Python SDKs | v0.9.53 |
+| [#9830](https://github.com/simstudioai/sim/pull/9830) | fix(cli): bind sim-setup's containers to loopback, fix reset and env quoting, retire the legacy simstudio CLI | v0.9.53 |
+| [#9464](https://github.com/simstudioai/sim/pull/9464) | feat(workflows): let a synchronous run continue in the background after a wait timeout | v0.9.37 |
+
+Release membership uses explicit PR references. Every listed merge commit is an ancestor of the successfully deployed v0.9.54 production release. Current code, live documentation, availability and access restrictions were reviewed. No new provider write, paid generation, keyless cloud setup or workflow execution is claimed. The canonical article URL supplies a new RSS GUID; no technical-release GUID is reused.
+
+## Media
+
+Original generated editorial illustration, 1672 × 941. Prompt: “Use case: stylized-concept. Original editorial illustration for a Sim changelog article about choosing workflow versions and seeing work waiting for review. Wide 16:9 composition, aerial view of a mountain valley with a thin winding road splitting briefly into two routes around a central rocky ridge and joining again in the distance. Delicate painterly graphite/gouache texture, strict monochrome grayscale, soft overcast light, broad natural shapes, atmospheric depth, understated detail. Calm and spacious, polished landscape painting, no text, labels, diagrams, interface, people, logos, or colorful accents. Single complete image.”
+
+This is conceptual art, not product UI or evidence of an execution. Independently verified behavior comes from the source changes and live product/docs.
+
+Final immutable asset: `/changelog/workflow-paths-v1.jpg`. The shared media frame, radius and lightbox are unchanged. Original files and generation provenance are preserved privately. No credentials, private workspace content, performance claims or generated product controls appear in the asset.
+
+## Review
+
+- [x] Claim/source/release and access review completed.
+- [x] Media inspected at native dimensions.
+- [x] Revised article desktop/mobile layout, links, metadata and discovery checked.
+- [x] Required local repository gates completed; test suites are left to CI.
+- [ ] Both current-head reviews and CI completed.
+- [ ] Actual staging deployment and hosted QA completed.
+
+## Announcement draft
+
+Sim makes paused runs and schedules easier to inspect from the CLI or API, and lets you run an earlier deployed version without changing the active one. https://www.sim.ai/changelog/workflow-run-controls
+
+Distribution remains manual and requires verification of the production article. Staging publication alone does not establish production publication.
diff --git a/apps/sim/content/library/ai-agent-platform-vendor-viability-funding-adoption-staying-power/index.mdx b/apps/sim/content/library/ai-agent-platform-vendor-viability-funding-adoption-staying-power/index.mdx
new file mode 100644
index 00000000000..54d9f5b75fc
--- /dev/null
+++ b/apps/sim/content/library/ai-agent-platform-vendor-viability-funding-adoption-staying-power/index.mdx
@@ -0,0 +1,195 @@
+---
+slug: ai-agent-platform-vendor-viability-funding-adoption-staying-power
+title: 'AI Agent Platform Vendor Viability: Funding, Adoption, and Staying Power Compared'
+description: 'Compare AI agent platform viability by funding, production evidence, licensing, portability, documentation, and continuity planning.'
+seoTitle: 'AI Agent Vendor Viability and Staying Power'
+date: 2026-11-12
+updated: 2026-11-12
+authors:
+ - andrew
+readingTime: 10
+tags: [AI Agents, Vendor Viability, Enterprise AI, Open Source, Procurement, Sim]
+ogImage: /library/ai-agent-platform-vendor-viability-funding-adoption-staying-power/cover.jpg
+draft: false
+faq:
+ - q: "which ai agent builders are backed by strong funding or companies"
+ a: "Sim, n8n, Workato, Gumloop, Dify, and Relevance AI have disclosed institutional backing, while Microsoft Copilot Studio is backed by Microsoft. Funding is only one continuity signal, so buyers should also examine production references, renewal evidence, licensing, portability, support, and recovery options."
+ - q: "what ai agent tools do people actually use in production"
+ a: "Sim, n8n, Zapier, Workato, Dify, Relevance AI, Gumloop, and Microsoft publish evidence of customer or production use. Buyers should validate vendor-published evidence through reference calls that cover workload size, deployment duration, reliability, renewal, and support experience."
+ - q: "most recommended ai agent platform on reddit"
+ a: "Sim fits teams that prioritize an Apache 2.0 core and self-hosting, while n8n is also frequently considered by automation-focused teams. Reddit recommendations are not audited adoption evidence and should be checked against production references, licensing, portability, and support."
+ - q: "what ai agent platform has the best documentation for onboarding"
+ a: "Sim, n8n, Dify, Microsoft Copilot Studio, Zapier, Workato, Gumloop, and Relevance AI all provide public documentation, but documentation quality depends on whether the buyer's team can complete a production-relevant deployment and troubleshoot a failure quickly. Buyers should benchmark onboarding with the same task and time limit across every finalist."
+ - q: "Which AI agent platform has the lowest vendor continuity risk?"
+ a: "Continuity risk depends on the buyer's operating model, but Sim fits teams that want an Apache 2.0 core they can continue to inspect and operate if the commercial service changes. Buyers should separately assess the Sim Enterprise License for enterprise-directory features and evaluate financial and operational continuity through reference checks."
+ - q: "Does strong funding guarantee that an AI agent vendor will survive?"
+ a: "No AI agent vendor can guarantee survival through funding alone. A large or recent round can support investment, but customer retention, operating discipline, product delivery, support quality, licensing, and portability determine whether that backing reduces buyer risk."
+ - q: "How do you verify that an AI agent platform is used in production?"
+ a: "A CIO should verify production adoption by speaking with comparable reference customers and reviewing workload scale, deployment duration, incident history, renewal, and support outcomes. Vendor case studies are useful starting evidence but should not be treated as independent proof."
+ - q: "Is Sim open source?"
+ a: "Sim's core is open source under the OSI-approved Apache 2.0 license. Code in apps/sim/ee is governed by the separate Sim Enterprise License, and production use of that code requires an active Sim Enterprise subscription."
+ - q: "Is n8n open source?"
+ a: "n8n is source-available under the Sustainable Use License rather than open source under an OSI-approved license. The license permits common internal and self-hosted uses but includes restrictions that buyers should review for their intended deployment and commercial model."
+ - q: "Is Dify open source?"
+ a: "Dify publishes its source code and supports self-hosting under a license based on Apache 2.0 with additional conditions. Buyers should review those conditions instead of treating Dify's license as unmodified Apache 2.0."
+ - q: "Is self-hosting enough to avoid vendor lock-in?"
+ a: "Self-hosting alone does not eliminate vendor lock-in because a workflow may still depend on proprietary schemas, hosted control planes, licensed enterprise components, or nonportable credentials. Buyers should test export, restoration, execution, version recovery, and dependency replacement in a clean environment."
+ - q: "What should an AI agent vendor continuity plan include?"
+ a: "An AI agent vendor continuity plan should include workflow exports, data exports, credential replacement procedures, source or runtime access, recovery tests, support escalation contacts, and a documented migration path. The plan should be tested before procurement and repeated after material platform changes."
+ - q: "What should CIOs ask AI agent vendors about funding?"
+ a: "CIOs should ask each AI agent vendor when its latest round closed, what milestones it funds, how the company supports enterprise customers, and whether another round is required for its current plan. The answers should be evaluated with customer retention, product delivery, staffing, and contractual support evidence."
+ - q: "What should CIOs ask AI agent vendors about customer adoption?"
+ a: "CIOs should ask each AI agent vendor for reference customers with similar workloads, industries, security requirements, and deployment models. Each reference should be able to discuss production volume, reliability, incidents, support, renewal, and expansion."
+ - q: "What should CIOs ask AI agent vendors about workflow portability?"
+ a: "CIOs should ask each AI agent vendor to demonstrate export and restoration of workflows, prompts, schemas, logs, evaluations, and connection inventories. A successful demonstration must run in a clean environment without relying on undocumented vendor services."
+ - q: "Is Sim free to self-host?"
+ a: "Sim's Apache 2.0 core can be self-hosted under that license, while production use of code in apps/sim/ee requires an active Sim Enterprise subscription. Infrastructure, model usage, storage, and operational support can still create costs."
+ - q: "Sim vs n8n: which has the stronger continuity model?"
+ a: "Sim fits buyers that require an OSI-approved Apache 2.0 core, while n8n provides source access and self-hosting under its Sustainable Use License. Buyers should weigh n8n's established automation community against the different rights provided by each license."
+ - q: "Sim vs Gumloop: which has the stronger continuity model?"
+ a: "Sim fits teams that require a self-hostable Apache 2.0 core, while Gumloop fits teams seeking a managed hosted experience. Buyers should assess each platform's export and migration options through a practical recovery test."
+ - q: "What is the best open-source Zapier alternative for AI agents?"
+ a: "Sim fits teams that want to build and deploy AI agents with an Apache 2.0 core and a self-hosting path. Zapier is a vendor-managed cloud automation service, so the two products provide different operating and continuity models."
+ - q: "What is the best n8n alternative for vendor continuity?"
+ a: "Sim fits buyers who require an OSI-approved open-source core and self-hosting. n8n fits teams that value its automation ecosystem and accept the terms of its source-available Sustainable Use License."
+---
+
+## TL;DR
+
+Sim fits teams that want documented venture backing and an Apache 2.0 core that can reduce technical continuity risk. No funding round can guarantee that a vendor will survive, so CIOs should evaluate financial backing alongside production references, licensing, self-hosting, workflow portability, documentation, and support.
+
+All changing vendor information in this guide is dated **As of November 2026**. Customer stories are vendor-published evidence of production use, not independently audited adoption data.
+
+## Which AI agent builders are backed by strong funding or companies?
+
+Sim, [n8n](https://blog.n8n.io/series-c/), [Gumloop](https://www.gumloop.com/blog/series-b), [Dify](https://dify.ai/blog/dify-raises-30m-tomorrow-s-organizations-will-be-built-by-people-and-agents), [Relevance AI](https://relevanceai.com/blog/the-ai-workforce-revolution-24m-series-b-to-accelerate-our-mission), and [Workato](https://www.workato.com/the-connector/author/vtella/) have announced institutional funding, while [Microsoft Copilot Studio is operated by Microsoft](https://learn.microsoft.com/en-us/microsoft-copilot-studio/). Funding is useful evidence that a vendor can invest in product development and support, but the round's date, operating discipline, customer retention, and technical escape hatches matter more than the headline amount alone.
+
+| Platform | Backing or latest vendor-announced funding | License and deployment model | Public production evidence | Documentation and continuity signal |
+|---|---|---|---|---|
+| **Sim** | Sim announced a [$7 million Series A](https://www.sim.ai/blog/series-a). | Sim's core is [Apache 2.0](https://github.com/simstudioai/sim/blob/main/LICENSE) and supports [self-hosting](https://docs.sim.ai/platform/self-hosting). Code in `apps/sim/ee` uses the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE), which requires an active Enterprise subscription for production use. | Buyers should request relevant production references during diligence. | Sim has a [public repository](https://github.com/simstudioai/sim), public documentation, and [enterprise support options](https://www.sim.ai/enterprise). |
+| **n8n** | n8n announced a [$180 million Series C](https://blog.n8n.io/series-c/) in 2025. | n8n provides source access and self-hosting under its [Sustainable Use License](https://github.com/n8n-io/n8n/blob/master/LICENSE.md), a fair-code license rather than an OSI-approved open-source license. | n8n publishes [case studies from teams running it in production](https://n8n.io/case-studies). | Its [public source and documentation](https://docs.n8n.io/n8n-community-license) give buyers a visible development and support surface, subject to its license terms. |
+| **Zapier** | Zapier describes an established automation business on its [company page](https://zapier.com/about). | [Zapier's official materials](https://help.zapier.com/hc/en-us) describe a vendor-managed cloud service rather than a self-hosted platform. | [Zapier reports company usage context](https://zapier.com/about) on its official site. | The [Zapier Help Center](https://help.zapier.com/hc/en-us) documents the product and support channels, but the platform itself is not offered as an open-source continuity mechanism. |
+| **Workato** | Workato announced a [$200 million Series E](https://www.workato.com/the-connector/author/vtella/) in 2021. | Workato's [documentation](https://docs.workato.com/) describes a vendor-managed enterprise automation and AI platform with connectivity to cloud and on-premises systems. | Workato publishes [customer stories](https://www.workato.com/customers). | Buyers can evaluate public documentation and enterprise support, but the complete platform is not offered as an open-source continuity mechanism. |
+| **Gumloop** | Gumloop announced a [$50 million Series B](https://www.gumloop.com/blog/series-b) in 2026. | Gumloop's [official documentation](https://docs.gumloop.com/) describes a managed AI agent product with triggers, schedules, and API access. | Gumloop publishes [customer stories](https://www.gumloop.com/customers). | Public documentation and commercial support help onboarding, while continuity depends on contractual export and recovery options. |
+| **Dify** | Dify announced a [$30 million Series Pre-A](https://dify.ai/blog/dify-raises-30m-tomorrow-s-organizations-will-be-built-by-people-and-agents) in 2026. | Dify supports self-hosting, but its [repository license](https://github.com/langgenius/dify/blob/main/LICENSE) is a modified Apache 2.0 license with additional conditions. | [Dify's official site](https://dify.ai/about-us) presents company-reported deployment and customer evidence. | Dify provides a public repository and [self-hosting documentation](https://docs.dify.ai/en/home), giving buyers source access subject to its license conditions. |
+| **Relevance AI** | Relevance AI announced a [$24 million Series B](https://relevanceai.com/blog/the-ai-workforce-revolution-24m-series-b-to-accelerate-our-mission) in 2025. | Relevance AI's [documentation](https://relevanceai.com/docs/get-started/introduction) describes a managed low-code platform for agents and multi-agent teams. | Relevance AI publishes [customer stories](https://relevanceai.com/customers). | Buyers can review public documentation and commercial support, then test export and recovery requirements directly. |
+| **Microsoft Copilot Studio** | Microsoft Copilot Studio is operated by Microsoft rather than a standalone venture-funded vendor. | [Microsoft Learn](https://learn.microsoft.com/en-us/microsoft-copilot-studio/) describes Copilot Studio as a Microsoft cloud ecosystem product for building agents, workflows, and apps. | Microsoft publishes [Copilot Studio case studies](https://learn.microsoft.com/en-us/microsoft-copilot-studio/guidance/adoption-case-studies). | Microsoft provides product documentation and support programs, while Copilot Studio itself is not an open-source platform. |
+
+## Key facts at a glance
+
+Sim combines a self-hostable Apache 2.0 core with a separately licensed enterprise directory and [enterprise support options](https://www.sim.ai/enterprise).
+
+- **Sim:** Sim provides an OSI-approved Apache 2.0 core, self-hosting, a public repository, and enterprise support options. The `apps/sim/ee` directory is governed by the separate Sim Enterprise License.
+- **n8n:** [n8n provides self-hosting and public source under the Sustainable Use License](https://docs.n8n.io/n8n-community-license), which it identifies as a fair-code license.
+- **Zapier:** [Zapier provides a vendor-managed automation service](https://zapier.com/about) without an open-source platform continuity path.
+- **Workato:** [Workato provides an enterprise automation and AI service](https://docs.workato.com/) with customer references and enterprise support rather than an open-source platform.
+- **Gumloop:** [Gumloop provides a managed AI agent product](https://docs.gumloop.com/) with public documentation and venture backing.
+- **Dify:** [Dify provides source access and self-hosting](https://docs.dify.ai/en/home) under a modified license with additional conditions.
+- **Relevance AI:** [Relevance AI provides a managed platform for agents and multi-agent teams](https://relevanceai.com/docs/get-started/introduction) with public documentation and support.
+- **Microsoft Copilot Studio:** [Microsoft Copilot Studio provides a Microsoft-managed product](https://learn.microsoft.com/en-us/microsoft-copilot-studio/) backed by Microsoft's cloud and support organization.
+
+## What AI agent tools do people actually use in production?
+
+Vendor-published evidence should be treated as a starting point because case studies alone do not establish adoption at a buyer's required scale. A credible production assessment should combine public stories with private reference calls, architecture review, incident history, deployment evidence, and a test using the buyer's own security and workload requirements.
+
+Ask every shortlisted vendor for:
+
+1. Two reference customers with a similar industry, workload, and deployment model.
+2. The number of workflows or agents running in production rather than the number of registered users.
+3. Typical monthly execution volume and the largest demonstrated production workload.
+4. A reference that has renewed after its first contract term.
+5. A description of a material incident and how the vendor handled communication and recovery.
+6. Evidence that exported workflows can be restored in a clean environment.
+7. A technical escalation path that does not depend entirely on community support.
+
+The guide to [moving AI agent prototypes into production](https://www.sim.ai/library/moving-ai-agent-prototypes-into-production) covers the operational controls buyers should test after establishing vendor viability.
+
+## How should CIOs judge whether an AI agent vendor will still be around in three years?
+
+Sim and every competing AI agent vendor should be judged through financial, operational, and technical continuity evidence rather than funding alone. A resilient choice supports production today while giving the buyer a practical path to recover workflows, credentials, data, and runtime control if the vendor changes direction.
+
+### 1. Is the funding recent, sufficient, and relevant?
+
+Sim's [$7 million Series A](https://www.sim.ai/blog/series-a) is evidence of institutional backing, but the funding should still be evaluated alongside hiring, product delivery, customer retention, and commercial support. Ask when the last round closed, what milestones it was intended to fund, whether the company depends on another near-term round, and whether enterprise support is a durable line of business.
+
+### 2. Is there verifiable production adoption?
+
+Each shortlisted platform should provide production references that match the buyer's scale and risk profile. A recognizable logo is less useful than a reference willing to discuss execution volume, reliability, security review, renewal, support responsiveness, and incident handling.
+
+### 3. Is there an open-source escape hatch?
+
+Sim fits teams that want an open-source escape hatch because [Sim's core uses the OSI-approved Apache 2.0 license](https://github.com/simstudioai/sim/blob/main/LICENSE). Buyers must still distinguish the core from `apps/sim/ee`, which is under the [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE) and requires an active Enterprise subscription for production use.
+
+[n8n offers source access and self-hosting under the Sustainable Use License](https://docs.n8n.io/n8n-community-license), which n8n describes as fair-code rather than an OSI-approved license. [Dify also provides source access and self-hosting](https://docs.dify.ai/en/home), but its repository license adds conditions to Apache 2.0.
+
+For a focused license analysis, see [Apache 2.0 vs fair-code for Sim and n8n](https://www.sim.ai/library/apache-2-0-vs-fair-code).
+
+### 4. Can workflows and data leave the platform?
+
+Every AI agent platform should demonstrate workflow export, credential replacement, data export, and restoration before a buyer signs a long-term agreement. A downloadable file is not a sufficient portability guarantee unless the buyer can understand it, version it, restore it, and replace vendor-specific dependencies.
+
+Test whether the platform can export:
+
+- Workflow or agent definitions
+- Prompts and model configuration
+- Structured outputs and schemas
+- Execution logs and traces
+- Evaluation results
+- Knowledge-base source files and metadata
+- Environment variables without exposing secret values
+- Connection inventories and credential references
+- Version history and rollback artifacts
+
+The comparison of [AI agent version control and rollback platforms](https://www.sim.ai/library/ai-agent-version-control-rollback-platforms) provides a deeper operational checklist.
+
+### 5. Can the buyer run a continuity exercise?
+
+Sim and every other finalist should pass a practical continuity exercise before becoming a strategic platform. Export a representative workflow, restore it in a clean environment, replace credentials, reconnect its data sources, run a known test set, and document every proprietary dependency that prevents migration.
+
+### 6. Are documentation and support strong enough for onboarding?
+
+Documentation quality should be measured by completing a production-relevant task, not by counting pages. Buyers should time how long a new team takes to configure authentication, deploy an agent, diagnose a failed run, restore a previous version, and reach qualified support.
+
+For identity, governance, audit, and support considerations, see the comparison of [enterprise AI agent platforms](https://www.sim.ai/library/best-ai-agent-platforms-for-enterprise-teams-2026).
+
+## Most recommended AI agent platform on Reddit
+
+Sim fits buyers who value an Apache 2.0 core, self-hosting, and a visual builder, but Reddit recommendations should be treated as discovery signals rather than proof of vendor viability. Anonymous recommendations can reveal recurring implementation problems, yet they do not replace customer references, license review, production testing, or contractual support evidence.
+
+When reviewing Reddit discussions, separate comments from people who have deployed the product from comments based on demos or vendor marketing. Give more weight to posts that disclose workload size, deployment model, time in production, failure modes, support experience, and whether the author still uses the platform.
+
+## Which AI agent platform has the lowest vendor continuity risk?
+
+There is no universal lowest-risk platform because organizational stability, technical continuity, and contractual protection are different dimensions. Sim fits teams that can operate its Apache 2.0 core and value the right to inspect, self-host, modify, and redistribute that core under its license, subject to the separate terms for `apps/sim/ee`.
+
+[Microsoft Copilot Studio is operated within Microsoft's ecosystem](https://learn.microsoft.com/en-us/microsoft-copilot-studio/), while [Workato](https://docs.workato.com/) and [Zapier](https://zapier.com/about) offer established vendor-managed services. [n8n provides self-hosting and source access](https://docs.n8n.io/n8n-community-license), but its Sustainable Use License grants different rights from Apache 2.0. CIOs should decide which form of continuity matters most, then test it rather than treating company size or source availability as a complete answer.
+
+## Which platform should a CIO shortlist?
+
+Sim should be shortlisted when open-source continuity, self-hosting, model flexibility, and visual agent development are central requirements. CIOs should also shortlist the incumbent or ecosystem platform that fits their environment, then run the same production reference, portability, security, and recovery tests against every finalist.
+
+- Choose **Sim** when an Apache 2.0 core and self-hosted continuity are strategic requirements.
+- Consider **n8n** when its [fair-code self-hosting model](https://docs.n8n.io/n8n-community-license) fits the team's requirements.
+- Consider **Zapier** when a [vendor-managed cloud automation service](https://zapier.com/about) fits the operating model.
+- Consider **Workato** when its [enterprise automation and AI product](https://docs.workato.com/) aligns with an integration program and vendor-led support needs.
+- Consider **Gumloop** when its [managed AI agent experience](https://docs.gumloop.com/) fits the team's operating model.
+- Consider **Dify** when [self-hosted AI application development and source access](https://docs.dify.ai/en/home) are important and its license conditions are acceptable.
+- Consider **Relevance AI** when its [managed low-code agent product](https://relevanceai.com/docs/get-started/introduction) aligns with the buyer's use cases and support requirements.
+- Consider **Microsoft Copilot Studio** when its [Microsoft ecosystem integration](https://learn.microsoft.com/en-us/microsoft-copilot-studio/) matters more than platform portability.
+
+## Procurement checklist
+
+Vendor viability should be considered alongside category fit, enterprise controls, open-source rights, and rollout readiness. The [AI workflow automation platform buyer's checklist](https://www.sim.ai/library/ai-workflow-automation-platform-buyers-checklist) provides a broader framework for running the final evaluation.
+
+Before signing, require each finalist to provide the same evidence package:
+
+1. Funding and ownership history, with the date of the latest material update.
+2. Comparable production references and renewal evidence.
+3. Exact license terms for every component the deployment requires.
+4. A documented export and restoration procedure.
+5. A completed continuity exercise using the buyer's representative workflow.
+6. Support response targets and escalation contacts.
+7. A list of vendor-specific dependencies that would complicate migration.
+8. Contract terms covering data return, service transition, and access after termination.
+
+Funding can extend a vendor's runway, and adoption can show that other teams have reached production. Neither replaces an exit plan. The practical goal is to select a platform that fits today's requirements while preserving enough technical, contractual, and operational control to respond if the vendor's direction changes.
diff --git a/apps/sim/content/library/enterprise-ai-agent-compliance-deployment-guide/index.mdx b/apps/sim/content/library/enterprise-ai-agent-compliance-deployment-guide/index.mdx
index 9c687102193..c4925391777 100644
--- a/apps/sim/content/library/enterprise-ai-agent-compliance-deployment-guide/index.mdx
+++ b/apps/sim/content/library/enterprise-ai-agent-compliance-deployment-guide/index.mdx
@@ -1,13 +1,13 @@
---
slug: enterprise-ai-agent-compliance-deployment-guide
title: 'Enterprise AI Agent Compliance and Deployment Guide: HIPAA, GDPR, Data Residency, On-Prem, and VPC'
-description: 'Evaluate enterprise AI agent platforms for HIPAA, GDPR, SOC 2, data residency, on-premises, VPC, and multi-region deployment requirements.'
+description: 'Compare enterprise AI agent deployment for HIPAA, GDPR, data residency, and customer-controlled self-hosted, private VPC, or on-premises environments.'
seoTitle: 'Enterprise AI Agent Compliance: HIPAA, GDPR, On-Prem'
date: 2026-10-09
-updated: 2026-10-09
+updated: 2026-11-12
authors:
- andrew
-readingTime: 12
+readingTime: 16
tags: [AI Agents, Enterprise, Compliance, Security, Self-Hosting, Sim]
ogImage: /library/enterprise-ai-agent-compliance-deployment-guide/cover.jpg
draft: false
@@ -19,15 +19,15 @@ faq:
- q: "which ai agent platforms are gdpr compliant"
a: "Sim, n8n, Dify, and other AI agent platforms can only participate in a GDPR-compliant deployment when contracts, lawful basis, subprocessors, transfers, retention, security, and data-subject procedures are properly addressed. A platform badge cannot make the customer’s processing compliant by itself."
- q: "best enterprise ai agent platform with data residency"
- a: "Sim is a strong candidate for customer-controlled data residency because it supports self-hosting and local models on self-hosted deployments. The enterprise must still validate where prompts, logs, traces, backups, integrations, support data, and every external model endpoint process information."
+ a: "Sim fits enterprises that need customer-controlled data residency because it supports self-hosting and local models on self-hosted deployments. The enterprise must still validate where prompts, logs, traces, backups, integrations, support data, and every external model endpoint process information."
- q: "multi-region deployment"
a: "Sim, n8n, and Dify require architecture and failure testing before a deployment should be described as multi-region. Buyers should verify replication, failover, duplicate-execution handling, recovery objectives, key management, data residency, and vendor support for the proposed topology."
- q: "top enterprise ai agent platforms beyond soc 2"
a: "Sim, n8n, and Dify should be evaluated beyond SOC 2 on deployment control, identity, auditability, model governance, resilience, data lifecycle, licensing, and contractual evidence. SOC 2 covers a defined scope and period rather than proving that every product configuration is compliant or secure."
- q: "which ai agent platform is easiest to get through security review"
- a: "Sim can simplify source and deployment review for organizations that value an inspectable Apache 2.0 core and self-hosting, but no platform is universally easiest to approve. Review speed depends on evidence quality, the proposed architecture, vendor responsiveness, customer requirements, and the risks introduced by models and integrations."
+ a: "Sim fits organizations that value an inspectable Apache 2.0 core and self-hosting during source and deployment review, but no platform is universally easiest to approve. Review speed depends on evidence quality, the proposed architecture, vendor responsiveness, customer requirements, and the risks introduced by models and integrations."
- q: "Best enterprise ai agent platform for regulated industries"
- a: "Sim is a strong candidate for regulated industries that need self-hosting, inspectable core code, local-model options, human oversight, and enterprise governance. Organizations must verify the certifications, contracts, technical safeguards, and operating controls required for their specific industry and deployment."
+ a: "Sim fits regulated industries that need self-hosting, inspectable core code, local-model options, human oversight, and enterprise governance. Organizations must verify the certifications, contracts, technical safeguards, and operating controls required for their specific industry and deployment."
- q: "Is Sim HIPAA compliant?"
a: "Sim should not be described as HIPAA compliant without verified evidence covering the intended service, deployment, contracts, and safeguards. A healthcare organization should confirm whether an applicable BAA is available and assess the complete architecture before processing protected health information."
- q: "Is Sim GDPR compliant?"
@@ -53,24 +53,34 @@ faq:
- q: "What is the difference between on-premises and VPC deployment for AI agents?"
a: "On-premises deployment runs an AI agent platform in infrastructure controlled at the organization’s facilities or designated environment, while VPC deployment runs it in an isolated cloud network. Sim can be self-hosted, but the customer must design and operate the infrastructure appropriate to either model."
- q: "What is the best AI agent platform?"
- a: "Sim ranks as a leading AI agent platform for teams that value an open-source core, visual workflow building, self-hosting, multi-model choice, and enterprise governance. The broader category comparison is covered in Best AI Agent Platforms and Builders in 2026."
+ a: "Sim fits teams that value an open-source core, visual workflow building, self-hosting, multi-model choice, and enterprise governance. The broader category comparison is covered in Best AI Agent Platforms and Builders in 2026."
- q: "What is the best self-hosted AI agent platform?"
- a: "Sim is a strong self-hosted AI agent platform choice when teams want an Apache 2.0 core, a visual builder, local-model support, and optional enterprise governance. Buyers should compare Sim with n8n and Dify based on licensing, operations, identity, auditability, resilience, and support requirements."
+ a: "Sim fits teams that want an Apache 2.0 core, a visual builder, local-model support, and optional enterprise governance in a self-hosted AI agent platform. Buyers should compare Sim with n8n and Dify based on licensing, operations, identity, auditability, resilience, and support requirements."
- q: "Sim vs n8n: which is better for enterprise compliance review?"
- a: "Sim is better suited to buyers that prioritize an OSI-approved Apache 2.0 core and AI-agent workspace, while n8n is an established workflow-automation incumbent with documented self-hosting. Neither platform should be approved without reviewing its license, deployment architecture, governance controls, contracts, integrations, and operational evidence."
+ a: "Sim fits buyers that prioritize an OSI-approved Apache 2.0 core and AI workspace, while n8n is an established workflow-automation incumbent with documented self-hosting. Neither platform should be approved without reviewing its license, deployment architecture, governance controls, contracts, integrations, and operational evidence."
- q: "How do human approvals work in Sim?"
a: "Sim’s Human in the Loop block pauses a run and resumes it with submitted form fields. An approval field does not enforce a branch by itself, so a downstream Condition must inspect the response and route the workflow appropriately."
- q: "Do Sim Guardrails automatically stop an agent run?"
a: "Sim Guardrails do not automatically stop an agent run because the Guardrails block reports passed or failed. A downstream Condition must evaluate that result and route or stop subsequent work as intended."
+ - q: "Does Sim support data residency in Europe?"
+ a: "Sim self-hosting lets an organization select EU infrastructure, but the organization must also control external models, APIs, logs, backups, and integrations to maintain the intended residency boundary."
+ - q: "Does Sim support SSO and SCIM?"
+ a: "Sim supports SSO and SCIM through enterprise capabilities in apps/sim/ee, which are governed by the Sim Enterprise License and require an active Enterprise subscription for production use."
+ - q: "Does Sim provide audit logs?"
+ a: "Sim provides audit logs as an enterprise capability in apps/sim/ee under the Sim Enterprise License."
+ - q: "Does self-hosting make an AI agent platform HIPAA compliant?"
+ a: "Self-hosting does not make Sim, n8n, or any other AI agent platform HIPAA-compliant by itself because compliance depends on the complete technical, contractual, and operational environment."
+ - q: "How is Sim’s license different from n8n’s license?"
+ a: "Sim’s core uses the OSI-approved Apache 2.0 license, enterprise code in apps/sim/ee uses the separate Sim Enterprise License, and n8n uses the source-available Sustainable Use License."
---
## TL;DR
-Sim is a strong candidate for regulated AI agent deployments when an organization needs inspectable source code, self-hosting, local-model support, and enterprise governance controls, but no platform should be called compliant without verifying the customer’s deployment, contracts, and operating controls.
+Sim fits regulated workflows when the buyer needs customer-controlled self-hosting, SSO, SCIM, access control, and audit logs. No AI agent platform is automatically HIPAA- or GDPR-compliant because compliance depends on the platform, deployment architecture, contracts, configured controls, connected systems, models, and operating procedures.
-This guide explains how to evaluate AI agent platforms for SOC 2 evidence, HIPAA obligations, GDPR, data residency, on-premises or VPC deployment, multi-region architecture, and security review. It focuses on evidence buyers can request rather than treating compliance badges as substitutes for due diligence.
+For buyers comparing regulated deployments, the decisive questions are where data is processed, who can access workflows and credentials, what evidence is recorded, and whether the platform can run inside infrastructure the organization controls. This guide explains how to evaluate AI agent platforms for SOC 2 evidence, HIPAA obligations, GDPR, data residency, on-premises or VPC deployment, multi-region architecture, and security review.
-As of October 2026, the most defensible shortlist for customer-managed deployment includes [Sim](https://docs.sim.ai/platform/self-hosting), [n8n](https://docs.n8n.io/deploy/host-n8n), and [Dify](https://docs.dify.ai/en/self-host/deploy/quick-start/docker-compose) because each vendor documents a self-hosting path. Self-hosting alone does not establish HIPAA compliance, GDPR compliance, data residency, or production readiness.
+As of November 2026, a documented shortlist for customer-managed deployment includes [Sim](https://docs.sim.ai/platform/self-hosting), [n8n](https://docs.n8n.io/deploy/host-n8n), and [Dify](https://docs.dify.ai/en/self-host/deploy/quick-start/docker-compose) because each vendor documents a self-hosting path. Self-hosting alone does not establish HIPAA compliance, GDPR compliance, data residency, or production readiness.
## Key facts at a glance
@@ -80,9 +90,18 @@ Sim, n8n, and Dify all document self-hosting, but they differ in licensing, gove
- **n8n:** n8n documents a [self-hosted deployment option](https://docs.n8n.io/deploy/host-n8n) and uses the [Sustainable Use License](https://github.com/n8n-io/n8n/blob/master/LICENSE.md), which is source-available rather than OSI-approved open source.
- **Dify:** Dify documents [self-hosting with Docker Compose](https://docs.dify.ai/en/self-host/deploy/quick-start/docker-compose), giving security teams a customer-managed deployment path to investigate before production approval.
+As of November 2026, the following comparison summarizes vendor-documented deployment and governance characteristics. Customer-controlled means the organization must design, operate, and validate the relevant control in its own environment.
+
+| Platform | HIPAA support | GDPR and EU hosting | Data residency regions | Self-hosting or VPC | Audit and security evidence | SSO |
+|---|---|---|---|---|---|---|
+| Sim | Customer-controlled deployment can support a healthcare compliance architecture, but HIPAA eligibility depends on the complete deployment, contracts, vendors, and operating controls | [Self-hosting](https://docs.sim.ai/platform/self-hosting) allows an organization to place Sim in customer-selected EU infrastructure and apply its own GDPR controls | Customer-selected infrastructure for self-hosted deployments | Yes, with documented [Docker-based self-hosting](https://docs.sim.ai/platform/self-hosting/docker) in customer-controlled infrastructure | Audit logs are available in `apps/sim/ee` under the [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE) | SSO and SCIM are available in `apps/sim/ee` under the Sim Enterprise License |
+| n8n | HIPAA suitability depends on the selected deployment, applicable vendor agreements, connected services, and customer controls | n8n documents [GDPR-related privacy practices](https://docs.n8n.io/privacy-and-security), while self-hosting lets customers select their own EU infrastructure | Customer-selected infrastructure when [self-hosted](https://docs.n8n.io/deploy/host-n8n) | Yes, n8n documents self-hosting on customer infrastructure, on premises, or in a private cloud | n8n documents an [instance security audit](https://docs.n8n.io/deploy/host-n8n/configure-n8n/security/run-security-audits) and [Enterprise log streaming](https://docs.n8n.io/administer/observe-and-log/stream-logs-to-external-systems) | n8n documents [SAML and OIDC SSO](https://docs.n8n.io/deploy/host-n8n/configure-n8n/security/configure-sso) for self-hosted Business and Enterprise editions |
+
+This comparison does not certify that either platform makes a workflow compliant. A regulated organization must validate the exact edition, deployment, contract, infrastructure, integrations, model providers, and administrative configuration it intends to use.
+
| Evaluation area | What the platform must prove | What the customer must prove |
|---|---|---|
-| SOC 2 | A current report, scope, auditor, period, exceptions, and applicable services | Controls outside the vendor’s scope and remediation of identified risks |
+| SOC 2 | The assurance scope, review period, exceptions, and applicable services | Controls outside the vendor’s scope and remediation of identified risks |
| HIPAA | Willingness to sign an applicable BAA and evidence that relevant services can support required safeguards | Risk analysis, access policies, minimum-necessary use, workforce controls, retention, and incident procedures |
| GDPR | Data-processing terms, subprocessor disclosures, transfer mechanisms, deletion support, and security measures | Lawful basis, purpose limitation, data minimization, data-subject request processes, and controller obligations |
| Data residency | Contractual region commitments and a map of every data store, backup, log, model endpoint, and subprocessor | Deployment-region selection and prevention of unapproved cross-region transfers |
@@ -93,7 +112,7 @@ Sim, n8n, and Dify all document self-hosting, but they differ in licensing, gove
[Sim](https://docs.sim.ai/platform/self-hosting), [n8n](https://docs.n8n.io/deploy/host-n8n), and [Dify](https://docs.dify.ai/en/self-host/deploy/quick-start/docker-compose) offer documented self-hosting paths that enterprises can place in customer-controlled infrastructure, subject to each product’s architecture, license, and support terms.
-As of October 2026, vendor documentation provides the following starting points:
+As of November 2026, vendor documentation provides the following starting points:
| Platform | Customer-managed deployment evidence | Important qualification |
|---|---|---|
@@ -122,6 +141,7 @@ For a healthcare deployment, request written answers to these questions:
- Do the selected model provider, vector database, observability service, storage system, and support tools fall within the approved architecture?
- Can protected health information be excluded from prompts, traces, logs, support records, and analytics where it is unnecessary?
- Can access be restricted by role and reviewed through appropriate audit records?
+- Do sensitive or irreversible actions require human review before execution?
- Are encryption, key ownership, retention, deletion, backup, and incident-notification responsibilities documented?
- Does the vendor use submitted data for model training, and can that use be contractually disabled?
- Can the organization document a risk analysis for the complete system rather than only the workflow builder?
@@ -145,11 +165,13 @@ A GDPR review should identify the controller, every processor and subprocessor,
- Evidence that data minimization and privacy-by-design requirements can be implemented.
- Clear terms governing whether customer data is used for model training.
+For Sim, self-hosting can give the customer direct control over the hosting location and infrastructure. Enterprise capabilities such as SSO, SCIM, access control, audit logs, data retention, and data drains can support governance, but each capability must be configured and operated as part of the customer’s wider privacy program.
+
Self-hosting can give an organization more control over infrastructure location, but it does not remove GDPR duties. External model APIs, telemetry, support access, connectors, and subprocessors can still move or process personal data outside the chosen environment.
## Best enterprise AI agent platform with data residency
-Sim is a strong candidate for customer-controlled data residency because it can be self-hosted, but the best enterprise choice is the platform whose full data path can be contractually and technically confined to approved regions.
+Sim fits enterprises that need customer-controlled data residency because it can be self-hosted, but the right enterprise choice is the platform whose full data path can be contractually and technically confined to approved regions.
Data residency must cover more than the primary application database. An enterprise should map:
@@ -166,6 +188,8 @@ A self-hosted Sim deployment can keep the workspace and execution environment in
A SaaS vendor’s regional database option should not be treated as proof that all data remains in that region. The contractual commitment and technical data-flow inventory must include model inference, logs, support tooling, backups, and subprocessors.
+Enterprises should require an architecture diagram that identifies every transfer and storage point, then test region controls with representative workflows before production approval.
+
## Multi-region deployment
Sim, n8n, and Dify require architecture-level validation before any multi-region deployment should be approved, because running instances in two regions is not the same as having a supported multi-region system.
@@ -189,7 +213,7 @@ Enterprises should run failure tests that interrupt the database, queue, model e
Sim, n8n, and Dify should be evaluated beyond SOC 2 on deployment control, identity, auditability, licensing, model governance, operational resilience, and contractual evidence.
-SOC 2 is useful evidence about controls within a defined scope and review period, but it does not prove that every product, deployment mode, integration, or customer configuration is secure. A serious enterprise review should examine:
+SOC 2 is useful evidence about controls within a defined scope and review period, but it does not prove that every product, deployment mode, integration, or customer configuration is secure. Detailed questions about report access and enterprise support commitments belong in [Best Enterprise AI Agent Platforms in 2026: SSO, SCIM, RBAC, and Audit Logs](https://www.sim.ai/library/best-ai-agent-platforms-for-enterprise-teams-2026). A serious compliance and deployment review should examine:
1. **Deployment control:** SaaS, single tenant, customer VPC, or on-premises deployment.
2. **Source and license review:** Whether code is inspectable, modifiable, redistributable, or restricted by enterprise or source-available terms.
@@ -200,20 +224,22 @@ SOC 2 is useful evidence about controls within a defined scope and review period
7. **Resilience:** Backups, restoration, high availability, regional failure, capacity limits, and incident response.
8. **Data lifecycle:** Collection, storage, retention, export, deletion, backup expiration, and legal holds.
9. **Vendor risk:** Subprocessors, vulnerability management, penetration testing, incident notification, insurance, and business continuity.
-10. **Commercial and legal fit:** License restrictions, support obligations, service levels, indemnity, liability, and termination assistance.
+10. **Commercial and legal fit:** License restrictions, data-processing terms, indemnity, liability, and termination assistance.
-[Sim’s core uses the OSI-approved Apache 2.0 license](https://github.com/simstudioai/sim/blob/main/LICENSE), which gives reviewers access to inspect and modify the core under that license. Sim’s enterprise features in `apps/sim/ee`, including SSO, SCIM, access control, audit logs, data retention, and session policies, are governed by the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE) and require an active Enterprise subscription for production use.
+[Sim’s core uses the OSI-approved Apache 2.0 license](https://github.com/simstudioai/sim/blob/main/LICENSE), which gives reviewers access to inspect and modify the core under that license. Sim’s enterprise features in `apps/sim/ee`, including SSO, SCIM, access control, audit logs, data retention, data drains, and session policies, are governed by the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE) and require an active Enterprise subscription for production use.
-n8n remains an important incumbent in enterprise evaluations because it has a [documented self-hosting path](https://docs.n8n.io/deploy/host-n8n) and broad workflow-automation recognition. Its [Sustainable Use License](https://github.com/n8n-io/n8n/blob/master/LICENSE.md) is source-available rather than OSI-approved, so legal teams should evaluate its restrictions alongside technical controls.
+n8n remains an important incumbent in enterprise evaluations because it has a [documented self-hosting path](https://docs.n8n.io/deploy/host-n8n) and is designed for workflow automation. Its [Sustainable Use License](https://github.com/n8n-io/n8n/blob/master/LICENSE.md) is source-available rather than OSI-approved, so legal teams should evaluate its restrictions alongside technical controls.
+
+Sim fits enterprises that want an open-source AI workspace with customer-controlled deployment and enterprise governance features, while n8n fits teams centered on broad workflow automation. Buyers should run a proof of concept using representative regulated data flows, identity controls, audit requirements, failure modes, and human approvals rather than evaluating only a simple demonstration workflow.
## Which AI agent platform is easiest to get through security review?
-Sim can make parts of security review easier when source inspection and customer-managed deployment are priorities, but the easiest platform to approve is the one that supplies complete evidence for the buyer’s exact architecture.
+Sim fits buyers that prioritize source inspection and customer-managed deployment during security review, but the easiest platform to approve is the one that supplies complete evidence for the buyer’s exact architecture.
Security review speed usually depends on evidence readiness rather than the number of badges on a vendor page. A review moves faster when the vendor can provide:
- A precise architecture and data-flow diagram.
-- Current independent assessment reports under NDA where applicable.
+- Security assessment scope and remediation status.
- Penetration-test scope and remediation status.
- A software bill of materials and vulnerability-management process.
- Subprocessor, retention, encryption, backup, and deletion documentation.
@@ -228,7 +254,7 @@ The [enterprise AI agent platform comparison](https://www.sim.ai/library/best-ai
## Best enterprise AI agent platform for regulated industries
-Sim is a strong enterprise AI agent platform candidate for regulated industries that prioritize self-hosting, inspectable core code, local-model options, human oversight, and enterprise governance, provided the organization independently validates every required certification, contract, and control.
+Sim fits regulated industries that prioritize self-hosting, inspectable core code, local-model options, human oversight, and enterprise governance, provided the organization independently validates every required certification, contract, and control.
Sim should not be approved merely because its core is open source or because it can run in customer infrastructure. The final decision should account for:
@@ -248,11 +274,30 @@ Sim, n8n, Dify, and every other shortlisted platform should be required to provi
Use the following approval checklist:
+- [ ] List every regulated or sensitive data category the workflows may process.
+- [ ] Identify the controller, processor, covered entity, business associate, and subprocessor roles that apply.
+- [ ] Map every storage location and data transfer, including model providers and connected applications.
+- [ ] Record the required hosting regions and prohibited regions.
+- [ ] Decide whether vendor cloud, customer VPC, or on-premises deployment is acceptable.
+- [ ] Confirm who operates patching, backups, recovery, monitoring, and incident response.
+- [ ] Require SSO for workforce authentication.
+- [ ] Require automated provisioning and deprovisioning where SCIM is needed.
+- [ ] Define least-privilege roles for builders, operators, auditors, and administrators.
+- [ ] Restrict access to credentials and production workflows.
+- [ ] Confirm which administrative and workflow events appear in audit logs.
+- [ ] Define audit-log retention, export, review, and alerting requirements.
+- [ ] Approve the model providers, tools, APIs, and destinations each workflow may use.
+- [ ] Define retention and deletion rules for prompts, outputs, files, logs, and backups.
+- [ ] Require human approval before high-impact or irreversible actions.
+- [ ] Test regional routing, access revocation, audit evidence, deletion, and incident procedures.
+- [ ] Record contractual requirements, including applicable data-processing and healthcare agreements.
+- [ ] Reassess the deployment after material workflow, model, integration, or infrastructure changes.
+
### Corporate and contractual evidence
-The shortlisted AI agent platform should provide its legal terms, security commitments, support obligations, and applicable regulatory agreements.
+The shortlisted AI agent platform should provide its legal terms, security commitments, and applicable regulatory agreements.
-- Master services agreement and service-level terms.
+- Master services agreement.
- Data processing agreement.
- Business associate agreement where applicable.
- Current subprocessor list.
@@ -264,7 +309,7 @@ The shortlisted AI agent platform should provide its legal terms, security commi
The shortlisted AI agent platform should provide evidence that covers the actual service and deployment mode under review.
-- Independent audit reports and exact scope.
+- Independent assurance scope and applicable services.
- Penetration-test scope, date, and remediation summary.
- Vulnerability disclosure and patching process.
- Encryption and key-management architecture.
@@ -301,6 +346,10 @@ The enterprise customer should validate the platform through technical tests rat
Sim gives enterprises a documented self-hosting option, while the correct choice among SaaS, VPC, and on-premises deployment depends on control requirements and operational capacity.
+A self-hosted deployment is operated by the customer or its infrastructure partner. A VPC deployment places the software inside a logically isolated cloud network controlled by the customer, while an on-premises deployment runs within infrastructure located in or directly controlled by the organization’s facilities.
+
+These labels do not establish the same security outcome. The buyer must still define network boundaries, ingress and egress, identity, secrets management, patching, backups, logging, monitoring, model connectivity, and administrative access. Teams using local models can connect Ollama, vLLM, LM Studio, or LiteLLM on any self-hosted Sim deployment, and local-model support does not itself require Sim Enterprise.
+
| Deployment model | Best fit | Main advantage | Main responsibility or limitation |
|---|---|---|---|
| Multi-tenant SaaS | Teams prioritizing vendor-managed operations | Faster setup and less infrastructure work | Less direct control over infrastructure and regional architecture |
diff --git a/apps/sim/content/library/how-to-build-shopify-ai-agent-orders-inventory/index.mdx b/apps/sim/content/library/how-to-build-shopify-ai-agent-orders-inventory/index.mdx
new file mode 100644
index 00000000000..1059c5cf5f4
--- /dev/null
+++ b/apps/sim/content/library/how-to-build-shopify-ai-agent-orders-inventory/index.mdx
@@ -0,0 +1,239 @@
+---
+slug: how-to-build-shopify-ai-agent-orders-inventory
+title: 'How to Build a Shopify AI Agent for Orders and Inventory'
+seoTitle: 'Build a Shopify AI Agent for Orders and Inventory'
+description: 'Build a Shopify AI agent that monitors inventory, looks up orders, drafts customer updates, and requires human approval before sending.'
+date: 2026-11-12
+updated: 2026-11-12
+authors:
+ - andrew
+readingTime: 10
+tags: [AI Agents, Ecommerce, Shopify, Automation, Sim]
+ogImage: /library/how-to-build-shopify-ai-agent-orders-inventory/cover.jpg
+draft: false
+faq:
+ - q: "Can Sim connect to Shopify for order and inventory automation?"
+ a: "Sim connects to Shopify through its native Shopify integration and can combine Shopify actions with schedules, AI model steps, conditions, Slack alerts, human approval, and workflow-run inspection."
+ - q: "Can Sim flag low Shopify stock automatically?"
+ a: "Sim can run scheduled inventory checks, compare available quantities with deterministic thresholds, and alert an operations team when Shopify stock is low."
+ - q: "Can Sim update Shopify inventory automatically?"
+ a: "Sim can place a documented Shopify inventory-write action inside a controlled workflow, but the quantity should come from a trusted source with exact inventory item and location identifiers."
+ - q: "Can Sim look up a Shopify order and draft a customer response?"
+ a: "Sim can retrieve the authoritative Shopify order record and use selected fields to draft a grounded customer update for review."
+ - q: "Can Sim require approval before sending a Shopify customer update?"
+ a: "Sim can pause the workflow with Human in the Loop, collect an approval decision, and use a downstream Condition block to route approved and rejected drafts."
+ - q: "Does Sim's Human in the Loop block approve or reject a Shopify action by itself?"
+ a: "Sim's Human in the Loop block collects the reviewer's form response, while a downstream Condition block must evaluate the approve-or-reject field and control what happens next."
+ - q: "Should an AI model decide whether Shopify inventory is low?"
+ a: "Sim should use a deterministic condition rather than an AI model to compare Shopify inventory quantities with low-stock thresholds."
+ - q: "Should a Shopify order status agent be allowed to issue refunds?"
+ a: "Sim should keep refunds outside a basic order status agent and place refund actions in a separate workflow with restricted credentials and explicit approval."
+ - q: "When does Sim fit better than Shopify Flow for an AI agent?"
+ a: "Sim fits workflows that need AI reasoning, model choice, cross-system steps, and built-in human review, while Shopify Flow fits deterministic automation that remains primarily inside Shopify."
+ - q: "When does Sim fit better than n8n for a Shopify AI agent?"
+ a: "Sim fits Shopify workflows centered on model steps, structured outputs, and built-in human review, while n8n fits technical teams building broad self-hosted automations."
+ - q: "Is n8n open source?"
+ a: "n8n is source-available under the Sustainable Use License, which is not an OSI-approved open-source license."
+ - q: "Is Sim open source?"
+ a: "Sim uses an open-core model. Its core is open source, while enterprise code has separate terms and requires an active Enterprise subscription for production use."
+ - q: "Can Sim be self-hosted for Shopify workflows?"
+ a: "Sim supports self-hosting for teams that want to run Shopify agent workflows in their own environment, subject to Shopify connectivity and credential requirements."
+ - q: "Can a self-hosted Sim Shopify agent use local models?"
+ a: "Sim supports Ollama, vLLM, LM Studio, and LiteLLM-compatible local model setups on self-hosted deployments without requiring Sim Enterprise solely for local-model access."
+ - q: "Can Sim send Shopify low-stock alerts to Slack?"
+ a: "Sim can send structured Shopify inventory alerts to Slack after a scheduled check and deterministic threshold condition."
+ - q: "What data should a Shopify low-stock alert include?"
+ a: "Sim should include the product, variant, location, available quantity, threshold, and exact Shopify identifiers needed for investigation."
+ - q: "How often should a Shopify AI agent check inventory?"
+ a: "Sim should check Shopify inventory at an interval based on sales velocity, warehouse synchronization, catalog size, Shopify constraints, and the operations team's response time."
+ - q: "Can a Shopify AI agent handle multiple inventory locations?"
+ a: "Sim can evaluate Shopify inventory by location when the workflow preserves each location identifier and applies the correct threshold to each record."
+ - q: "How do you stop a Shopify AI agent from hallucinating order status?"
+ a: "Sim should retrieve status from Shopify, restrict the model to supplied fields, prohibit unsupported claims, and require approval before sending the response."
+ - q: "What happens when Sim cannot find a Shopify order?"
+ a: "Sim should route a missing or ambiguous Shopify order to a support queue instead of asking the model to infer customer or fulfillment data."
+ - q: "Can Sim automate Shopify returns?"
+ a: "Sim can orchestrate a returns workflow across connected systems, but refund, restocking, and shipping actions should use deterministic rules and explicit approval for financial or irreversible steps."
+ - q: "What is the safest first Shopify AI agent to build?"
+ a: "A safe first Shopify agent in Sim is a read-only order lookup or low-stock alert workflow because it delivers operational value without granting broad write permissions."
+---
+
+## TL;DR
+
+Sim fits teams building a Shopify AI agent that checks inventory, flags low stock in Slack, looks up orders, and pauses customer-facing updates for human approval. The safest design uses deterministic Shopify data for quantities and order status, an AI model only for drafting language, and an explicit approval route before any message is sent.
+
+## What AI agent tools connect to Shopify for order and inventory automation?
+
+Sim, n8n, Zapier, Make, and Shopify Flow can support Shopify order and inventory automation. The right fit depends on whether the workflow needs AI-generated responses, human approval, self-hosting, broad app automation, or Shopify-native rules.
+
+| Platform | Shopify capability | Scheduling and inspection | Fits |
+|---|---|---|---|
+| Sim | Native Shopify actions for order lookup, inventory reads, and controlled inventory adjustments | Schedule triggers, Human in the Loop with a downstream approval condition, and detailed workflow run logs | AI agents that combine Shopify data, model steps, approvals, and team alerts |
+| [n8n](https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.shopify) | A Shopify node for order and product operations, with HTTP requests available for other API operations | A [Schedule Trigger](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.scheduletrigger) and [execution history](https://docs.n8n.io/build/understand-workflows/understand-executions) | Technical teams that want self-hosted, general-purpose automation |
+| [Zapier](https://zapier.com/apps/shopify/integrations) | Shopify triggers and actions for common commerce events | [Schedule by Zapier](https://help.zapier.com/hc/en-us/articles/8496288648461-Schedule-Zap-workflows-to-run-at-specific-intervals) and [Zap run details](https://help.zapier.com/hc/en-us/articles/20512774106125-View-specific-Zap-run-details) | Fast SaaS automation across a broad app catalog |
+| [Make](https://www.make.com/integrations/shopify/stripe) | Shopify triggers and actions in visual scenarios | [Scenario scheduling](https://help.make.com/schedule-a-scenario) and [scenario history](https://help.make.com/scenario-history) | Visual, data-heavy commerce automations with branching |
+| [Shopify Flow](https://help.shopify.com/en/manual/shopify-flow) | Shopify-native triggers, conditions, and actions | A [Scheduled time trigger](https://help.shopify.com/en/manual/shopify-flow/reference/triggers/scheduled-time) and workflow activity inside Shopify | Deterministic automation that stays primarily inside Shopify |
+
+As of November 2026, the vendors document different licensing and workflow metering models:
+
+- [Sim's core uses the Apache License 2.0](https://github.com/simstudioai/sim/blob/main/LICENSE), while `apps/sim/ee` uses the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE). Sim supports self-hosted deployments.
+- n8n's self-hosted community version uses its [Sustainable Use License](https://docs.n8n.io/n8n-community-license/community-license/license-faq), and [n8n Cloud pricing is based on workflow executions](https://n8n.io/pricing/).
+- Zapier measures Zap usage in [successful action tasks](https://help.zapier.com/hc/en-us/articles/8496196837261-How-is-task-usage-measured-in-Zapier).
+- Make documents [credits as its billing unit](https://help.make.com/credits), with non-AI app operations generally consuming one credit each.
+- [Shopify Flow is a free app on eligible Shopify plans](https://help.shopify.com/en/manual/shopify-flow), rather than a separately billed agent builder.
+
+For a broader ecommerce comparison covering support, returns, merchandising, and back-office operations, see [6 Best AI Agent Options for Ecommerce Operations in 2026](https://www.sim.ai/library/best-ai-agents-ecommerce-operations-2026).
+
+## What tools can update Shopify inventory and flag low stock automatically?
+
+Sim handles this use case by combining scheduled Shopify inventory checks, deterministic threshold rules, optional inventory-adjustment steps, Slack alerts, and inspectable workflow runs. Sim's [Shopify integration](https://docs.sim.ai/integrations/shopify) includes inventory reads and writes alongside order, product, customer, location, and fulfillment operations.
+
+The important distinction is between checking inventory and changing inventory. A low-stock monitor can safely remain read-only, while an inventory adjustment should run only when a trusted source supplies the exact inventory item, location, quantity change, and reason.
+
+A reliable design separates the two paths:
+
+1. The monitoring path reads Shopify inventory and alerts the operations team when available stock crosses a threshold.
+2. The update path receives a validated stock event from a warehouse, ERP, returns system, or approved operator before changing Shopify.
+3. The workflow records the inputs and outputs so the team can investigate discrepancies.
+
+Sim should not ask an AI model to invent quantities or decide which Shopify variant to update from an ambiguous product name. Use exact IDs and deterministic conditions for inventory writes, reserving the model for tasks such as summarizing an alert or explaining the operational impact.
+
+## How do you build a scheduled Shopify low-stock monitor in Sim?
+
+Build the scheduled low-stock monitor as a read-only workflow that retrieves inventory, evaluates a fixed threshold, and alerts the operations team in Slack.
+
+### How should the Shopify low-stock workflow start?
+
+Start the low-stock workflow with a Schedule trigger set to the monitoring interval the store can support operationally.
+
+A small store may check once or twice per day, while a high-volume operation may need more frequent checks. The interval should account for catalog size, warehouse synchronization frequency, stock velocity, and how quickly the team can act on an alert.
+
+### How should Sim check Shopify inventory?
+
+Use Sim's native Shopify inventory-reading operation and preserve Shopify's inventory item and location identifiers throughout the workflow. Shopify models an [inventory level as an item at a specific location](https://shopify.dev/docs/api/admin-graphql/latest/queries/inventorylevel), so location-level evaluation avoids hiding a fulfillment shortage behind stock held elsewhere.
+
+Configure Shopify List Inventory Items to return the inventory records needed for the monitored catalog. The operation returns 50 inventory items by default, so repeat it with `pageInfo.endCursor` in the `after` field while `pageInfo.hasNextPage` is true. Each inventory item includes up to 10 locations; when `inventoryLevelsPageInfo.hasNextPage` is true, use Shopify Get Inventory Level with the inventory item ID, repeat it with its `pageInfo.endCursor` in `after`, and evaluate every returned location record.
+
+Normalize each record into fields such as:
+
+- Product title
+- Variant title
+- Inventory item ID
+- Location name
+- Location ID
+- Available quantity
+- Low-stock threshold
+- Shopify admin reference
+
+### How should Sim decide whether Shopify stock is low?
+
+Use a deterministic Condition block to compare available quantity with the store's threshold.
+
+For example, route a variant to the alert branch when available quantity is less than or equal to its threshold. Keep the threshold in a maintained data source when different products need different reorder points. Otherwise, a workflow variable can hold one shared threshold.
+
+The AI model should not make this comparison. Inventory quantities and thresholds are structured values, so a deterministic rule is more predictable and easier to inspect.
+
+### How should Sim alert the operations team about low Shopify stock?
+
+Send a structured Slack alert containing the exact variant, location, available quantity, and threshold that triggered the workflow.
+
+A useful alert template is:
+
+```text
+Low Shopify stock detected
+Product: {{product_title}}
+Variant: {{variant_title}}
+Location: {{location_name}}
+Available: {{available_quantity}}
+Threshold: {{threshold}}
+Inventory item ID: {{inventory_item_id}}
+```
+
+Group alerts into one digest when a large catalog could produce excessive messages. Send an immediate message only for urgent conditions such as zero available units or a high-priority product crossing its safety-stock threshold.
+
+For more trigger patterns, see [Best AI Agent Platforms for Webhook, Schedule, and Event Triggers](https://www.sim.ai/library/best-ai-agent-platforms-webhook-schedule-event-triggers).
+
+## How do you build a Shopify order status agent with human approval?
+
+Build a Shopify order status agent by accepting an order identifier, retrieving the authoritative Shopify record, drafting a customer update, and pausing the run for human review.
+
+### How should the Shopify order status agent receive a request?
+
+Receive an exact Shopify order ID or order number together with the minimum customer information needed to validate the request. Shopify Get Order requires a GraphQL ID such as `gid://shopify/Order/123456789`, so an order number such as `#1001` must first be resolved with Shopify List Orders and its returned order ID passed to Shopify Get Order.
+
+The request can originate from an internal form, a support workflow, Chat, or another authenticated system. Do not let a customer retrieve an order by supplying only a common name or email address. Require an appropriate identity check before exposing order details.
+
+### How should Sim look up the Shopify order?
+
+Use Sim's Shopify order lookup operation and treat Shopify as the source of truth for payment, fulfillment, cancellation, and shipment fields. Shopify's [order query retrieves an order by ID and exposes financial and fulfillment information](https://shopify.dev/docs/api/admin-graphql/latest/queries/order).
+
+Pass the exact Shopify GraphQL order ID into Shopify Get Order. If the request supplies an order number, query Shopify List Orders for that number, verify the match, and pass the returned `id` into Shopify Get Order. If no matching order is found, route the run to a support queue instead of asking the model to infer an order or fabricate a status.
+
+### How should Sim draft the customer update?
+
+Give the model only the verified Shopify fields needed to write a concise status update.
+
+A suitable model instruction is:
+
+```text
+Draft a short customer update using only the supplied Shopify order data. Do not promise a delivery date unless the data contains one. Do not claim an item has shipped unless Shopify reports it as fulfilled or shipped. If the status is ambiguous, say that a support specialist is reviewing the order.
+```
+
+Ask the model for structured output with fields such as `subject`, `message`, `statusSummary`, and `escalationNeeded`. Structured output makes the approval screen and downstream routing more predictable.
+
+### How does human approval work in a Sim Shopify agent?
+
+Sim's Human in the Loop block pauses the workflow and resumes it with the reviewer's submitted form fields.
+
+Add fields for the edited customer message, an approve-or-reject choice, and an optional reviewer note. The Human in the Loop block does not enforce the choice by itself, so place a Condition block immediately afterward and route on the submitted approval value.
+
+The approved branch can pass the message to the store's customer communication system. The rejected branch should stop the customer-facing path and send the request to the support team with the reviewer's note.
+
+For a deeper comparison of approval patterns, see [Best AI Agent Builders for Human Approval Workflows](https://www.sim.ai/library/best-ai-agent-builders-for-human-approval-workflows).
+
+## How do you prevent a Shopify AI agent from sending the wrong order update?
+
+Prevent incorrect Shopify order updates by grounding every factual claim in Shopify data, requiring identity checks, using structured output, and placing a human approval condition before delivery.
+
+Use these controls:
+
+1. Retrieve the order from Shopify instead of relying on customer-supplied status text.
+2. Pass only the relevant order fields to the model.
+3. Prohibit invented shipment dates, refunds, discounts, and fulfillment claims in the model instruction.
+4. Require structured output so missing fields are detectable.
+5. Pause for human review before any customer-facing message.
+6. Route rejected or ambiguous drafts to support.
+7. Inspect the workflow run when a customer disputes a message.
+8. Limit Shopify credentials to the permissions the workflow actually needs.
+
+A read-only order status agent should not receive inventory-write, refund, or cancellation permissions. Separate higher-risk actions into dedicated workflows with narrower credentials and explicit approvals.
+
+## Should a Shopify AI agent automatically update inventory?
+
+A Shopify AI agent should automatically adjust inventory only when the quantity change comes from a trusted system and the workflow has exact inventory item, location, and delta inputs.
+
+Warehouse receipts, approved cycle counts, completed returns, and ERP synchronization events can be suitable sources. Free-form customer messages, model estimates, and ambiguous product names are not suitable sources.
+
+For a write workflow, validate the event, reject missing identifiers, record the previous and requested quantities, require approval for unusual changes, run Shopify's documented [inventory adjustment operation](https://shopify.dev/docs/api/admin-graphql/latest/mutations/inventoryAdjustQuantities), and confirm the resulting quantity. A separate low-stock monitor can then detect whether the adjusted quantity crossed an alert threshold.
+
+## What should ecommerce teams log for Shopify order and inventory agents?
+
+Retain enough workflow-run context to explain what triggered the agent, which Shopify record it used, which condition fired, and what action followed.
+
+Useful diagnostic fields include the workflow version, trigger time, Shopify order or inventory item ID, location ID, quantity before and after a write, threshold, approval decision, reviewer identity where appropriate, downstream message identifier, and error output. Avoid copying unnecessary customer data into prompts, alerts, or logs.
+
+Teams operating important commerce workflows should also monitor failed Shopify requests, repeated retries, missing identifiers, rejected approvals, unusual inventory changes, and message-delivery failures. See [What Is AI Agent Observability? Traces, Metrics, and Evals Explained](https://www.sim.ai/library/ai-agent-observability) for the broader monitoring model.
+
+## What platform fits ecommerce teams automating order and returns handling?
+
+Sim fits ecommerce teams that need customizable AI reasoning, native business-system actions, human approval, and inspectable multi-step workflows for orders and returns.
+
+Shopify Flow often fits deterministic Shopify-only rules. n8n, Zapier, and Make fit teams whose main requirement is broad app-to-app automation. Returns handling should be treated as a higher-risk workflow because it can affect refunds, inventory, shipping labels, and customer communications. Use separate approval gates for financial or irreversible actions.
+
+## What platform fits a small store that needs customer answers and order updates?
+
+Sim fits a small ecommerce store that wants one agent to retrieve Shopify order data, draft grounded answers, and require approval before sending sensitive updates.
+
+Start with a narrow order-status workflow rather than an unrestricted support agent. Let the agent answer only from verified Shopify fields and approved store policies, escalate exceptions to a person, and keep refunds, cancellations, address changes, and inventory writes behind separate controls.
+
+[Build your Shopify agent in Sim](https://www.sim.ai) with a read-only order lookup or low-stock monitor first, then add tightly scoped write actions as the operating process matures.
diff --git a/apps/sim/ee/access-requests/lib/targets.ts b/apps/sim/ee/access-requests/lib/targets.ts
index be0cf1174ff..a34e87b2fe9 100644
--- a/apps/sim/ee/access-requests/lib/targets.ts
+++ b/apps/sim/ee/access-requests/lib/targets.ts
@@ -98,6 +98,7 @@ const FEATURE_PARENTS: Partial<
disableKnowledgeBaseCreation: ['hideKnowledgeBaseTab'],
disableKnowledgeBaseFileUpload: ['hideKnowledgeBaseTab'],
disableKnowledgeBaseExport: ['hideKnowledgeBaseTab'],
+ disableComputerUse: ['hideCopilot'],
disableTableCreation: ['hideTablesTab'],
disableTableExport: ['hideTablesTab'],
disableBulkFileDownload: ['hideFilesTab'],
diff --git a/apps/sim/executor/handlers/agent/handler.test.ts b/apps/sim/executor/handlers/agent/handler.test.ts
index e1b1a3cabc4..85c92e38033 100644
--- a/apps/sim/executor/handlers/agent/handler.test.ts
+++ b/apps/sim/executor/handlers/agent/handler.test.ts
@@ -6,6 +6,10 @@ import {
schemaMock,
setEnvFlags,
} from '@sim/testing'
+import {
+ llmBedrockCredentialMock,
+ llmBedrockCredentialMockFns,
+} from '@sim/testing/mocks/llm-bedrock-credential.mock'
import { getMockLogger } from '@sim/testing/mocks/logger.mock'
import {
permissionCheckMock,
@@ -61,6 +65,8 @@ mockGetBlock.mockReturnValue(undefined)
const mockImportWorkspaceFileSecretProvenanceForModelView =
workspaceFileSecretProvenanceMockFns.mockImportWorkspaceFileSecretProvenanceForModelView
const mockValidateModelProvider = permissionCheckMockFns.mockValidateModelProvider
+const mockResolveBedrockCredentialSession =
+ llmBedrockCredentialMockFns.mockResolveBedrockCredentialSession
mockImportWorkspaceFileSecretProvenanceForModelView.mockResolvedValue(true)
providersUtilsMockFns.mockGetProviderFromModel.mockReturnValue('mock-provider')
providersUtilsMockFns.mockIsDeepResearchModel.mockImplementation((model: string) =>
@@ -75,6 +81,8 @@ const { mockDiscoverMcpServerToolsAsExecutor, mockOpenAgentTurnSession } = vi.ho
mockOpenAgentTurnSession: vi.fn().mockResolvedValue(undefined),
}))
+vi.mock('@/lib/internal/llm/bedrock-credential', () => llmBedrockCredentialMock)
+
vi.mock('@/lib/memory/agent-turn-session', () => ({
openAgentTurnSession: mockOpenAgentTurnSession,
}))
@@ -817,6 +825,7 @@ describe('AgentBlockHandler', () => {
if (model.startsWith('gpt')) return 'openai'
if (model.startsWith('claude')) return 'anthropic'
if (model.startsWith('vertex/')) return 'vertex'
+ if (model.startsWith('bedrock/')) return 'bedrock'
if (model === 'blacklisted-model') throw new Error('provider blacklisted')
return 'openai'
}
@@ -1134,6 +1143,91 @@ describe('AgentBlockHandler', () => {
expect(sameProvider.azureEndpoint).toBe('https://azure.example.com')
})
+ it("signs a Bedrock call with the selected AWS credential's session for the run's user", async () => {
+ const session = {
+ credentialId: 'aws-cred',
+ accessKeyId: 'ASIASESSIONKEY000001',
+ secretAccessKey: 'session-secret',
+ sessionToken: 'session-token',
+ }
+ mockResolveBedrockCredentialSession.mockResolvedValueOnce(session)
+ mockExecuteProviderRequest.mockResolvedValueOnce(providerResponse('bedrock/claude'))
+
+ await handler.execute(mockContext, mockBlock, {
+ ...baseInputs,
+ model: 'bedrock/claude',
+ bedrockAuthMode: 'credential',
+ bedrockCredential: 'aws-cred',
+ bedrockRegion: 'us-west-2',
+ })
+
+ expect(mockResolveBedrockCredentialSession).toHaveBeenCalledWith(
+ expect.objectContaining({
+ credentialId: 'aws-cred',
+ actingUserId: mockContext.userId,
+ workflowId: mockContext.workflowId,
+ workspaceId: mockContext.workspaceId,
+ })
+ )
+ const [providerId, request] = mockExecuteProviderRequest.mock.calls[0]
+ expect(providerId).toBe('bedrock')
+ expect(request.bedrockSession).toEqual(session)
+ expect(request.bedrockRegion).toBe('us-west-2')
+ })
+
+ it('fails a Bedrock call whose AWS credential cannot be used instead of sending it', async () => {
+ mockResolveBedrockCredentialSession.mockRejectedValueOnce(
+ new Error('Not authorized to use this AWS credential')
+ )
+
+ await expect(
+ handler.execute(mockContext, mockBlock, {
+ ...baseInputs,
+ model: 'bedrock/claude',
+ bedrockAuthMode: 'credential',
+ bedrockCredential: 'aws-cred',
+ })
+ ).rejects.toThrow('Not authorized to use this AWS credential')
+ expect(mockExecuteProviderRequest).not.toHaveBeenCalled()
+ })
+
+ it('fails a Bedrock call set to a saved AWS credential when the credential resolves blank', async () => {
+ await expect(
+ handler.execute(mockContext, mockBlock, {
+ ...baseInputs,
+ model: 'bedrock/claude',
+ bedrockAuthMode: 'credential',
+ bedrockCredential: ' ',
+ })
+ ).rejects.toThrow('Select an AWS credential for Bedrock')
+ expect(mockExecuteProviderRequest).not.toHaveBeenCalled()
+ })
+
+ it('keeps the AWS credential off a fallback on another provider', async () => {
+ mockExecuteProviderRequest
+ .mockRejectedValueOnce(new Error('overloaded'))
+ .mockResolvedValueOnce(providerResponse('gpt-4o-mini'))
+ mockResolveBedrockCredentialSession.mockResolvedValueOnce({
+ credentialId: 'aws-cred',
+ accessKeyId: 'AKIASAVEDKEY00000001',
+ secretAccessKey: 'saved-secret',
+ })
+
+ await handler.execute(mockContext, mockBlock, {
+ ...baseInputs,
+ model: 'bedrock/claude',
+ bedrockAuthMode: 'credential',
+ bedrockCredential: 'aws-cred',
+ fallbackModels: [{ model: 'gpt-4o-mini' }],
+ })
+
+ const [fallbackProvider, fallbackRequest] = mockExecuteProviderRequest.mock.calls[1]
+ expect(fallbackProvider).toBe('openai')
+ expect(fallbackRequest.bedrockSession).toBeUndefined()
+ expect(JSON.stringify(fallbackRequest)).not.toMatch(/aws-cred|saved-secret/)
+ expect(mockResolveBedrockCredentialSession).toHaveBeenCalledTimes(1)
+ })
+
it('re-resolves tuning for the fallback: row value wins, caps clamp, undeclared values drop', async () => {
const fallbackCap = getModelCapabilities('gpt-5.4-mini')?.maxOutputTokens
expect(fallbackCap).toEqual(expect.any(Number))
diff --git a/apps/sim/executor/handlers/agent/handler.ts b/apps/sim/executor/handlers/agent/handler.ts
index 96a953799ee..03da301096e 100644
--- a/apps/sim/executor/handlers/agent/handler.ts
+++ b/apps/sim/executor/handlers/agent/handler.ts
@@ -93,6 +93,10 @@ import type {
} from '@/executor/types'
import { collectBlockData } from '@/executor/utils/block-data'
import { stringifyJSON } from '@/executor/utils/json'
+import {
+ BLOCK_MODEL_CREDENTIAL_FIELDS,
+ resolveBlockModelCredentials,
+} from '@/executor/utils/model-credentials'
import {
assertFallbackFamilyCredentials,
getModelFallbacks,
@@ -108,7 +112,6 @@ import type {
ResolvedSecretTraceRegistry,
} from '@/executor/utils/resolved-secret-trace-registry'
import { annotateDuplicateToolBindings } from '@/executor/utils/tool-binding-labels'
-import { resolveVertexCredential } from '@/executor/utils/vertex-credential'
import { executeProviderRequest } from '@/providers'
import {
formatAttachmentSizes,
@@ -160,6 +163,7 @@ const AGENT_RAW_PROVIDER_ERROR_INPUT_PATHS: readonly ResolvedSecretInputPath[] =
['bedrockAccessKeyId'],
['bedrockSecretKey'],
['bedrockRegion'],
+ ['bedrockCredential'],
['reasoningEffort'],
['verbosity'],
['thinkingLevel'],
@@ -2706,7 +2710,10 @@ export class AgentBlockHandler implements BlockHandler {
inputs = {
...(sameProvider
? config.modelInputs
- : omit(config.modelInputs, [...PROVIDER_FAMILY_CREDENTIAL_FIELDS, 'vertexCredential'])),
+ : omit(config.modelInputs, [
+ ...PROVIDER_FAMILY_CREDENTIAL_FIELDS,
+ ...BLOCK_MODEL_CREDENTIAL_FIELDS,
+ ])),
apiKey: resolveFallbackApiKey({
candidate,
configuredModel: config.configuredModel,
@@ -2920,6 +2927,8 @@ export class AgentBlockHandler implements BlockHandler {
bedrockAccessKeyId: inputs.bedrockAccessKeyId,
bedrockSecretKey: inputs.bedrockSecretKey,
bedrockRegion: inputs.bedrockRegion,
+ bedrockCredential: inputs.bedrockCredential,
+ bedrockAuthMode: inputs.bedrockAuthMode,
responseFormat,
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,
@@ -2980,18 +2989,12 @@ export class AgentBlockHandler implements BlockHandler {
const model = providerRequest.model
try {
- let finalApiKey: string | undefined = providerRequest.apiKey
-
- if (providerId === 'vertex' && providerRequest.vertexCredential) {
- finalApiKey = await resolveVertexCredential({
- credentialId: providerRequest.vertexCredential,
- actingUserId: ctx.userId,
- workspaceId: ctx.workspaceId,
- workflowId: ctx.workflowId,
- executionId: ctx.executionId,
- callerLabel: 'vertex-agent',
- })
- }
+ const credentials = await resolveBlockModelCredentials(
+ ctx,
+ providerId,
+ providerRequest,
+ 'vertex-agent'
+ )
const { blockData, blockNameMapping } = collectBlockData(ctx)
const agentMemoryRetrieval = agentConversation?.memoryId
@@ -3014,7 +3017,7 @@ export class AgentBlockHandler implements BlockHandler {
: providerRequest.tools,
temperature: providerRequest.temperature,
maxTokens: providerRequest.maxTokens,
- apiKey: finalApiKey,
+ apiKey: credentials.apiKey ?? providerRequest.apiKey,
azureEndpoint: providerRequest.azureEndpoint,
azureApiVersion: providerRequest.azureApiVersion,
vertexProject: providerRequest.vertexProject,
@@ -3022,6 +3025,7 @@ export class AgentBlockHandler implements BlockHandler {
bedrockAccessKeyId: providerRequest.bedrockAccessKeyId,
bedrockSecretKey: providerRequest.bedrockSecretKey,
bedrockRegion: providerRequest.bedrockRegion,
+ bedrockSession: credentials.bedrockSession,
responseFormat: providerRequest.responseFormat,
workflowId: providerRequest.workflowId,
workspaceId: ctx.workspaceId,
diff --git a/apps/sim/executor/handlers/agent/types.ts b/apps/sim/executor/handlers/agent/types.ts
index a092fee6959..4b218e90be7 100644
--- a/apps/sim/executor/handlers/agent/types.ts
+++ b/apps/sim/executor/handlers/agent/types.ts
@@ -46,6 +46,10 @@ export interface AgentInputs {
bedrockAccessKeyId?: string
bedrockSecretKey?: string
bedrockRegion?: string
+ /** A saved AWS credential ID; Bedrock signs with it instead of inline keys. */
+ bedrockCredential?: string
+ /** `'credential'` when the block signs Bedrock calls with {@link bedrockCredential}. */
+ bedrockAuthMode?: string
reasoningEffort?: string
verbosity?: string
thinkingLevel?: string
diff --git a/apps/sim/executor/handlers/evaluator/handler.test.ts b/apps/sim/executor/handlers/evaluator/handler.test.ts
index c181b4c1248..c443c1efcab 100644
--- a/apps/sim/executor/handlers/evaluator/handler.test.ts
+++ b/apps/sim/executor/handlers/evaluator/handler.test.ts
@@ -2,6 +2,11 @@ import {
credentialsAccessMock,
credentialsAccessMockFns,
} from '@sim/testing/mocks/credentials-access.mock'
+import {
+ llmBedrockCredentialMock,
+ llmBedrockCredentialMockFns,
+ MockBedrockCredentialError,
+} from '@sim/testing/mocks/llm-bedrock-credential.mock'
import { permissionsMock, permissionsMockFns } from '@sim/testing/mocks/permissions.mock'
import '@sim/testing/mocks/executor'
@@ -12,6 +17,8 @@ const { mockResolveAutoModel } = vi.hoisted(() => ({
mockResolveAutoModel: vi.fn(),
}))
+vi.mock('@/lib/internal/llm/bedrock-credential', () => llmBedrockCredentialMock)
+
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
vi.mock('@/lib/oauth/credential-service', () => authOAuthUtilsMock)
@@ -52,6 +59,8 @@ credentialsAccessMockFns.mockGetCredentialActorContext.mockResolvedValue({
})
const mockCheckWorkspaceAccess = permissionsMockFns.mockCheckWorkspaceAccess
+const mockResolveBedrockCredentialSession =
+ llmBedrockCredentialMockFns.mockResolveBedrockCredentialSession
const mockGetProviderFromModel = getProviderFromModel as Mock
const mockExecuteProviderRequest = executeProviderRequest as Mock
@@ -176,6 +185,136 @@ describe('EvaluatorBlockHandler', () => {
expect(fallback.apiKey).toBeUndefined()
})
+ it("signs a Bedrock evaluation with the selected AWS credential's session", async () => {
+ const session = {
+ credentialId: 'aws-cred',
+ accessKeyId: 'ASIASESSIONKEY000001',
+ secretAccessKey: 'session-secret',
+ sessionToken: 'session-token',
+ }
+ mockGetProviderFromModel.mockReturnValue('bedrock')
+ mockResolveBedrockCredentialSession.mockResolvedValueOnce(session)
+
+ await handler.execute(mockContext, mockBlock, {
+ ...admissionInputs,
+ model: 'bedrock/claude',
+ bedrockAuthMode: 'credential',
+ bedrockCredential: 'aws-cred',
+ })
+
+ expect(mockResolveBedrockCredentialSession).toHaveBeenCalledWith(
+ expect.objectContaining({ credentialId: 'aws-cred', actingUserId: 'test-user' })
+ )
+ expect(mockExecuteProviderRequest.mock.calls[0][1].bedrockSession).toEqual(session)
+ })
+
+ it('fails a Bedrock evaluation set to a saved AWS credential when none resolves', async () => {
+ mockGetProviderFromModel.mockReturnValue('bedrock')
+
+ await expect(
+ handler.execute(mockContext, mockBlock, {
+ ...admissionInputs,
+ model: 'bedrock/claude',
+ bedrockAuthMode: 'credential',
+ })
+ ).rejects.toThrow('Select an AWS credential for Bedrock')
+ expect(mockExecuteProviderRequest).not.toHaveBeenCalled()
+ })
+
+ it('keeps the AWS credential session off a fallback on another provider', async () => {
+ mockGetProviderFromModel.mockImplementation((model: string) =>
+ model.startsWith('claude') ? 'anthropic' : 'bedrock'
+ )
+ mockResolveBedrockCredentialSession.mockResolvedValueOnce({
+ credentialId: 'aws-cred',
+ accessKeyId: 'AKIASAVEDKEY00000001',
+ secretAccessKey: 'saved-secret',
+ })
+ mockExecuteProviderRequest
+ .mockRejectedValueOnce(new Error('overloaded'))
+ .mockResolvedValueOnce({
+ content: '{"score1":7}',
+ model: 'claude-sonnet-5',
+ tokens: { input: 12, output: 3, total: 15 },
+ cost: 0,
+ })
+
+ await handler.execute(mockContext, mockBlock, {
+ ...admissionInputs,
+ model: 'bedrock/claude',
+ bedrockAuthMode: 'credential',
+ bedrockCredential: 'aws-cred',
+ fallbackModels: [{ model: 'claude-sonnet-5' }],
+ })
+
+ const [fallbackProvider, fallbackRequest] = mockExecuteProviderRequest.mock.calls[1]
+ expect(fallbackProvider).toBe('anthropic')
+ expect(fallbackRequest.bedrockSession).toBeUndefined()
+ expect(JSON.stringify(fallbackRequest)).not.toContain('saved-secret')
+ })
+
+ it('scores with a fallback model when the saved AWS credential is unavailable on the final try', async () => {
+ mockGetProviderFromModel.mockImplementation((model: string) =>
+ model.startsWith('claude') ? 'anthropic' : 'bedrock'
+ )
+ mockResolveBedrockCredentialSession.mockRejectedValueOnce(
+ new MockBedrockCredentialError('AWS STS is unavailable', { reason: 'unavailable' })
+ )
+ mockExecuteProviderRequest.mockResolvedValueOnce({
+ content: '{"score1":7}',
+ model: 'claude-sonnet-5',
+ tokens: { input: 12, output: 3, total: 15 },
+ cost: 0,
+ })
+ mockContext.resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry()
+ mockContext.blockLogs = [
+ {
+ blockId: mockBlock.id,
+ startedAt: '',
+ endedAt: '',
+ durationMs: 0,
+ success: false,
+ executionOrder: 1,
+ },
+ ]
+
+ const output = await handler.execute(
+ mockContext,
+ mockBlock,
+ {
+ ...admissionInputs,
+ model: 'bedrock/claude',
+ bedrockAuthMode: 'credential',
+ bedrockCredential: 'aws-cred',
+ fallbackModels: [{ model: 'claude-sonnet-5' }],
+ },
+ { nodeId: mockBlock.id, retry: { attempt: 2, maxTries: 2, isFinalTry: true } }
+ )
+
+ expect(output).toMatchObject({ model: 'claude-sonnet-5', score1: 7 })
+ expect(mockExecuteProviderRequest).toHaveBeenCalledTimes(1)
+ expect(mockExecuteProviderRequest.mock.calls[0][0]).toBe('anthropic')
+ expect(mockContext.blockLogs[0].modelFallbacks).toEqual(['bedrock/claude'])
+ })
+
+ it('fails a Bedrock evaluation whose AWS credential cannot be used instead of sending it', async () => {
+ mockGetProviderFromModel.mockReturnValue('bedrock')
+ mockResolveBedrockCredentialSession.mockRejectedValueOnce(
+ new Error('Not authorized to use this AWS credential')
+ )
+ mockExecuteProviderRequest.mockClear()
+
+ await expect(
+ handler.execute(mockContext, mockBlock, {
+ ...admissionInputs,
+ model: 'bedrock/claude',
+ bedrockAuthMode: 'credential',
+ bedrockCredential: 'aws-cred',
+ })
+ ).rejects.toThrow('Not authorized to use this AWS credential')
+ expect(mockExecuteProviderRequest).not.toHaveBeenCalled()
+ })
+
it('refuses to reach the provider without an execution subject', async () => {
mockContext.userId = undefined
diff --git a/apps/sim/executor/handlers/evaluator/handler.ts b/apps/sim/executor/handlers/evaluator/handler.ts
index 68118cd0e31..7eddaf327f5 100644
--- a/apps/sim/executor/handlers/evaluator/handler.ts
+++ b/apps/sim/executor/handlers/evaluator/handler.ts
@@ -18,7 +18,6 @@ import type {
ResolvedSecretInputPath,
ResolvedSecretTraceRegistry,
} from '@/executor/utils/resolved-secret-trace-registry'
-import { resolveVertexCredential } from '@/executor/utils/vertex-credential'
import { resolveProxiedModelCost } from '@/providers/cost-policy'
import { isAutoModel, SIM_AUTO_MODEL_ID } from '@/providers/models'
import type { ProviderRequest } from '@/providers/types'
@@ -50,6 +49,8 @@ export class EvaluatorBlockHandler implements BlockHandler {
bedrockAccessKeyId: inputs.bedrockAccessKeyId,
bedrockSecretKey: inputs.bedrockSecretKey,
bedrockRegion: inputs.bedrockRegion,
+ bedrockCredential: inputs.bedrockCredential,
+ bedrockAuthMode: inputs.bedrockAuthMode,
}
let systemPromptObj: { systemPrompt: string; responseFormat: any } = {
@@ -178,18 +179,6 @@ export class EvaluatorBlockHandler implements BlockHandler {
await validateModelProvider(ctx.userId, ctx.workspaceId, model, ctx)
const providerId = getProviderFromModel(model)
- let finalApiKey: string | undefined = evaluatorConfig.apiKey
- if (providerId === 'vertex' && evaluatorConfig.vertexCredential) {
- finalApiKey = await resolveVertexCredential({
- credentialId: evaluatorConfig.vertexCredential,
- actingUserId: ctx.userId,
- workspaceId: ctx.workspaceId,
- workflowId: ctx.workflowId,
- executionId: ctx.executionId,
- callerLabel: 'vertex-evaluator',
- })
- }
-
try {
const providerRequest: ProviderRequest = {
model,
@@ -204,7 +193,7 @@ export class EvaluatorBlockHandler implements BlockHandler {
]),
temperature: EVALUATOR.DEFAULT_TEMPERATURE,
- apiKey: finalApiKey,
+ apiKey: evaluatorConfig.apiKey,
azureEndpoint: inputs.azureEndpoint,
azureApiVersion: inputs.azureApiVersion,
vertexProject: evaluatorConfig.vertexProject,
@@ -226,6 +215,7 @@ export class EvaluatorBlockHandler implements BlockHandler {
providerId,
request: providerRequest,
resolvedSecretTraceRegistry: modelInputProjection.registry,
+ credentials: { inputs: evaluatorConfig, callerLabel: 'vertex-evaluator' },
})
const parsedContent = this.extractJSONFromResponse(
diff --git a/apps/sim/executor/handlers/router/handler.test.ts b/apps/sim/executor/handlers/router/handler.test.ts
index efd6684a7b9..2230532f119 100644
--- a/apps/sim/executor/handlers/router/handler.test.ts
+++ b/apps/sim/executor/handlers/router/handler.test.ts
@@ -2,6 +2,11 @@ import {
credentialsAccessMock,
credentialsAccessMockFns,
} from '@sim/testing/mocks/credentials-access.mock'
+import {
+ llmBedrockCredentialMock,
+ llmBedrockCredentialMockFns,
+ MockBedrockCredentialError,
+} from '@sim/testing/mocks/llm-bedrock-credential.mock'
import { permissionsMock, permissionsMockFns } from '@sim/testing/mocks/permissions.mock'
import '@sim/testing/mocks/executor'
@@ -18,6 +23,8 @@ const { mockResolveAutoModel } = vi.hoisted(() => ({
mockResolveAutoModel: vi.fn(),
}))
+vi.mock('@/lib/internal/llm/bedrock-credential', () => llmBedrockCredentialMock)
+
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
vi.mock('@/lib/oauth/credential-service', () => authOAuthUtilsMock)
@@ -59,6 +66,8 @@ credentialsAccessMockFns.mockGetCredentialActorContext.mockResolvedValue({
})
const mockCheckWorkspaceAccess = permissionsMockFns.mockCheckWorkspaceAccess
+const mockResolveBedrockCredentialSession =
+ llmBedrockCredentialMockFns.mockResolveBedrockCredentialSession
const mockGenerateRouterPrompt = generateRouterPrompt as Mock
const mockGenerateRouterV2Prompt = generateRouterV2Prompt as Mock
@@ -178,6 +187,90 @@ describe('RouterBlockHandler', () => {
})
})
+ it("signs a Bedrock routing call with the selected AWS credential's session", async () => {
+ const session = {
+ credentialId: 'aws-cred',
+ accessKeyId: 'ASIASESSIONKEY000001',
+ secretAccessKey: 'session-secret',
+ sessionToken: 'session-token',
+ }
+ mockGetProviderFromModel.mockReturnValue('bedrock')
+ mockResolveBedrockCredentialSession.mockResolvedValueOnce(session)
+
+ await handler.execute(mockContext, mockBlock, {
+ prompt: 'Pick a destination',
+ model: 'bedrock/claude',
+ bedrockAuthMode: 'credential',
+ bedrockCredential: 'aws-cred',
+ })
+
+ expect(mockResolveBedrockCredentialSession).toHaveBeenCalledWith(
+ expect.objectContaining({ credentialId: 'aws-cred', actingUserId: 'test-user' })
+ )
+ expect(mockExecuteProviderRequest.mock.calls[0][1].bedrockSession).toEqual(session)
+ })
+
+ it('fails a Bedrock routing call set to a saved AWS credential when none resolves', async () => {
+ mockGetProviderFromModel.mockReturnValue('bedrock')
+
+ await expect(
+ handler.execute(mockContext, mockBlock, {
+ prompt: 'Pick a destination',
+ model: 'bedrock/claude',
+ bedrockAuthMode: 'credential',
+ bedrockCredential: '',
+ })
+ ).rejects.toThrow('Select an AWS credential for Bedrock')
+ expect(mockExecuteProviderRequest).not.toHaveBeenCalled()
+ })
+
+ it('routes with a fallback model when the saved AWS credential is unavailable on the final try', async () => {
+ mockGetProviderFromModel.mockImplementation((model: string) =>
+ model.startsWith('claude') ? 'anthropic' : 'bedrock'
+ )
+ mockResolveBedrockCredentialSession.mockRejectedValueOnce(
+ new MockBedrockCredentialError('AWS STS is unavailable', { reason: 'unavailable' })
+ )
+ mockExecuteProviderRequest.mockResolvedValueOnce({
+ content: 'target-block-1',
+ model: 'claude-sonnet-5',
+ tokens: { input: 10, output: 2, total: 12 },
+ cost: 0,
+ })
+ mockContext.resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry()
+ mockContext.blockLogs = [
+ {
+ blockId: mockBlock.id,
+ startedAt: '',
+ endedAt: '',
+ durationMs: 0,
+ success: false,
+ executionOrder: 1,
+ },
+ ]
+
+ const output = await handler.execute(
+ mockContext,
+ mockBlock,
+ {
+ prompt: 'Pick a destination',
+ model: 'bedrock/claude',
+ bedrockAuthMode: 'credential',
+ bedrockCredential: 'aws-cred',
+ fallbackModels: [{ model: 'claude-sonnet-5' }],
+ },
+ { nodeId: mockBlock.id, retry: { attempt: 2, maxTries: 2, isFinalTry: true } }
+ )
+
+ expect(output).toMatchObject({
+ model: 'claude-sonnet-5',
+ selectedPath: { blockId: 'target-block-1' },
+ })
+ expect(mockExecuteProviderRequest).toHaveBeenCalledTimes(1)
+ expect(mockExecuteProviderRequest.mock.calls[0][0]).toBe('anthropic')
+ expect(mockContext.blockLogs[0].modelFallbacks).toEqual(['bedrock/claude'])
+ })
+
it('selects the same legacy destination when a fallback provider answers', async () => {
mockExecuteProviderRequest
.mockRejectedValueOnce(new Error('overloaded'))
@@ -514,6 +607,33 @@ describe('RouterBlockHandler V2', () => {
})
})
+ it("signs a Bedrock Router V2 call with the selected AWS credential's session", async () => {
+ const session = {
+ credentialId: 'aws-cred',
+ accessKeyId: 'ASIASESSIONKEY000001',
+ secretAccessKey: 'session-secret',
+ sessionToken: 'session-token',
+ }
+ mockGetProviderFromModel.mockReturnValue('bedrock')
+ mockResolveBedrockCredentialSession.mockResolvedValueOnce(session)
+ mockExecuteProviderRequest.mockResolvedValueOnce({
+ content: '{"route":"route-support","reasoning":"Needs assistance"}',
+ model: 'bedrock/claude',
+ tokens: { input: 10, output: 2, total: 12 },
+ cost: 0,
+ })
+
+ await handler.execute(mockContext, mockRouterV2Block, {
+ context: 'Help me',
+ model: 'bedrock/claude',
+ routes: [{ id: 'route-support', title: 'Support', value: 'Needs help' }],
+ bedrockAuthMode: 'credential',
+ bedrockCredential: 'aws-cred',
+ })
+
+ expect(mockExecuteProviderRequest.mock.calls[0][1].bedrockSession).toEqual(session)
+ })
+
it('preserves route selection and reasoning when an Auto request falls back', async () => {
mockExecuteProviderRequest
.mockRejectedValueOnce(new Error('overloaded'))
diff --git a/apps/sim/executor/handlers/router/handler.ts b/apps/sim/executor/handlers/router/handler.ts
index 8abcf52792b..784bc35620d 100644
--- a/apps/sim/executor/handlers/router/handler.ts
+++ b/apps/sim/executor/handlers/router/handler.ts
@@ -20,7 +20,6 @@ import type { BlockHandler, BlockNodeMetadata, ExecutionContext } from '@/execut
import { executeModelRequestWithFallbacks } from '@/executor/utils/model-fallback-request'
import { refuseResolvedSecretProjection } from '@/executor/utils/resolved-secret-projection-refusal'
import type { ResolvedSecretInputPath } from '@/executor/utils/resolved-secret-trace-registry'
-import { resolveVertexCredential } from '@/executor/utils/vertex-credential'
import { resolveProxiedModelCost } from '@/providers/cost-policy'
import { isAutoModel, SIM_AUTO_MODEL_ID } from '@/providers/models'
import type { ProviderRequest } from '@/providers/types'
@@ -96,6 +95,8 @@ export class RouterBlockHandler implements BlockHandler {
bedrockAccessKeyId: inputs.bedrockAccessKeyId,
bedrockSecretKey: inputs.bedrockSecretKey,
bedrockRegion: inputs.bedrockRegion,
+ bedrockCredential: inputs.bedrockCredential,
+ bedrockAuthMode: inputs.bedrockAuthMode,
}
try {
@@ -113,24 +114,12 @@ export class RouterBlockHandler implements BlockHandler {
await validateModelProvider(ctx.userId, ctx.workspaceId, resolved.model, ctx)
const providerId = getProviderFromModel(resolved.model)
- let finalApiKey: string | undefined = routerConfig.apiKey
- if (providerId === 'vertex' && routerConfig.vertexCredential) {
- finalApiKey = await resolveVertexCredential({
- credentialId: routerConfig.vertexCredential,
- actingUserId: ctx.userId,
- workspaceId: ctx.workspaceId,
- workflowId: ctx.workflowId,
- executionId: ctx.executionId,
- callerLabel: 'vertex-router',
- })
- }
-
const providerRequest: ProviderRequest = {
model: resolved.model,
systemPrompt: resolved.systemPrompt,
context: JSON.stringify(messages),
temperature: ROUTER.INFERENCE_TEMPERATURE,
- apiKey: finalApiKey,
+ apiKey: routerConfig.apiKey,
azureEndpoint: inputs.azureEndpoint,
azureApiVersion: inputs.azureApiVersion,
vertexProject: routerConfig.vertexProject,
@@ -152,6 +141,7 @@ export class RouterBlockHandler implements BlockHandler {
providerId,
request: providerRequest,
resolvedSecretTraceRegistry: modelInputProjection.registry,
+ credentials: { inputs: routerConfig, callerLabel: 'vertex-router' },
})
const chosenBlockId = result.content.trim().toLowerCase()
@@ -266,6 +256,8 @@ export class RouterBlockHandler implements BlockHandler {
bedrockAccessKeyId: inputs.bedrockAccessKeyId,
bedrockSecretKey: inputs.bedrockSecretKey,
bedrockRegion: inputs.bedrockRegion,
+ bedrockCredential: inputs.bedrockCredential,
+ bedrockAuthMode: inputs.bedrockAuthMode,
}
try {
@@ -283,24 +275,12 @@ export class RouterBlockHandler implements BlockHandler {
await validateModelProvider(ctx.userId, ctx.workspaceId, resolved.model, ctx)
const providerId = getProviderFromModel(resolved.model)
- let finalApiKey: string | undefined = routerConfig.apiKey
- if (providerId === 'vertex' && routerConfig.vertexCredential) {
- finalApiKey = await resolveVertexCredential({
- credentialId: routerConfig.vertexCredential,
- actingUserId: ctx.userId,
- workspaceId: ctx.workspaceId,
- workflowId: ctx.workflowId,
- executionId: ctx.executionId,
- callerLabel: 'vertex-router',
- })
- }
-
const providerRequest: ProviderRequest = {
model: resolved.model,
systemPrompt: resolved.systemPrompt,
context: JSON.stringify(messages),
temperature: ROUTER.INFERENCE_TEMPERATURE,
- apiKey: finalApiKey,
+ apiKey: routerConfig.apiKey,
azureEndpoint: inputs.azureEndpoint,
azureApiVersion: inputs.azureApiVersion,
vertexProject: routerConfig.vertexProject,
@@ -341,6 +321,7 @@ export class RouterBlockHandler implements BlockHandler {
providerId,
request: providerRequest,
resolvedSecretTraceRegistry: modelInputProjection.registry,
+ credentials: { inputs: routerConfig, callerLabel: 'vertex-router' },
})
let chosenRouteId: string
diff --git a/apps/sim/executor/utils/model-credentials.ts b/apps/sim/executor/utils/model-credentials.ts
new file mode 100644
index 00000000000..943ca4bd258
--- /dev/null
+++ b/apps/sim/executor/utils/model-credentials.ts
@@ -0,0 +1,64 @@
+import { resolveBedrockCredentialSession } from '@/lib/internal/llm/bedrock-credential'
+import { getSelectedBedrockCredential } from '@/blocks/utils'
+import type { ExecutionContext } from '@/executor/types'
+import { resolveVertexCredential } from '@/executor/utils/vertex-credential'
+import type { BedrockCredentialSession } from '@/providers/types'
+
+/**
+ * The block fields that select a saved model credential. A fallback on another
+ * provider drops them, so one provider's credential never reaches another.
+ */
+export const BLOCK_MODEL_CREDENTIAL_FIELDS = [
+ 'vertexCredential',
+ 'bedrockCredential',
+ 'bedrockAuthMode',
+] as const
+
+/** A block's saved model credential selection. */
+export type BlockModelCredentialInputs = Parameters[0] & {
+ vertexCredential?: string
+}
+
+/** What a saved credential adds to a provider request; a field is present only when it resolved. */
+export interface BlockModelCredentials {
+ apiKey?: string
+ bedrockSession?: BedrockCredentialSession
+}
+
+/**
+ * Resolves the saved credential a block's model call signs with: a Vertex AI
+ * OAuth or service-account token (returned as `apiKey`), or a Bedrock AWS
+ * credential session. Resolution runs for the run's acting user within the
+ * workflow's workspace, and a failure fails that attempt rather than letting it
+ * fall back to other credentials.
+ */
+export async function resolveBlockModelCredentials(
+ ctx: ExecutionContext,
+ providerId: string,
+ inputs: BlockModelCredentialInputs,
+ callerLabel: string
+): Promise {
+ if (providerId === 'vertex' && inputs.vertexCredential) {
+ const apiKey = await resolveVertexCredential({
+ credentialId: inputs.vertexCredential,
+ actingUserId: ctx.userId,
+ workspaceId: ctx.workspaceId,
+ workflowId: ctx.workflowId,
+ executionId: ctx.executionId,
+ callerLabel,
+ })
+ return { apiKey }
+ }
+ if (providerId !== 'bedrock') return {}
+ const credentialId = getSelectedBedrockCredential(inputs)
+ if (!credentialId) return {}
+ const bedrockSession = await resolveBedrockCredentialSession({
+ credentialId,
+ actingUserId: ctx.userId,
+ workflowId: ctx.workflowId,
+ workspaceId: ctx.workspaceId,
+ executionId: ctx.executionId,
+ signal: ctx.abortSignal,
+ })
+ return { bedrockSession }
+}
diff --git a/apps/sim/executor/utils/model-fallback-request.test.ts b/apps/sim/executor/utils/model-fallback-request.test.ts
index ab25ce03736..045c046db46 100644
--- a/apps/sim/executor/utils/model-fallback-request.test.ts
+++ b/apps/sim/executor/utils/model-fallback-request.test.ts
@@ -1,4 +1,8 @@
import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing'
+import {
+ llmBedrockCredentialMock,
+ llmBedrockCredentialMockFns,
+} from '@sim/testing/mocks/llm-bedrock-credential.mock'
import {
permissionCheckMock,
permissionCheckMockFns,
@@ -11,8 +15,10 @@ import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-tr
import type { SerializedBlock } from '@/serializer/types'
const validateModel = permissionCheckMockFns.mockValidateModelProvider
+const resolveBedrockSession = llmBedrockCredentialMockFns.mockResolveBedrockCredentialSession
providersUtilsMockFns.mockGetProviderFromModel.mockImplementation((model: string) => {
if (model.startsWith('claude')) return 'anthropic'
+ if (model.startsWith('bedrock/')) return 'bedrock'
if (model.startsWith('vertex/')) return 'vertex'
return 'openai'
})
@@ -24,6 +30,7 @@ const { request } = vi.hoisted(() => ({
vi.mock('@/executor/utils/provider-request', () => ({ executeBlockProviderRequest: request }))
vi.mock('@/ee/access-control/utils/permission-check', () => permissionCheckMock)
vi.mock('@/providers/utils', () => providersUtilsMock)
+vi.mock('@/lib/internal/llm/bedrock-credential', () => llmBedrockCredentialMock)
function context(): ExecutionContext {
return {
@@ -246,6 +253,62 @@ describe('executeModelRequestWithFallbacks', () => {
expect(request.mock.calls[1][0].request.apiKey).toBeUndefined()
})
+ it('reuses the saved AWS session for a same-provider fallback after the primary model fails', async () => {
+ const session = {
+ credentialId: 'aws-cred',
+ accessKeyId: 'ASIASESSIONKEY000001',
+ secretAccessKey: 'session-secret',
+ sessionToken: 'session-token',
+ }
+ resolveBedrockSession.mockResolvedValueOnce(session)
+ request.mockRejectedValueOnce(new Error('overloaded'))
+
+ const output = await executeModelRequestWithFallbacks({
+ ...input(),
+ providerId: 'bedrock',
+ configuredModel: 'bedrock/claude-sonnet',
+ request: { model: 'bedrock/claude-sonnet', systemPrompt: 'Score the content' },
+ fallbackModels: [{ model: 'bedrock/claude-haiku' }],
+ credentials: {
+ inputs: { bedrockAuthMode: 'credential', bedrockCredential: 'aws-cred' },
+ callerLabel: 'test',
+ },
+ })
+
+ expect(output.result.model).toBe('bedrock/claude-haiku')
+ expect(resolveBedrockSession).toHaveBeenCalledTimes(1)
+ expect(request.mock.calls.map(([args]) => args.request.bedrockSession)).toEqual([
+ session,
+ session,
+ ])
+ })
+
+ it('fails every same-provider candidate after one failed credential resolution, then falls back without the session', async () => {
+ resolveBedrockSession.mockRejectedValueOnce(new Error('AWS STS is unavailable'))
+ const options = input()
+
+ const output = await executeModelRequestWithFallbacks({
+ ...options,
+ providerId: 'bedrock',
+ configuredModel: 'bedrock/claude-sonnet',
+ request: { model: 'bedrock/claude-sonnet', systemPrompt: 'Score the content' },
+ fallbackModels: [{ model: 'bedrock/claude-haiku' }, { model: 'claude-sonnet-5' }],
+ credentials: {
+ inputs: { bedrockAuthMode: 'credential', bedrockCredential: 'aws-cred' },
+ callerLabel: 'test',
+ },
+ })
+
+ expect(output.result.model).toBe('claude-sonnet-5')
+ expect(resolveBedrockSession).toHaveBeenCalledTimes(1)
+ expect(request.mock.calls.map(([args]) => args.providerId)).toEqual(['anthropic'])
+ expect(request.mock.calls[0][0].request.bedrockSession).toBeUndefined()
+ expect(options.ctx.blockLogs[0].modelFallbacks).toEqual([
+ 'bedrock/claude-sonnet',
+ 'bedrock/claude-haiku',
+ ])
+ })
+
it('strips the Auto identity prompt from fallbacks and keeps the pool model out of the trace', async () => {
const options = input()
request.mockRejectedValueOnce(new Error('overloaded'))
diff --git a/apps/sim/executor/utils/model-fallback-request.ts b/apps/sim/executor/utils/model-fallback-request.ts
index 20f9183c9d7..e4687e58c90 100644
--- a/apps/sim/executor/utils/model-fallback-request.ts
+++ b/apps/sim/executor/utils/model-fallback-request.ts
@@ -4,6 +4,11 @@ import { resolveFallbackTuning } from '@/lib/workflows/blocks/fallback-models'
import { validateModelProvider } from '@/ee/access-control/utils/permission-check'
import { isRetryableBlockError } from '@/executor/execution/block-retry'
import type { BlockRetryAttempt, ExecutionContext } from '@/executor/types'
+import {
+ type BlockModelCredentialInputs,
+ type BlockModelCredentials,
+ resolveBlockModelCredentials,
+} from '@/executor/utils/model-credentials'
import {
assertFallbackFamilyCredentials,
getModelFallbacks,
@@ -32,11 +37,16 @@ interface ModelFallbackRequestInput {
fallbackSystemPrompt: string
retry?: BlockRetryAttempt
resolvedSecretTraceRegistry: ResolvedSecretTraceRegistry | undefined
+ /** The block's saved Vertex or Bedrock credential selection, resolved per attempt. */
+ credentials?: { inputs: BlockModelCredentialInputs; callerLabel: string }
}
/**
* Shared non-streaming model execution for Router and Evaluator. Block retries
* exhaust the primary first; the final try walks the ordered fallback models.
+ * Saved credentials resolve inside the attempt, so one that cannot be resolved fails
+ * that attempt and the fallbacks still run. Only candidates on the primary's provider
+ * resolve them, once per call, and they never reach another provider.
* Parsing a successful response remains the handler's job, so a routing decision
* such as NO_MATCH is never silently replaced by another model's decision.
*/
@@ -50,6 +60,7 @@ export async function executeModelRequestWithFallbacks({
fallbackSystemPrompt,
retry,
resolvedSecretTraceRegistry,
+ credentials,
}: ModelFallbackRequestInput): Promise<{ result: ProviderResponse; usedFallback: boolean }> {
const fallbacks = getModelFallbacks(ctx, block, fallbackModels, logger)
const candidates = [
@@ -62,6 +73,7 @@ export async function executeModelRequestWithFallbacks({
]
const failedModels: string[] = []
let lastError: unknown
+ let savedCredentials: Promise | undefined
for (const [index, candidate] of candidates.entries()) {
const isPrimary = index === 0
@@ -93,7 +105,9 @@ export async function executeModelRequestWithFallbacks({
request
)
candidateRequest = {
- ...(sameProvider ? request : omit(request, [...PROVIDER_FAMILY_CREDENTIAL_FIELDS])),
+ ...(sameProvider
+ ? request
+ : omit(request, [...PROVIDER_FAMILY_CREDENTIAL_FIELDS, 'bedrockSession'])),
...tuning,
temperature: tuning.temperature === undefined ? undefined : Number(tuning.temperature),
maxTokens: tuning.maxTokens === undefined ? undefined : Number(tuning.maxTokens),
@@ -111,6 +125,15 @@ export async function executeModelRequestWithFallbacks({
}
try {
+ if (credentials && candidateProviderId === providerId) {
+ savedCredentials ??= resolveBlockModelCredentials(
+ ctx,
+ providerId,
+ credentials.inputs,
+ credentials.callerLabel
+ )
+ candidateRequest = { ...candidateRequest, ...(await savedCredentials) }
+ }
const result = await executeBlockProviderRequest({
ctx,
providerId: candidateProviderId,
diff --git a/apps/sim/hooks/use-computer-use-status.ts b/apps/sim/hooks/use-computer-use-status.ts
index a44fd9e69fb..7521354f4f4 100644
--- a/apps/sim/hooks/use-computer-use-status.ts
+++ b/apps/sim/hooks/use-computer-use-status.ts
@@ -1,11 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { ComputerUseStatus } from '@sim/desktop-bridge'
import { getDesktopBridge } from '@/lib/desktop'
-import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
/** Refresh native permissions when the user returns from System Settings and on activity changes. */
export function useComputerUseStatus() {
- const enabled = useFeatureFlag('mothership-computer-use')
const requestVersion = useRef(0)
const activityVersion = useRef(0)
const [status, setStatus] = useState | null>(null)
@@ -21,7 +19,7 @@ export function useComputerUseStatus() {
}, [])
const refresh = useCallback(async () => {
const bridge = getDesktopBridge()?.computerUse
- if (!bridge || !enabled) return
+ if (!bridge) return
const version = ++requestVersion.current
try {
const nextStatus = await bridge.getStatus()
@@ -31,7 +29,7 @@ export function useComputerUseStatus() {
if (version !== requestVersion.current) return
setError(true)
}
- }, [enabled, updateStatus])
+ }, [updateStatus])
useEffect(() => {
const bridge = getDesktopBridge()?.computerUse
if (!bridge) return
@@ -51,19 +49,19 @@ export function useComputerUseStatus() {
const onFocus = () => {
void refresh()
}
- if (enabled) window.addEventListener('focus', onFocus)
+ window.addEventListener('focus', onFocus)
return () => {
requestVersion.current += 1
activityVersion.current += 1
unsubscribe()
window.removeEventListener('focus', onFocus)
}
- }, [enabled, refresh])
+ }, [refresh])
return {
- status: enabled && status ? { ...status, activeAction } : null,
+ status: status ? { ...status, activeAction } : null,
activeAction,
setStatus: updateStatus,
refresh,
- error: enabled && error,
+ error,
}
}
diff --git a/apps/sim/lib/api/contracts/computer-use.ts b/apps/sim/lib/api/contracts/computer-use.ts
index 3c4737e6493..52f2730a46b 100644
--- a/apps/sim/lib/api/contracts/computer-use.ts
+++ b/apps/sim/lib/api/contracts/computer-use.ts
@@ -1,22 +1,7 @@
import { ComputerUseSchema } from '@sim/desktop-bridge/computer-use'
import { z } from 'zod'
import { desktopToolCallIdSchema } from '@/lib/api/contracts/desktop-tool-authorization'
-import { organizationIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives'
-import { type ContractQuery, defineRouteContract } from '@/lib/api/contracts/types'
-
-/** The chat owner whose rollout applies: exactly one of a workspace or an organization. */
-const computerUseAvailabilityQuerySchema = z.union([
- z.object({ workspaceId: workspaceIdSchema }).strict(),
- z.object({ organizationId: organizationIdSchema }).strict(),
-])
-const computerUseAvailabilityResponseSchema = z.object({ enabled: z.boolean() }).strict()
-export const computerUseAvailabilityContract = defineRouteContract({
- method: 'GET',
- path: '/api/desktop/computer/availability',
- query: computerUseAvailabilityQuerySchema,
- response: { mode: 'json', schema: computerUseAvailabilityResponseSchema },
-})
-export type ComputerUseOwner = ContractQuery
+import { defineRouteContract } from '@/lib/api/contracts/types'
const authorizeComputerUseBodySchema = z.object({ toolCallId: desktopToolCallIdSchema }).strict()
const authorizeComputerUseResponseSchema = z
diff --git a/apps/sim/lib/api/contracts/credentials.ts b/apps/sim/lib/api/contracts/credentials.ts
index ca17bf95a48..eeb55e89558 100644
--- a/apps/sim/lib/api/contracts/credentials.ts
+++ b/apps/sim/lib/api/contracts/credentials.ts
@@ -1,6 +1,7 @@
import { z } from 'zod'
import { organizationIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives'
import { defineRouteContract } from '@/lib/api/contracts/types'
+import { MICROSOFT_CLOUD_IDS } from '@/lib/credentials/microsoft-clouds'
import {
ATLASSIAN_PRODUCTS,
getMissingServiceAccountFields,
@@ -61,6 +62,8 @@ export const workspaceCredentialSchema = z.object({
envKey: z.string().nullable(),
envOwnerUserId: z.string().nullable(),
instanceUrl: z.string().url().optional(),
+ /** ISO time the saved token stops working, when its vendor stated one; absent when it never expires or is unknown. */
+ expiresAt: z.string().optional(),
createdBy: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
@@ -574,6 +577,18 @@ export const createWorkspaceCredentialContract = defineRouteContract({
*/
/** The reconnected credential's stored auth mode, so the dialog reopens on it. */
const reconnectAuthTypeSchema = z.enum(WORKLOAD_IDENTITY_AUTH_TYPE_LIST).optional()
+/** A reconnected Microsoft app credential's national cloud, absent for the global cloud. */
+const reconnectCloudSchema = z.enum(MICROSOFT_CLOUD_IDS).optional()
+
+/** A reconnected keyless credential's custom audience; absent when it uses the default. */
+const reconnectAudienceSchema = z.string().optional()
+
+/** A reconnected keyless Google credential's domain-wide delegation, so the dialog keeps it. */
+const reconnectDelegationSchema = z
+ .object({ user: z.string(), services: z.array(z.string()) })
+ .optional()
+
+export type ReconnectDelegation = NonNullable>
export const workloadIdentitySubjectGrantShape = {
issuer: z
@@ -583,7 +598,7 @@ export const workloadIdentitySubjectGrantShape = {
.string()
.optional()
.describe(
- "The `aud` claim the cloud must accept. Absent for Google Cloud, whose audience is each credential's own workload identity pool provider."
+ "The default `aud` claim the cloud must accept; a keyless config's optional `audience` replaces it. Absent for Google Cloud, whose default audience is each credential's own workload identity pool provider."
),
subject: z
.string()
@@ -613,11 +628,20 @@ export const prepareWorkloadIdentityContract = defineRouteContract({
mode: 'json',
schema: z.discriminatedUnion('available', [
/** This deployment has no workload-identity signing key; offer key-based modes only. */
- z.object({ available: z.literal(false), reconnectAuthType: reconnectAuthTypeSchema }),
+ z.object({
+ available: z.literal(false),
+ reconnectAuthType: reconnectAuthTypeSchema,
+ reconnectDelegation: reconnectDelegationSchema,
+ reconnectCloud: reconnectCloudSchema,
+ reconnectAudience: reconnectAudienceSchema,
+ }),
z.object({
available: z.literal(true),
...workloadIdentitySubjectGrantShape,
reconnectAuthType: reconnectAuthTypeSchema,
+ reconnectDelegation: reconnectDelegationSchema,
+ reconnectCloud: reconnectCloudSchema,
+ reconnectAudience: reconnectAudienceSchema,
}),
]),
},
diff --git a/apps/sim/lib/api/contracts/oauth-connections.ts b/apps/sim/lib/api/contracts/oauth-connections.ts
index e166d60ec27..b0555c1ec78 100644
--- a/apps/sim/lib/api/contracts/oauth-connections.ts
+++ b/apps/sim/lib/api/contracts/oauth-connections.ts
@@ -125,7 +125,9 @@ const oauthTokenResponseSchema = z.object({
applicationKey: z.string().optional(),
realmId: z.string().optional(),
quickBooksEnvironment: z.enum(['sandbox', 'production']).optional(),
- authStyle: z.enum(['x-api-token', 'snowflake-workload-identity', 'basic']).optional(),
+ authStyle: z
+ .enum(['x-api-token', 'snowflake-workload-identity', 'snowflake-key-pair', 'basic', 'bearer'])
+ .optional(),
})
/** Token material a resolved credential yields, on the wire and in-process alike. */
diff --git a/apps/sim/lib/api/contracts/tools/aws/athena-batch-get-query-execution.ts b/apps/sim/lib/api/contracts/tools/aws/athena-batch-get-query-execution.ts
index 2d0d0de38dd..f4fc6148880 100644
--- a/apps/sim/lib/api/contracts/tools/aws/athena-batch-get-query-execution.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/athena-batch-get-query-execution.ts
@@ -1,5 +1,8 @@
import { z } from 'zod'
-import { athenaConnectionSchema } from '@/lib/api/contracts/tools/aws/athena-shared'
+import {
+ athenaConnectionSchema,
+ athenaQueryExecutionDetailsShape,
+} from '@/lib/api/contracts/tools/aws/athena-shared'
import type {
ContractBody,
ContractBodyInput,
@@ -35,6 +38,7 @@ const BatchGetQueryExecutionResponseSchema = z.object({
queryQueueTimeInMillis: z.number().nullable(),
totalExecutionTimeInMillis: z.number().nullable(),
outputLocation: z.string().nullable(),
+ ...athenaQueryExecutionDetailsShape,
})
),
unprocessedQueryExecutionIds: z.array(
diff --git a/apps/sim/lib/api/contracts/tools/aws/athena-get-query-execution.ts b/apps/sim/lib/api/contracts/tools/aws/athena-get-query-execution.ts
index 7383ab81d99..7847b402b6a 100644
--- a/apps/sim/lib/api/contracts/tools/aws/athena-get-query-execution.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/athena-get-query-execution.ts
@@ -1,5 +1,8 @@
import { z } from 'zod'
-import { athenaConnectionSchema } from '@/lib/api/contracts/tools/aws/athena-shared'
+import {
+ athenaConnectionSchema,
+ athenaQueryExecutionDetailsShape,
+} from '@/lib/api/contracts/tools/aws/athena-shared'
import type {
ContractBody,
ContractBodyInput,
@@ -30,6 +33,7 @@ const GetQueryExecutionResponseSchema = z.object({
queryQueueTimeInMillis: z.number().nullable(),
totalExecutionTimeInMillis: z.number().nullable(),
outputLocation: z.string().nullable(),
+ ...athenaQueryExecutionDetailsShape,
}),
})
diff --git a/apps/sim/lib/api/contracts/tools/aws/athena-get-query-results.ts b/apps/sim/lib/api/contracts/tools/aws/athena-get-query-results.ts
index 5fa8d7f362b..50eee5c2b84 100644
--- a/apps/sim/lib/api/contracts/tools/aws/athena-get-query-results.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/athena-get-query-results.ts
@@ -23,6 +23,11 @@ const GetQueryResultsResponseSchema = z.object({
z.object({
name: z.string(),
type: z.string(),
+ label: z.string().nullable(),
+ precision: z.number().nullable(),
+ scale: z.number().nullable(),
+ nullable: z.string().nullable(),
+ caseSensitive: z.boolean().nullable(),
})
),
rows: z.array(z.record(z.string(), z.string())),
diff --git a/apps/sim/lib/api/contracts/tools/aws/athena-shared.ts b/apps/sim/lib/api/contracts/tools/aws/athena-shared.ts
index 5bddcc9266b..3db4a7836db 100644
--- a/apps/sim/lib/api/contracts/tools/aws/athena-shared.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/athena-shared.ts
@@ -105,6 +105,25 @@ export const athenaEngineVersionSchema = z.object({
effectiveEngineVersion: z.string().nullable(),
})
+/** Query execution fields beyond the core status and statistics. */
+export const athenaQueryExecutionDetailsShape = {
+ servicePreProcessingTimeInMillis: z.number().nullable(),
+ serviceProcessingTimeInMillis: z.number().nullable(),
+ dataManifestLocation: z.string().nullable(),
+ reusedPreviousResult: z.boolean().nullable(),
+ athenaError: z
+ .object({
+ errorCategory: z.number().nullable(),
+ errorType: z.number().nullable(),
+ retryable: z.boolean().nullable(),
+ errorMessage: z.string().nullable(),
+ })
+ .nullable(),
+ executionParameters: z.array(z.string()).nullable(),
+ substatementType: z.string().nullable(),
+ engineVersion: athenaEngineVersionSchema.nullable(),
+}
+
export const athenaSuccessResponseSchema = z.object({
success: z.literal(true),
output: z.object({ success: z.literal(true) }),
diff --git a/apps/sim/lib/api/contracts/tools/aws/athena-start-query.ts b/apps/sim/lib/api/contracts/tools/aws/athena-start-query.ts
index 3d3d38f69c4..6cdaf7e8169 100644
--- a/apps/sim/lib/api/contracts/tools/aws/athena-start-query.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/athena-start-query.ts
@@ -25,6 +25,10 @@ const StartQuerySchema = athenaConnectionSchema.extend({
(v) => (v === '' || v === undefined || v === null ? undefined : v),
z.coerce.number().int().min(0).max(10080).optional()
),
+ encryptionOption: z.enum(['SSE_S3', 'SSE_KMS', 'CSE_KMS']).optional(),
+ kmsKey: z.string().optional(),
+ expectedBucketOwner: z.string().optional(),
+ clientRequestToken: z.string().min(32).max(128).optional(),
})
const StartQueryResponseSchema = z.object({
diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudformation-create-change-set.ts b/apps/sim/lib/api/contracts/tools/aws/cloudformation-create-change-set.ts
index 35ab5c3f3b2..fb3a1afc9ef 100644
--- a/apps/sim/lib/api/contracts/tools/aws/cloudformation-create-change-set.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/cloudformation-create-change-set.ts
@@ -50,6 +50,18 @@ const CreateChangeSetSchema = z
),
changeSetType: z.enum(['CREATE', 'UPDATE', 'IMPORT']).optional(),
description: z.string().max(1024, 'Description must be at most 1024 characters').optional(),
+ roleArn: z.string().min(1).optional(),
+ tags: z
+ .array(
+ z.object({
+ key: z
+ .string()
+ .min(1, 'Tag key is required')
+ .max(128, 'Tag key must be at most 128 characters'),
+ value: z.string().max(256, 'Tag value must be at most 256 characters'),
+ })
+ )
+ .optional(),
})
.superRefine((data, ctx) => {
if (!data.templateBody && !data.usePreviousTemplate) {
diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudformation-create-stack.ts b/apps/sim/lib/api/contracts/tools/aws/cloudformation-create-stack.ts
index 8b56f1ee24f..abe49a442c1 100644
--- a/apps/sim/lib/api/contracts/tools/aws/cloudformation-create-stack.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/cloudformation-create-stack.ts
@@ -58,6 +58,8 @@ const CreateStackSchema = z.object({
.optional(),
onFailure: z.enum(['ROLLBACK', 'DELETE', 'DO_NOTHING']).optional(),
timeoutInMinutes: z.coerce.number().int().positive().optional(),
+ roleArn: z.string().min(1).optional(),
+ enableTerminationProtection: z.boolean().optional(),
})
const CreateStackResponseSchema = z.object({
diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudformation-describe-change-set.ts b/apps/sim/lib/api/contracts/tools/aws/cloudformation-describe-change-set.ts
index d3353c05d4a..ba9e123da83 100644
--- a/apps/sim/lib/api/contracts/tools/aws/cloudformation-describe-change-set.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/cloudformation-describe-change-set.ts
@@ -18,6 +18,7 @@ const DescribeChangeSetSchema = z.object({
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
changeSetName: z.string().min(1, 'Change set name is required'),
stackName: z.string().optional(),
+ nextToken: z.string().optional(),
})
const DescribeChangeSetResponseSchema = z.object({
@@ -40,8 +41,38 @@ const DescribeChangeSetResponseSchema = z.object({
physicalResourceId: z.string().optional(),
resourceType: z.string().optional(),
replacement: z.string().optional(),
+ type: z.string().optional(),
+ policyAction: z.string().optional(),
+ scope: z.array(z.string()),
+ changeSetId: z.string().optional(),
+ details: z.array(
+ z.object({
+ attribute: z.string().optional(),
+ name: z.string().optional(),
+ requiresRecreation: z.string().optional(),
+ evaluation: z.string().optional(),
+ changeSource: z.string().optional(),
+ causingEntity: z.string().optional(),
+ })
+ ),
})
),
+ parameters: z.array(
+ z.object({
+ parameterKey: z.string(),
+ parameterValue: z.string().optional(),
+ usePreviousValue: z.boolean().optional(),
+ resolvedValue: z.string().optional(),
+ })
+ ),
+ tags: z.array(z.object({ key: z.string(), value: z.string() })),
+ notificationArns: z.array(z.string()),
+ includeNestedStacks: z.boolean().optional(),
+ parentChangeSetId: z.string().optional(),
+ rootChangeSetId: z.string().optional(),
+ onStackFailure: z.string().optional(),
+ importExistingResources: z.boolean().optional(),
+ nextToken: z.string().nullable(),
}),
})
diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudformation-describe-stack-events.ts b/apps/sim/lib/api/contracts/tools/aws/cloudformation-describe-stack-events.ts
index a052375a958..17cc8f455c6 100644
--- a/apps/sim/lib/api/contracts/tools/aws/cloudformation-describe-stack-events.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/cloudformation-describe-stack-events.ts
@@ -31,6 +31,15 @@ const DescribeStackEventsResponseSchema = z.object({
resourceStatus: z.string().optional(),
resourceStatusReason: z.string().optional(),
timestamp: z.number().optional(),
+ operationId: z.string().optional(),
+ resourceProperties: z.string().optional(),
+ clientRequestToken: z.string().optional(),
+ detailedStatus: z.string().optional(),
+ hookType: z.string().optional(),
+ hookStatus: z.string().optional(),
+ hookStatusReason: z.string().optional(),
+ hookInvocationPoint: z.string().optional(),
+ hookFailureMode: z.string().optional(),
})
),
}),
diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudformation-describe-stacks.ts b/apps/sim/lib/api/contracts/tools/aws/cloudformation-describe-stacks.ts
index 87ecea916f3..eae930abd46 100644
--- a/apps/sim/lib/api/contracts/tools/aws/cloudformation-describe-stacks.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/cloudformation-describe-stacks.ts
@@ -37,6 +37,7 @@ const DescribeStacksResponseSchema = z.object({
outputKey: z.string(),
outputValue: z.string(),
description: z.string().optional(),
+ exportName: z.string().optional(),
})
),
tags: z.array(
@@ -45,6 +46,26 @@ const DescribeStacksResponseSchema = z.object({
value: z.string(),
})
),
+ parameters: z.array(
+ z.object({
+ parameterKey: z.string(),
+ parameterValue: z.string().optional(),
+ usePreviousValue: z.boolean().optional(),
+ resolvedValue: z.string().optional(),
+ })
+ ),
+ capabilities: z.array(z.string()),
+ changeSetId: z.string().optional(),
+ deletionTime: z.number().optional(),
+ disableRollback: z.boolean().optional(),
+ notificationArns: z.array(z.string()),
+ timeoutInMinutes: z.number().optional(),
+ roleArn: z.string().optional(),
+ parentId: z.string().optional(),
+ rootId: z.string().optional(),
+ detailedStatus: z.string().optional(),
+ deletionMode: z.string().optional(),
+ retainExceptOnCreate: z.boolean().optional(),
})
),
}),
diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudformation-get-template-summary.ts b/apps/sim/lib/api/contracts/tools/aws/cloudformation-get-template-summary.ts
index ccf0cf7c067..f2a3c60e544 100644
--- a/apps/sim/lib/api/contracts/tools/aws/cloudformation-get-template-summary.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/cloudformation-get-template-summary.ts
@@ -41,6 +41,7 @@ const GetTemplateSummaryResponseSchema = z.object({
parameterType: z.string().optional(),
noEcho: z.boolean().optional(),
description: z.string().optional(),
+ allowedValues: z.array(z.string()),
})
),
capabilities: z.array(z.string()),
diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudformation-update-stack.ts b/apps/sim/lib/api/contracts/tools/aws/cloudformation-update-stack.ts
index 9c9e8506570..a735940d32c 100644
--- a/apps/sim/lib/api/contracts/tools/aws/cloudformation-update-stack.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/cloudformation-update-stack.ts
@@ -58,6 +58,7 @@ const UpdateStackSchema = z
})
)
.optional(),
+ roleArn: z.string().min(1).optional(),
})
.superRefine((data, ctx) => {
if (!data.templateBody && !data.usePreviousTemplate) {
diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudwatch-describe-alarm-history.ts b/apps/sim/lib/api/contracts/tools/aws/cloudwatch-describe-alarm-history.ts
index 22e241e1ccc..86a51cba138 100644
--- a/apps/sim/lib/api/contracts/tools/aws/cloudwatch-describe-alarm-history.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/cloudwatch-describe-alarm-history.ts
@@ -51,6 +51,8 @@ const DescribeAlarmHistoryResponseSchema = z.object({
timestamp: z.number().optional(),
historyItemType: z.string().optional(),
historySummary: z.string().optional(),
+ historyData: z.string().optional(),
+ alarmContributorId: z.string().optional(),
})
),
}),
diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudwatch-describe-alarms.ts b/apps/sim/lib/api/contracts/tools/aws/cloudwatch-describe-alarms.ts
index 45322bcf4cf..6ccf96aef7d 100644
--- a/apps/sim/lib/api/contracts/tools/aws/cloudwatch-describe-alarms.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/cloudwatch-describe-alarms.ts
@@ -34,6 +34,7 @@ const DescribeAlarmsSchema = z.object({
.max(100, 'limit must be at most 100 (CloudWatch DescribeAlarms limit)')
.optional()
),
+ nextToken: z.string().optional(),
})
const DescribeAlarmsResponseSchema = z.object({
@@ -51,8 +52,49 @@ const DescribeAlarmsResponseSchema = z.object({
threshold: z.number().optional(),
evaluationPeriods: z.number().optional(),
stateUpdatedTimestamp: z.number().optional(),
+ alarmType: z.string(),
+ alarmDescription: z.string().optional(),
+ actionsEnabled: z.boolean().optional(),
+ alarmActions: z.array(z.string()),
+ okActions: z.array(z.string()),
+ insufficientDataActions: z.array(z.string()),
+ stateReasonData: z.string().optional(),
+ stateTransitionedTimestamp: z.number().optional(),
+ alarmConfigurationUpdatedTimestamp: z.number().optional(),
+ statistic: z.string().optional(),
+ extendedStatistic: z.string().optional(),
+ dimensions: z.array(z.object({ name: z.string(), value: z.string() })).optional(),
+ period: z.number().optional(),
+ unit: z.string().optional(),
+ datapointsToAlarm: z.number().optional(),
+ treatMissingData: z.string().optional(),
+ thresholdMetricId: z.string().optional(),
+ metrics: z
+ .array(
+ z.object({
+ id: z.string(),
+ expression: z.string().optional(),
+ label: z.string().optional(),
+ returnData: z.boolean().optional(),
+ period: z.number().optional(),
+ accountId: z.string().optional(),
+ metricStat: z
+ .object({
+ namespace: z.string().optional(),
+ metricName: z.string().optional(),
+ dimensions: z.array(z.object({ name: z.string(), value: z.string() })),
+ period: z.number().optional(),
+ stat: z.string().optional(),
+ unit: z.string().optional(),
+ })
+ .optional(),
+ })
+ )
+ .optional(),
+ alarmRule: z.string().optional(),
})
),
+ nextToken: z.string().nullable(),
}),
})
diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudwatch-filter-log-events.ts b/apps/sim/lib/api/contracts/tools/aws/cloudwatch-filter-log-events.ts
index 3233f6202b6..79d0fbadf69 100644
--- a/apps/sim/lib/api/contracts/tools/aws/cloudwatch-filter-log-events.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/cloudwatch-filter-log-events.ts
@@ -26,6 +26,7 @@ const FilterLogEventsSchema = z.object({
(v) => (v === '' || v === undefined || v === null ? undefined : v),
z.coerce.number().int().positive().optional()
),
+ nextToken: z.string().optional(),
})
const FilterLogEventsResponseSchema = z.object({
@@ -37,8 +38,10 @@ const FilterLogEventsResponseSchema = z.object({
timestamp: z.number().optional(),
message: z.string().optional(),
ingestionTime: z.number().optional(),
+ eventId: z.string().optional(),
})
),
+ nextToken: z.string().nullable(),
}),
})
diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudwatch-get-log-events.ts b/apps/sim/lib/api/contracts/tools/aws/cloudwatch-get-log-events.ts
index 3bfff17b8c3..00a8ecb1bbd 100644
--- a/apps/sim/lib/api/contracts/tools/aws/cloudwatch-get-log-events.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/cloudwatch-get-log-events.ts
@@ -29,6 +29,7 @@ const GetLogEventsSchema = z.object({
.max(10000, 'limit must be at most 10000 (CloudWatch Logs GetLogEvents limit)')
.optional()
),
+ nextToken: z.string().optional(),
})
const GetLogEventsResponseSchema = z.object({
@@ -41,6 +42,8 @@ const GetLogEventsResponseSchema = z.object({
ingestionTime: z.number().optional(),
})
),
+ nextForwardToken: z.string().nullable(),
+ nextBackwardToken: z.string().nullable(),
}),
})
diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudwatch-get-metric-statistics.ts b/apps/sim/lib/api/contracts/tools/aws/cloudwatch-get-metric-statistics.ts
index 80278dca99c..f36bc514780 100644
--- a/apps/sim/lib/api/contracts/tools/aws/cloudwatch-get-metric-statistics.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/cloudwatch-get-metric-statistics.ts
@@ -7,26 +7,33 @@ import type {
import { defineRouteContract } from '@/lib/api/contracts/types'
import { validateAwsRegion } from '@/lib/core/security/input-validation'
-const GetMetricStatisticsSchema = z.object({
- region: z
- .string()
- .min(1, 'AWS region is required')
- .refine((v) => validateAwsRegion(v).isValid, {
- message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)',
- }),
- accessKeyId: z.string().min(1, 'AWS access key ID is required'),
- secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
- namespace: z.string().min(1, 'Namespace is required'),
- metricName: z.string().min(1, 'Metric name is required'),
- startTime: z.coerce.number().int(),
- endTime: z.coerce.number().int(),
- period: z.coerce.number().int().min(1),
- statistics: z
- .array(z.enum(['Average', 'Sum', 'Minimum', 'Maximum', 'SampleCount']))
- .min(1, 'At least one statistic is required')
- .max(5, 'At most 5 statistics are allowed per GetMetricStatistics call'),
- dimensions: z.string().optional(),
-})
+const GetMetricStatisticsSchema = z
+ .object({
+ region: z
+ .string()
+ .min(1, 'AWS region is required')
+ .refine((v) => validateAwsRegion(v).isValid, {
+ message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)',
+ }),
+ accessKeyId: z.string().min(1, 'AWS access key ID is required'),
+ secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
+ namespace: z.string().min(1, 'Namespace is required'),
+ metricName: z.string().min(1, 'Metric name is required'),
+ startTime: z.coerce.number().int(),
+ endTime: z.coerce.number().int(),
+ period: z.coerce.number().int().min(1),
+ statistics: z
+ .array(z.enum(['Average', 'Sum', 'Minimum', 'Maximum', 'SampleCount']))
+ .max(5, 'At most 5 statistics are allowed per GetMetricStatistics call')
+ .optional(),
+ dimensions: z.string().optional(),
+ extendedStatistics: z.array(z.string().min(1)).max(10).optional(),
+ unit: z.string().optional(),
+ })
+ .refine((body) => Boolean(body.statistics?.length || body.extendedStatistics?.length), {
+ message: 'Provide statistics or extendedStatistics',
+ path: ['statistics'],
+ })
const GetMetricStatisticsResponseSchema = z.object({
success: z.literal(true),
@@ -41,6 +48,7 @@ const GetMetricStatisticsResponseSchema = z.object({
maximum: z.number().optional(),
sampleCount: z.number().optional(),
unit: z.string().optional(),
+ extendedStatistics: z.record(z.string(), z.number()).optional(),
})
),
}),
diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudwatch-query-logs.ts b/apps/sim/lib/api/contracts/tools/aws/cloudwatch-query-logs.ts
index 4de4c84d4a2..58ffa561022 100644
--- a/apps/sim/lib/api/contracts/tools/aws/cloudwatch-query-logs.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/cloudwatch-query-logs.ts
@@ -36,6 +36,7 @@ const QueryLogsResponseSchema = z.object({
recordsScanned: z.number(),
}),
status: z.string(),
+ queryId: z.string(),
}),
})
diff --git a/apps/sim/lib/api/contracts/tools/aws/dynamodb-delete.ts b/apps/sim/lib/api/contracts/tools/aws/dynamodb-delete.ts
index 2333b0fcf92..b0c57015cd7 100644
--- a/apps/sim/lib/api/contracts/tools/aws/dynamodb-delete.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/dynamodb-delete.ts
@@ -23,10 +23,13 @@ const DeleteSchema = z.object({
conditionExpression: z.string().optional(),
expressionAttributeNames: z.record(z.string(), z.string()).optional(),
expressionAttributeValues: z.record(z.string(), z.unknown()).optional(),
+ returnValues: z.enum(['NONE', 'ALL_OLD']).optional(),
})
const DeleteResponseSchema = z.object({
message: z.string(),
+ // untyped-response: DynamoDB DeleteItem ALL_OLD Attributes is an arbitrary user attribute-value record
+ attributes: z.record(z.string(), z.unknown()).nullable().optional(),
})
export const awsDynamodbDeleteContract = defineRouteContract({
diff --git a/apps/sim/lib/api/contracts/tools/aws/dynamodb-get.ts b/apps/sim/lib/api/contracts/tools/aws/dynamodb-get.ts
index d487ce13050..b93146169fe 100644
--- a/apps/sim/lib/api/contracts/tools/aws/dynamodb-get.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/dynamodb-get.ts
@@ -27,6 +27,8 @@ const GetSchema = z.object({
if (val === true || val === 'true') return true
return undefined
}),
+ projectionExpression: z.string().optional(),
+ expressionAttributeNames: z.record(z.string(), z.string()).optional(),
})
const GetResponseSchema = z.object({
diff --git a/apps/sim/lib/api/contracts/tools/aws/dynamodb-introspect.ts b/apps/sim/lib/api/contracts/tools/aws/dynamodb-introspect.ts
index 2c0d1afdcd8..60532b3fe77 100644
--- a/apps/sim/lib/api/contracts/tools/aws/dynamodb-introspect.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/dynamodb-introspect.ts
@@ -46,6 +46,12 @@ const TableDetailsSchema = z.object({
itemCount: z.number(),
tableSizeBytes: z.number(),
billingMode: z.string(),
+ tableArn: z.string().nullable(),
+ creationDateTime: z.string().nullable(),
+ deletionProtectionEnabled: z.boolean().nullable(),
+ streamEnabled: z.boolean().nullable(),
+ streamViewType: z.string().nullable(),
+ latestStreamArn: z.string().nullable(),
})
const IntrospectResponseSchema = z.object({
diff --git a/apps/sim/lib/api/contracts/tools/aws/dynamodb-put.ts b/apps/sim/lib/api/contracts/tools/aws/dynamodb-put.ts
index 5fb4a57bb0e..bf93f0497c8 100644
--- a/apps/sim/lib/api/contracts/tools/aws/dynamodb-put.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/dynamodb-put.ts
@@ -23,12 +23,15 @@ const PutSchema = z.object({
conditionExpression: z.string().optional(),
expressionAttributeNames: z.record(z.string(), z.string()).optional(),
expressionAttributeValues: z.record(z.string(), z.unknown()).optional(),
+ returnValues: z.enum(['NONE', 'ALL_OLD']).optional(),
})
const PutResponseSchema = z.object({
message: z.string(),
// untyped-response: DynamoDB Item is an arbitrary user attribute-value record
item: z.record(z.string(), z.unknown()),
+ // untyped-response: DynamoDB PutItem ALL_OLD Attributes is an arbitrary user attribute-value record
+ attributes: z.record(z.string(), z.unknown()).nullable().optional(),
})
export const awsDynamodbPutContract = defineRouteContract({
diff --git a/apps/sim/lib/api/contracts/tools/aws/dynamodb-query.ts b/apps/sim/lib/api/contracts/tools/aws/dynamodb-query.ts
index e68f6298d4c..2b5f4515503 100644
--- a/apps/sim/lib/api/contracts/tools/aws/dynamodb-query.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/dynamodb-query.ts
@@ -24,7 +24,22 @@ const QuerySchema = z.object({
indexName: z.string().optional(),
limit: z.number().positive().optional(),
exclusiveStartKey: z.record(z.string(), z.unknown()).optional(),
- scanIndexForward: z.boolean().optional(),
+ scanIndexForward: z
+ .union([z.boolean(), z.enum(['true', 'false'])])
+ .optional()
+ .transform((val) => {
+ if (val === false || val === 'false') return false
+ if (val === true || val === 'true') return true
+ return undefined
+ }),
+ projectionExpression: z.string().optional(),
+ consistentRead: z
+ .union([z.boolean(), z.enum(['true', 'false'])])
+ .optional()
+ .transform((val) => {
+ if (val === true || val === 'true') return true
+ return undefined
+ }),
})
const QueryResponseSchema = z.object({
@@ -32,6 +47,7 @@ const QueryResponseSchema = z.object({
// untyped-response: DynamoDB Items are arbitrary user attribute-value records
items: z.array(z.record(z.string(), z.unknown())),
count: z.number(),
+ scannedCount: z.number().nullable(),
// untyped-response: DynamoDB LastEvaluatedKey mirrors the table's primary key shape
lastEvaluatedKey: z.record(z.string(), z.unknown()).optional(),
})
diff --git a/apps/sim/lib/api/contracts/tools/aws/dynamodb-scan.ts b/apps/sim/lib/api/contracts/tools/aws/dynamodb-scan.ts
index 10bc7231599..ee57f699c5d 100644
--- a/apps/sim/lib/api/contracts/tools/aws/dynamodb-scan.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/dynamodb-scan.ts
@@ -7,29 +7,60 @@ import type {
import { defineRouteContract } from '@/lib/api/contracts/types'
import { validateAwsRegion } from '@/lib/core/security/input-validation'
-const ScanSchema = z.object({
- region: z
- .string()
- .min(1, 'AWS region is required')
- .refine((v) => validateAwsRegion(v).isValid, {
- message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)',
- }),
- accessKeyId: z.string().min(1, 'AWS access key ID is required'),
- secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
- tableName: z.string().min(1, 'Table name is required'),
- filterExpression: z.string().optional(),
- projectionExpression: z.string().optional(),
- expressionAttributeNames: z.record(z.string(), z.string()).optional(),
- expressionAttributeValues: z.record(z.string(), z.unknown()).optional(),
- limit: z.number().positive().optional(),
- exclusiveStartKey: z.record(z.string(), z.unknown()).optional(),
-})
+const ScanSchema = z
+ .object({
+ region: z
+ .string()
+ .min(1, 'AWS region is required')
+ .refine((v) => validateAwsRegion(v).isValid, {
+ message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)',
+ }),
+ accessKeyId: z.string().min(1, 'AWS access key ID is required'),
+ secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
+ tableName: z.string().min(1, 'Table name is required'),
+ filterExpression: z.string().optional(),
+ projectionExpression: z.string().optional(),
+ expressionAttributeNames: z.record(z.string(), z.string()).optional(),
+ expressionAttributeValues: z.record(z.string(), z.unknown()).optional(),
+ limit: z.number().positive().optional(),
+ exclusiveStartKey: z.record(z.string(), z.unknown()).optional(),
+ indexName: z.string().optional(),
+ consistentRead: z
+ .union([z.boolean(), z.enum(['true', 'false'])])
+ .optional()
+ .transform((val) => {
+ if (val === true || val === 'true') return true
+ return undefined
+ }),
+ segment: z.number().int().min(0).max(999999).optional(),
+ totalSegments: z.number().int().min(1).max(1000000).optional(),
+ })
+ .superRefine((value, ctx) => {
+ if ((value.segment === undefined) !== (value.totalSegments === undefined)) {
+ ctx.addIssue({
+ code: 'custom',
+ path: ['segment'],
+ message: 'Segment and Total Segments must be set together for a parallel scan',
+ })
+ } else if (
+ value.segment !== undefined &&
+ value.totalSegments !== undefined &&
+ value.segment >= value.totalSegments
+ ) {
+ ctx.addIssue({
+ code: 'custom',
+ path: ['segment'],
+ message: 'Segment must be less than Total Segments',
+ })
+ }
+ })
const ScanResponseSchema = z.object({
message: z.string(),
// untyped-response: DynamoDB Items are arbitrary user attribute-value records
items: z.array(z.record(z.string(), z.unknown())),
count: z.number(),
+ scannedCount: z.number().nullable(),
// untyped-response: DynamoDB LastEvaluatedKey mirrors the table's primary key shape
lastEvaluatedKey: z.record(z.string(), z.unknown()).optional(),
})
diff --git a/apps/sim/lib/api/contracts/tools/aws/dynamodb-update.ts b/apps/sim/lib/api/contracts/tools/aws/dynamodb-update.ts
index 0b5e2c81ed7..cab57218d3c 100644
--- a/apps/sim/lib/api/contracts/tools/aws/dynamodb-update.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/dynamodb-update.ts
@@ -24,6 +24,7 @@ const UpdateSchema = z.object({
expressionAttributeNames: z.record(z.string(), z.string()).optional(),
expressionAttributeValues: z.record(z.string(), z.unknown()).optional(),
conditionExpression: z.string().optional(),
+ returnValues: z.enum(['NONE', 'ALL_OLD', 'UPDATED_OLD', 'ALL_NEW', 'UPDATED_NEW']).optional(),
})
const UpdateResponseSchema = z.object({
diff --git a/apps/sim/lib/api/contracts/tools/aws/iam-create-role.ts b/apps/sim/lib/api/contracts/tools/aws/iam-create-role.ts
index ab70e1d0df4..432b43547a3 100644
--- a/apps/sim/lib/api/contracts/tools/aws/iam-create-role.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/iam-create-role.ts
@@ -3,8 +3,10 @@ import {
iamAssumeRolePolicyDocumentSchema,
iamConnectionShape,
iamCreatePathSchema,
+ iamPolicyArnSchema,
iamRoleDescriptionSchema,
iamRoleNameSchema,
+ iamTagsJsonSchema,
} from '@/lib/api/contracts/tools/aws/iam-shared'
import type {
ContractBody,
@@ -26,6 +28,8 @@ const Schema = z.object({
.max(43200, 'Max session duration cannot exceed 43200 seconds (12 hours)')
.optional()
.nullable(),
+ permissionsBoundary: iamPolicyArnSchema.optional().nullable(),
+ tags: iamTagsJsonSchema.optional().nullable(),
})
const CreateRoleResponseSchema = z.object({
@@ -35,6 +39,13 @@ const CreateRoleResponseSchema = z.object({
arn: z.string(),
path: z.string(),
createDate: z.string().nullable(),
+ permissionsBoundaryArn: z.string().nullable(),
+ tags: z.array(
+ z.object({
+ key: z.string(),
+ value: z.string(),
+ })
+ ),
})
export const awsIamCreateRoleContract = defineRouteContract({
diff --git a/apps/sim/lib/api/contracts/tools/aws/iam-create-user.ts b/apps/sim/lib/api/contracts/tools/aws/iam-create-user.ts
index 4e252e679b0..fc91d4255e7 100644
--- a/apps/sim/lib/api/contracts/tools/aws/iam-create-user.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/iam-create-user.ts
@@ -2,6 +2,8 @@ import { z } from 'zod'
import {
iamConnectionShape,
iamCreatePathSchema,
+ iamPolicyArnSchema,
+ iamTagsJsonSchema,
iamUserName64Schema,
} from '@/lib/api/contracts/tools/aws/iam-shared'
import type {
@@ -15,6 +17,8 @@ const Schema = z.object({
...iamConnectionShape,
userName: iamUserName64Schema,
path: iamCreatePathSchema.optional().nullable(),
+ permissionsBoundary: iamPolicyArnSchema.optional().nullable(),
+ tags: iamTagsJsonSchema.optional().nullable(),
})
const CreateUserResponseSchema = z.object({
@@ -24,6 +28,13 @@ const CreateUserResponseSchema = z.object({
arn: z.string(),
path: z.string(),
createDate: z.string().nullable(),
+ permissionsBoundaryArn: z.string().nullable(),
+ tags: z.array(
+ z.object({
+ key: z.string(),
+ value: z.string(),
+ })
+ ),
})
export const awsIamCreateUserContract = defineRouteContract({
diff --git a/apps/sim/lib/api/contracts/tools/aws/iam-get-role.ts b/apps/sim/lib/api/contracts/tools/aws/iam-get-role.ts
index 8ee1d93e5c7..dfebcef7ff8 100644
--- a/apps/sim/lib/api/contracts/tools/aws/iam-get-role.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/iam-get-role.ts
@@ -23,6 +23,13 @@ const GetRoleResponseSchema = z.object({
assumeRolePolicyDocument: z.string().nullable(),
roleLastUsedDate: z.string().nullable(),
roleLastUsedRegion: z.string().nullable(),
+ permissionsBoundaryArn: z.string().nullable(),
+ tags: z.array(
+ z.object({
+ key: z.string(),
+ value: z.string(),
+ })
+ ),
})
export const awsIamGetRoleContract = defineRouteContract({
diff --git a/apps/sim/lib/api/contracts/tools/aws/iam-list-policies.ts b/apps/sim/lib/api/contracts/tools/aws/iam-list-policies.ts
index 42d0f5a7bba..6d90944c4a2 100644
--- a/apps/sim/lib/api/contracts/tools/aws/iam-list-policies.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/iam-list-policies.ts
@@ -21,6 +21,7 @@ const Schema = z.object({
pathPrefix: iamPolicyPathPrefixSchema.optional().nullable(),
maxItems: iamMaxItemsSchema.optional().nullable(),
marker: iamMarkerSchema.optional().nullable(),
+ policyUsageFilter: z.enum(['PermissionsPolicy', 'PermissionsBoundary']).optional().nullable(),
})
/**
diff --git a/apps/sim/lib/api/contracts/tools/aws/iam-shared.ts b/apps/sim/lib/api/contracts/tools/aws/iam-shared.ts
index 26559165348..a5036b95a06 100644
--- a/apps/sim/lib/api/contracts/tools/aws/iam-shared.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/iam-shared.ts
@@ -1,3 +1,4 @@
+import { isRecordLike } from '@sim/utils/object'
import { z } from 'zod'
import { validateAwsRegion } from '@/lib/core/security/input-validation'
@@ -266,3 +267,22 @@ export const iamPaginationResponseShape = {
marker: z.string().nullable(),
count: z.number(),
}
+
+/** IAM tags as a JSON object string: at most 50 pairs with string, number, or boolean values. */
+export const iamTagsJsonSchema = z.string().refine(
+ (value) => {
+ try {
+ const parsed: unknown = JSON.parse(value)
+ return (
+ isRecordLike(parsed) &&
+ Object.keys(parsed).length <= 50 &&
+ Object.values(parsed).every((tagValue) =>
+ ['string', 'number', 'boolean'].includes(typeof tagValue)
+ )
+ )
+ } catch {
+ return false
+ }
+ },
+ { message: 'Tags must be a JSON object of at most 50 key/value pairs with scalar values' }
+)
diff --git a/apps/sim/lib/api/contracts/tools/aws/identity-center-describe-user.ts b/apps/sim/lib/api/contracts/tools/aws/identity-center-describe-user.ts
index 66915a7dbc5..cd435236ded 100644
--- a/apps/sim/lib/api/contracts/tools/aws/identity-center-describe-user.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/identity-center-describe-user.ts
@@ -24,6 +24,8 @@ const ResponseSchema = z.object({
email: z.string().nullable(),
userStatus: z.string().nullable(),
title: z.string().nullable(),
+ givenName: z.string().nullable(),
+ familyName: z.string().nullable(),
externalIds: z.array(z.object({ issuer: z.string(), id: z.string() })),
})
diff --git a/apps/sim/lib/api/contracts/tools/aws/identity-center-get-group.ts b/apps/sim/lib/api/contracts/tools/aws/identity-center-get-group.ts
index a4caa36bc6d..6dbe279c61d 100644
--- a/apps/sim/lib/api/contracts/tools/aws/identity-center-get-group.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/identity-center-get-group.ts
@@ -20,6 +20,7 @@ const ResponseSchema = z.object({
groupId: z.string(),
displayName: z.string().nullable(),
description: z.string().nullable(),
+ externalIds: z.array(z.object({ issuer: z.string(), id: z.string() })),
})
export const awsIdentityCenterGetGroupContract = defineRouteContract({
diff --git a/apps/sim/lib/api/contracts/tools/aws/identity-center-get-user.ts b/apps/sim/lib/api/contracts/tools/aws/identity-center-get-user.ts
index 8ed5810ef28..7d680dbe015 100644
--- a/apps/sim/lib/api/contracts/tools/aws/identity-center-get-user.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/identity-center-get-user.ts
@@ -21,6 +21,11 @@ const ResponseSchema = z.object({
userName: z.string(),
displayName: z.string().nullable(),
email: z.string().nullable(),
+ givenName: z.string().nullable(),
+ familyName: z.string().nullable(),
+ userStatus: z.string().nullable(),
+ title: z.string().nullable(),
+ externalIds: z.array(z.object({ issuer: z.string(), id: z.string() })),
})
export const awsIdentityCenterGetUserContract = defineRouteContract({
diff --git a/apps/sim/lib/api/contracts/tools/aws/identity-center-list-account-assignments.ts b/apps/sim/lib/api/contracts/tools/aws/identity-center-list-account-assignments.ts
index a1c19b2cf47..2b14ab09b7d 100644
--- a/apps/sim/lib/api/contracts/tools/aws/identity-center-list-account-assignments.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/identity-center-list-account-assignments.ts
@@ -1,5 +1,6 @@
import { z } from 'zod'
import {
+ identityCenterAccountIdSchema,
identityCenterConnectionShape,
identityCenterInstanceArnSchema,
identityCenterMaxResultsSchema,
@@ -21,6 +22,7 @@ const Schema = z.object({
principalType: identityCenterPrincipalTypeSchema,
maxResults: identityCenterMaxResultsSchema.optional(),
nextToken: identityCenterNextTokenSchema.optional(),
+ accountId: identityCenterAccountIdSchema.optional(),
})
const ResponseSchema = z.object({
diff --git a/apps/sim/lib/api/contracts/tools/aws/lambda-get-function.ts b/apps/sim/lib/api/contracts/tools/aws/lambda-get-function.ts
index ed5fc8369fd..48943d87b4f 100644
--- a/apps/sim/lib/api/contracts/tools/aws/lambda-get-function.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/lambda-get-function.ts
@@ -37,6 +37,9 @@ const GetFunctionResponseSchema = z.object({
imageUri: z.string().nullable(),
resolvedImageUri: z.string().nullable(),
sourceKmsKeyArn: z.string().nullable(),
+ error: z
+ .object({ errorCode: z.string().nullable(), message: z.string().nullable() })
+ .nullable(),
})
.nullable(),
tags: z.record(z.string(), z.string()),
diff --git a/apps/sim/lib/api/contracts/tools/aws/lambda-invoke.ts b/apps/sim/lib/api/contracts/tools/aws/lambda-invoke.ts
index e711f7a8c99..7bb519a4e82 100644
--- a/apps/sim/lib/api/contracts/tools/aws/lambda-invoke.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/lambda-invoke.ts
@@ -25,6 +25,7 @@ const InvokeSchema = z.object({
.min(1, 'qualifier cannot be empty')
.max(128, 'qualifier cannot exceed 128 characters')
.optional(),
+ tenantId: z.string().min(1).max(256, 'tenantId cannot exceed 256 characters').optional(),
})
const InvokeResponseSchema = z.object({
diff --git a/apps/sim/lib/api/contracts/tools/aws/lambda-shared.ts b/apps/sim/lib/api/contracts/tools/aws/lambda-shared.ts
index 52063da1188..226a4bc9ad9 100644
--- a/apps/sim/lib/api/contracts/tools/aws/lambda-shared.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/lambda-shared.ts
@@ -181,6 +181,7 @@ export const lambdaFunctionConfigurationSchema = z.object({
.object({
retentionPeriodInDays: z.number().nullable(),
executionTimeout: z.number().nullable(),
+ kmsKeyArn: z.string().nullable(),
})
.nullable(),
tenancyConfig: z.object({ tenantIsolationMode: z.string().nullable() }).nullable(),
diff --git a/apps/sim/lib/api/contracts/tools/aws/lambda-update-function-configuration.ts b/apps/sim/lib/api/contracts/tools/aws/lambda-update-function-configuration.ts
index 5bb9715627e..2df35a3f8e2 100644
--- a/apps/sim/lib/api/contracts/tools/aws/lambda-update-function-configuration.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/lambda-update-function-configuration.ts
@@ -34,6 +34,8 @@ const UpdateFunctionConfigurationSchema = z
snapStartApplyOn: z.enum(['PublishedVersions', 'None']).optional(),
logFormat: z.enum(['JSON', 'Text']).optional(),
logGroup: z.string().optional(),
+ applicationLogLevel: z.enum(['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR', 'FATAL']).optional(),
+ systemLogLevel: z.enum(['DEBUG', 'INFO', 'WARN']).optional(),
revisionId: z.string().optional(),
})
.superRefine((value, ctx) => {
diff --git a/apps/sim/lib/api/contracts/tools/aws/s3-list-objects.ts b/apps/sim/lib/api/contracts/tools/aws/s3-list-objects.ts
index b0779cb251a..4ecf45b111c 100644
--- a/apps/sim/lib/api/contracts/tools/aws/s3-list-objects.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/s3-list-objects.ts
@@ -14,6 +14,8 @@ const S3ListObjectsSchema = z.object({
prefix: z.string().optional().nullable(),
maxKeys: z.number().optional().nullable(),
continuationToken: z.string().optional().nullable(),
+ delimiter: z.string().optional().nullable(),
+ startAfter: z.string().optional().nullable(),
})
const S3ListObjectsResponseSchema = z.object({
@@ -25,8 +27,10 @@ const S3ListObjectsResponseSchema = z.object({
size: z.number(),
lastModified: z.string(),
etag: z.string(),
+ storageClass: z.string().nullable(),
})
),
+ commonPrefixes: z.array(z.string()),
isTruncated: z.boolean().optional(),
nextContinuationToken: z.string().optional(),
keyCount: z.number().optional(),
diff --git a/apps/sim/lib/api/contracts/tools/aws/s3-put-object.ts b/apps/sim/lib/api/contracts/tools/aws/s3-put-object.ts
index 26abb5ef81d..0128b46b9bc 100644
--- a/apps/sim/lib/api/contracts/tools/aws/s3-put-object.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/s3-put-object.ts
@@ -17,6 +17,10 @@ const S3PutObjectSchema = z.object({
content: z.string().optional().nullable(),
contentType: z.string().optional().nullable(),
acl: z.string().optional().nullable(),
+ metadata: z.record(z.string(), z.string()).optional().nullable(),
+ storageClass: z.string().optional().nullable(),
+ serverSideEncryption: z.string().optional().nullable(),
+ sseKmsKeyId: z.string().optional().nullable(),
})
const S3PutObjectResponseSchema = z.object({
@@ -28,6 +32,8 @@ const S3PutObjectResponseSchema = z.object({
location: z.string(),
key: z.string(),
bucket: z.string(),
+ versionId: z.string().nullable(),
+ serverSideEncryption: z.string().nullable(),
}),
})
diff --git a/apps/sim/lib/api/contracts/tools/aws/secrets-manager-create-secret.ts b/apps/sim/lib/api/contracts/tools/aws/secrets-manager-create-secret.ts
index 87d4fe316ba..efe2d7ddbb6 100644
--- a/apps/sim/lib/api/contracts/tools/aws/secrets-manager-create-secret.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/secrets-manager-create-secret.ts
@@ -13,6 +13,11 @@ const CreateSecretSchema = z.object({
name: z.string().min(1, 'Secret name is required'),
secretValue: z.string().min(1, 'Secret value is required'),
description: z.string().nullish(),
+ kmsKeyId: z.string().nullish(),
+ tags: z
+ .array(z.object({ key: z.string().min(1).max(128), value: z.string().max(256) }))
+ .max(50)
+ .nullish(),
})
const CreateSecretResponseSchema = z.object({
diff --git a/apps/sim/lib/api/contracts/tools/aws/secrets-manager-get-secret.ts b/apps/sim/lib/api/contracts/tools/aws/secrets-manager-get-secret.ts
index 6873afe8fc6..86000552c31 100644
--- a/apps/sim/lib/api/contracts/tools/aws/secrets-manager-get-secret.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/secrets-manager-get-secret.ts
@@ -18,6 +18,7 @@ const GetSecretSchema = z.object({
const GetSecretResponseSchema = z.object({
name: z.string(),
secretValue: z.string(),
+ secretBinary: z.string().nullable(),
arn: z.string(),
versionId: z.string(),
versionStages: z.array(z.string()),
diff --git a/apps/sim/lib/api/contracts/tools/aws/secrets-manager-list-secrets.ts b/apps/sim/lib/api/contracts/tools/aws/secrets-manager-list-secrets.ts
index 198e751949a..2ec15fbb049 100644
--- a/apps/sim/lib/api/contracts/tools/aws/secrets-manager-list-secrets.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/secrets-manager-list-secrets.ts
@@ -12,6 +12,26 @@ const ListSecretsSchema = z.object({
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
maxResults: z.number().min(1).max(100).nullish(),
nextToken: z.string().nullish(),
+ filters: z
+ .array(
+ z.object({
+ key: z.enum([
+ 'description',
+ 'name',
+ 'tag-key',
+ 'tag-value',
+ 'primary-region',
+ 'owning-service',
+ 'all',
+ ]),
+ values: z.array(z.string().min(1)).min(1).max(10),
+ })
+ )
+ .max(10)
+ .nullish(),
+ sortOrder: z.enum(['asc', 'desc']).nullish(),
+ sortBy: z.enum(['created-date', 'last-accessed-date', 'last-changed-date', 'name']).nullish(),
+ includePlannedDeletion: z.boolean().nullish(),
})
const RotationRulesResponseSchema = z.object({
@@ -26,6 +46,10 @@ const ListSecretsResponseSchema = z.object({
name: z.string(),
arn: z.string(),
description: z.string().nullable(),
+ kmsKeyId: z.string().nullable(),
+ rotationLambdaARN: z.string().nullable(),
+ owningService: z.string().nullable(),
+ primaryRegion: z.string().nullable(),
createdDate: z.string().nullable(),
lastChangedDate: z.string().nullable(),
lastAccessedDate: z.string().nullable(),
diff --git a/apps/sim/lib/api/contracts/tools/aws/secrets-manager-update-secret.ts b/apps/sim/lib/api/contracts/tools/aws/secrets-manager-update-secret.ts
index e72fe04a3ad..a7d35a0a5ee 100644
--- a/apps/sim/lib/api/contracts/tools/aws/secrets-manager-update-secret.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/secrets-manager-update-secret.ts
@@ -13,6 +13,7 @@ const UpdateSecretSchema = z.object({
secretId: z.string().min(1, 'Secret ID is required'),
secretValue: z.string().min(1, 'Secret value is required'),
description: z.string().nullish(),
+ kmsKeyId: z.string().nullish(),
})
const UpdateSecretResponseSchema = z.object({
diff --git a/apps/sim/lib/api/contracts/tools/aws/ses-get-account.ts b/apps/sim/lib/api/contracts/tools/aws/ses-get-account.ts
index c8555b7086a..ee528d689bc 100644
--- a/apps/sim/lib/api/contracts/tools/aws/ses-get-account.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/ses-get-account.ts
@@ -23,6 +23,8 @@ const GetAccountResponseSchema = z.object({
max24HourSend: z.number(),
maxSendRate: z.number(),
sentLast24Hours: z.number(),
+ productionAccessEnabled: z.boolean().nullable(),
+ enforcementStatus: z.string().nullable(),
})
export const awsSesGetAccountContract = defineRouteContract({
diff --git a/apps/sim/lib/api/contracts/tools/aws/ses-send-bulk-email.ts b/apps/sim/lib/api/contracts/tools/aws/ses-send-bulk-email.ts
index 06cc349435f..6288096bd1f 100644
--- a/apps/sim/lib/api/contracts/tools/aws/ses-send-bulk-email.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/ses-send-bulk-email.ts
@@ -1,4 +1,5 @@
import { z } from 'zod'
+import { sesEmailTagsSchema } from '@/lib/api/contracts/tools/aws/ses-shared'
import type {
ContractBody,
ContractBodyInput,
@@ -21,6 +22,9 @@ const SendBulkEmailSchema = z.object({
destinations: z.string().min(1, 'Destinations JSON array is required'),
defaultTemplateData: z.string().nullish(),
configurationSetName: z.string().nullish(),
+ replyToAddresses: z.string().nullish(),
+ fromName: z.string().nullish(),
+ emailTags: sesEmailTagsSchema,
})
const SendBulkEmailResponseSchema = z.object({
diff --git a/apps/sim/lib/api/contracts/tools/aws/ses-send-email.ts b/apps/sim/lib/api/contracts/tools/aws/ses-send-email.ts
index f6f93286d4a..968f4e7c57b 100644
--- a/apps/sim/lib/api/contracts/tools/aws/ses-send-email.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/ses-send-email.ts
@@ -1,4 +1,5 @@
import { z } from 'zod'
+import { sesEmailTagsSchema } from '@/lib/api/contracts/tools/aws/ses-shared'
import type {
ContractBody,
ContractBodyInput,
@@ -26,6 +27,8 @@ const SendEmailSchema = z
bccAddresses: z.string().nullish(),
replyToAddresses: z.string().nullish(),
configurationSetName: z.string().nullish(),
+ fromName: z.string().nullish(),
+ emailTags: sesEmailTagsSchema,
})
.refine((data) => data.bodyText || data.bodyHtml, {
message: 'At least one of bodyText or bodyHtml is required',
diff --git a/apps/sim/lib/api/contracts/tools/aws/ses-send-templated-email.ts b/apps/sim/lib/api/contracts/tools/aws/ses-send-templated-email.ts
index 589316675d2..bb46f9ac822 100644
--- a/apps/sim/lib/api/contracts/tools/aws/ses-send-templated-email.ts
+++ b/apps/sim/lib/api/contracts/tools/aws/ses-send-templated-email.ts
@@ -1,4 +1,5 @@
import { z } from 'zod'
+import { sesEmailTagsSchema } from '@/lib/api/contracts/tools/aws/ses-shared'
import type {
ContractBody,
ContractBodyInput,
@@ -23,6 +24,9 @@ const SendTemplatedEmailSchema = z.object({
ccAddresses: z.string().nullish(),
bccAddresses: z.string().nullish(),
configurationSetName: z.string().nullish(),
+ replyToAddresses: z.string().nullish(),
+ fromName: z.string().nullish(),
+ emailTags: sesEmailTagsSchema,
})
const SendTemplatedEmailResponseSchema = z.object({
diff --git a/apps/sim/lib/api/contracts/tools/aws/ses-shared.ts b/apps/sim/lib/api/contracts/tools/aws/ses-shared.ts
new file mode 100644
index 00000000000..65624ddb600
--- /dev/null
+++ b/apps/sim/lib/api/contracts/tools/aws/ses-shared.ts
@@ -0,0 +1,11 @@
+import { z } from 'zod'
+
+/** SES `MessageTag` name or value: 1-256 letters, digits, underscores, or hyphens. */
+const sesMessageTagPartSchema = z
+ .string()
+ .regex(/^[A-Za-z0-9_-]{1,256}$/, 'Tag names and values may contain only letters, digits, _ and -')
+
+/** Message tags on an SES send, for filtering its sending events. */
+export const sesEmailTagsSchema = z
+ .array(z.object({ name: sesMessageTagPartSchema, value: sesMessageTagPartSchema }))
+ .nullish()
diff --git a/apps/sim/lib/api/contracts/tools/confluence.ts b/apps/sim/lib/api/contracts/tools/confluence.ts
index 482f7e67e3e..71c149e8ce4 100644
--- a/apps/sim/lib/api/contracts/tools/confluence.ts
+++ b/apps/sim/lib/api/contracts/tools/confluence.ts
@@ -111,12 +111,14 @@ export const confluenceListAttachmentsQuerySchema = confluencePageScopedSchema.e
export const confluenceCreateCommentBodySchema = confluencePageScopedSchema.extend({
comment: z.string({ error: 'Comment is required' }).min(1, 'Comment is required'),
+ parentCommentId: z.string().optional(),
})
export const confluenceListCommentsQuerySchema = confluencePageScopedSchema.extend({
limit: z.string().optional().default('25'),
bodyFormat: z.string().optional().default('storage'),
cursor: z.string().optional(),
+ commentType: z.enum(['footer', 'inline']).optional(),
})
export const confluenceUpdateCommentBodySchema = confluenceCommentScopedBaseSchema
@@ -196,6 +198,8 @@ export const confluencePagesByLabelQuerySchema = confluenceBaseSchema.extend({
export const confluenceSearchBodySchema = confluenceBaseSchema.extend({
query: z.string({ error: 'Search query is required' }).min(1, 'Search query is required'),
limit: z.number().optional().default(25),
+ cql: z.string().optional(),
+ cursor: z.string().optional(),
})
export const confluenceSearchInSpaceBodySchema = confluenceBaseSchema.extend({
@@ -203,6 +207,7 @@ export const confluenceSearchInSpaceBodySchema = confluenceBaseSchema.extend({
query: z.string().optional(),
limit: z.number().optional().default(25),
contentType: z.string().optional(),
+ cursor: z.string().optional(),
})
export const confluenceSpaceBlogPostsBodySchema = confluenceSpaceScopedSchema.extend({
diff --git a/apps/sim/lib/api/contracts/tools/google.ts b/apps/sim/lib/api/contracts/tools/google.ts
index 024c6870bc8..6519bfb3c9c 100644
--- a/apps/sim/lib/api/contracts/tools/google.ts
+++ b/apps/sim/lib/api/contracts/tools/google.ts
@@ -30,6 +30,7 @@ export const gmailMailBodySchema = z.object({
replyToMessageId: z.string().optional().nullable(),
cc: z.string().optional().nullable(),
bcc: z.string().optional().nullable(),
+ replyTo: z.string().optional().nullable(),
attachments: RawFileInputArraySchema.optional().nullable(),
})
diff --git a/apps/sim/lib/api/contracts/tools/jsm.ts b/apps/sim/lib/api/contracts/tools/jsm.ts
index da5d2b74442..876ecfafdba 100644
--- a/apps/sim/lib/api/contracts/tools/jsm.ts
+++ b/apps/sim/lib/api/contracts/tools/jsm.ts
@@ -81,6 +81,8 @@ export const jsmRequestsBodySchema = jsmBaseBodySchema.extend({
serviceDeskId: z.string().optional(),
requestOwnership: z.string().optional(),
requestStatus: z.string().optional(),
+ approvalStatus: z.string().optional(),
+ organizationId: z.string().optional(),
requestTypeId: z.string().optional(),
searchTerm: z.string().optional(),
expand: z.string().optional(),
diff --git a/apps/sim/lib/api/contracts/tools/microsoft.ts b/apps/sim/lib/api/contracts/tools/microsoft.ts
index 4b4889b34c4..5ea5a6c48c8 100644
--- a/apps/sim/lib/api/contracts/tools/microsoft.ts
+++ b/apps/sim/lib/api/contracts/tools/microsoft.ts
@@ -16,6 +16,8 @@ export const outlookSendBodySchema = z.object({
cc: z.string().optional().nullable(),
bcc: z.string().optional().nullable(),
replyToMessageId: z.string().optional().nullable(),
+ replyTo: z.string().optional().nullable(),
+ importance: z.enum(['low', 'normal', 'high']).optional().nullable(),
attachments: RawFileInputArraySchema.optional().nullable(),
})
diff --git a/apps/sim/lib/api/contracts/v2/catalog.ts b/apps/sim/lib/api/contracts/v2/catalog.ts
index 59167a71c39..67942cb26d6 100644
--- a/apps/sim/lib/api/contracts/v2/catalog.ts
+++ b/apps/sim/lib/api/contracts/v2/catalog.ts
@@ -696,6 +696,12 @@ export const v2ConnectorConfigFieldSchema = z
.describe(
'When true the stored `sourceConfig` value is a `string[]`, not a `string`: a `selector` renders a multi-select picker and a `short-input` accepts a comma-separated list.'
),
+ apiKeyOnly: z
+ .boolean()
+ .optional()
+ .describe(
+ 'When true the field describes the connector’s API key (such as its key ID or a custom host the key signs for). It is required as stated only with `apiKey`, and omitted when a saved `credentialId` is sent.'
+ ),
})
.meta({
id: 'V2ConnectorConfigField',
@@ -731,7 +737,7 @@ export const v2ConnectorTypeSchema = z
mode: z
.literal('apiKey')
.describe(
- 'Authenticates with a stored API key; pass `apiKey`, and no `credentialId`, when creating the connector.'
+ 'Authenticates with a stored API key; pass `apiKey` when creating the connector, or a saved `credentialId` instead when `savedCredential` is present.'
),
label: z.string().optional().describe('Label shown above the key field.'),
placeholder: z.string().optional().describe('Placeholder shown in the key field.'),
@@ -740,10 +746,21 @@ export const v2ConnectorTypeSchema = z
.describe(
'Whether the key may be left blank, for a source reachable without authentication.'
),
+ savedCredential: z
+ .object({
+ provider: z
+ .string()
+ .describe('Service the saved credential must authenticate with, e.g. `aws`.'),
+ label: z.string().describe('Label for the saved-credential option.'),
+ })
+ .optional()
+ .describe(
+ 'When present, a saved credential of this service may be passed as `credentialId` instead of `apiKey`, once saved-credential setup is enabled on this deployment (until then it is refused with a validation error). Fields marked `apiKeyOnly` are then omitted.'
+ ),
}),
])
.describe(
- 'How the connector authenticates against its source: `oauth` connectors take `credentialId` (GitHub also accepts a personal access token as `apiKey`), `apiKey` connectors take `apiKey`.'
+ 'How the connector authenticates against its source: `oauth` connectors take `credentialId` (GitHub also accepts a personal access token as `apiKey`), `apiKey` connectors take `apiKey` (or, when they declare `savedCredential`, a saved `credentialId`).'
),
configFields: z
.array(v2ConnectorConfigFieldSchema)
@@ -787,7 +804,7 @@ export const v2ConnectorTypeSummarySchema = z
mode: z
.enum(['oauth', 'apiKey'])
.describe(
- 'How the connector authenticates against its source: `oauth` connectors take `credentialId` (GitHub also accepts a personal access token as `apiKey`), `apiKey` connectors take `apiKey`.'
+ 'How the connector authenticates against its source: `oauth` connectors take `credentialId` (GitHub also accepts a personal access token as `apiKey`), `apiKey` connectors take `apiKey` (or, when they declare `savedCredential`, a saved `credentialId`).'
),
})
.describe(
diff --git a/apps/sim/lib/api/contracts/v2/credentials.ts b/apps/sim/lib/api/contracts/v2/credentials.ts
index 0d61cf7712e..f6f4493764e 100644
--- a/apps/sim/lib/api/contracts/v2/credentials.ts
+++ b/apps/sim/lib/api/contracts/v2/credentials.ts
@@ -107,6 +107,12 @@ export const v2CredentialProviderFieldSchema = z
.max(20)
.optional()
.describe('Fixed values accepted by a selector field.'),
+ defaultValue: z
+ .string()
+ .min(1)
+ .max(255)
+ .optional()
+ .describe('Value an optional field takes when it is omitted.'),
hint: z.string().min(1).max(2000).optional().describe('Provider-specific setup guidance.'),
})
.strict()
@@ -167,7 +173,9 @@ export const v2ServiceAccountCredentialProviderSchema = z
requiredConfigFields: z.array(z.string()).describe('Fields you add to `keylessConfig`.'),
optionalConfigFields: z
.array(z.string())
- .describe('Fields `keylessConfig` may also carry.'),
+ .describe(
+ 'Fields `keylessConfig` may also carry. `audience` is a custom `aud` claim Sim signs in place of the default; your trust configuration must then accept it.'
+ ),
})
.strict()
.optional()
@@ -305,6 +313,7 @@ export const V2_OAUTH_CONNECTION_PROVIDER_IDS = [
'ramp',
'helpscout',
'hubspot',
+ 'apollo',
'linkedin',
'instagram',
'salesforce',
@@ -449,20 +458,24 @@ const v2CredentialWorkloadIdentitySubjectSchema = z
})
.strict()
.describe(
- 'Prefilled keyless `serviceAccountJson`: add `requiredConfigFields`, then send it to Create service-account credential.'
+ 'Prefilled keyless `serviceAccountJson`: add `requiredConfigFields` (and any `optionalConfigFields`, such as a custom `audience`), then send it to Create service-account credential.'
),
requiredConfigFields: z
.array(z.string())
.describe(
'Fields from your cloud setup that `keylessConfig` still needs, such as roleArn or host.'
),
- optionalConfigFields: z.array(z.string()).describe('Fields `keylessConfig` may also carry.'),
+ optionalConfigFields: z
+ .array(z.string())
+ .describe(
+ 'Fields `keylessConfig` may also carry. `audience` replaces the default `audience` above with your own value, such as one specific to your account; trust that value instead of the default.'
+ ),
})
.meta({
id: 'V2CredentialWorkloadIdentitySubject',
title: 'Credential workload identity subject',
description:
- 'The issuer, audience, and subject a cloud trust configuration pins, and the proof that attaches the subject to a new keyless credential.',
+ 'The issuer, default audience, and subject a cloud trust configuration pins, and the proof that attaches the subject to a new keyless credential. A `keylessConfig.audience` overrides the default audience.',
})
export const v2CreateCredentialWorkloadIdentitySubjectContract = defineRouteContract({
diff --git a/apps/sim/lib/api/contracts/v2/knowledge.ts b/apps/sim/lib/api/contracts/v2/knowledge.ts
index 2aab9f6129c..e09ab863dca 100644
--- a/apps/sim/lib/api/contracts/v2/knowledge.ts
+++ b/apps/sim/lib/api/contracts/v2/knowledge.ts
@@ -1777,7 +1777,9 @@ export const v2KnowledgeConnectorSchema = z
credentialId: z
.string()
.nullable()
- .describe('OAuth credential identifier, or null for API-key and unauthenticated sources.'),
+ .describe(
+ 'OAuth credential identifier, or the saved credential an `apiKey` source syncs with (see `auth.savedCredential`); null for unauthenticated sources and for `apiKey` sources that use an inline API key.'
+ ),
sourceConfig: z
.record(z.string(), z.unknown().describe('Connector-specific source configuration value.'))
.describe('Connector-specific source selection and filtering configuration.'),
@@ -1932,7 +1934,7 @@ export const v2CreateKnowledgeConnectorBodySchema = z
.max(255)
.optional()
.describe(
- 'OAuth credential identifier for connector types whose `auth.mode` is `oauth` (see connector types); omit it for `apiKey` connectors.'
+ 'OAuth credential identifier for connector types whose `auth.mode` is `oauth` (see connector types), or, when this deployment enables it, a saved credential for an `apiKey` connector type that declares `auth.savedCredential` (such as Amazon S3 with a saved AWS credential); otherwise omit it for `apiKey` connectors.'
),
apiKey: z
.string()
diff --git a/apps/sim/lib/api/contracts/v2/logs.ts b/apps/sim/lib/api/contracts/v2/logs.ts
index f5847eac519..e657a31519f 100644
--- a/apps/sim/lib/api/contracts/v2/logs.ts
+++ b/apps/sim/lib/api/contracts/v2/logs.ts
@@ -86,15 +86,15 @@ const v2LogDetailCostSchema = z
*
* That pass-through is also why this field disagrees with the run resources for the
* same run, and the disagreement is documented rather than reconciled. The run list
- * projects `paused` over the persisted value whenever the run holds a `paused` or
- * `partially_resumed` row in `paused_executions` (`executionStatus` in
- * `lib/workflows/executor/execution-queries.ts`), so an ordinary human-in-the-loop
+ * projects `paused` over the persisted value while the run holds a `paused` or
+ * `partially_resumed` row in `paused_executions` with a point still to resume
+ * (`executionStatus` in `lib/workflows/executor/execution-queries.ts`), so an ordinary human-in-the-loop
* pause reads `paused` there and `pending` here. Adopting the overlay would need this
* read to join `paused_executions`, and would silently move live runs between the
* `pending` and `paused` buckets of a shipped field that internal log consumers read
* from the same query — a breaking change, not a correction. Callers that need the
- * pause distinction read the run resources, which also carry the `paused` object that
- * separates "waiting on a human" from "a resume attempt failed".
+ * pause distinction read the run resources, which also carry the `paused` object with the
+ * pause kind, resume time, and any automatic-resume waiting reason.
*/
export const v2LogStatusSchema = z
.enum(PERSISTED_WORKFLOW_EXECUTION_STATUSES)
diff --git a/apps/sim/lib/api/contracts/v2/openapi/resources.ts b/apps/sim/lib/api/contracts/v2/openapi/resources.ts
index a24849f18fd..9b9a66c2a73 100644
--- a/apps/sim/lib/api/contracts/v2/openapi/resources.ts
+++ b/apps/sim/lib/api/contracts/v2/openapi/resources.ts
@@ -625,7 +625,7 @@ const WORKLOAD_IDENTITY_SUBJECT_EXAMPLE = {
subjectProof: WORKLOAD_IDENTITY_SUBJECT_PROOF,
},
requiredConfigFields: ['roleArn'],
- optionalConfigFields: ['stsRegion', 'sessionDurationSeconds'],
+ optionalConfigFields: ['stsRegion', 'sessionDurationSeconds', 'audience'],
} as const
const SECRET_EXAMPLE = {
@@ -1840,7 +1840,7 @@ const declaredRoutes = [
applicationOperation: credentialOperations.prepareWorkloadIdentity,
operationId: 'createCredentialWorkloadIdentitySubject',
summary: 'Create workload identity subject',
- description: `Start a keyless credential. Returns the issuer, audience, and a fresh subject to configure in the cloud's trust policy, plus \`keylessConfig\`: a prefilled \`serviceAccountJson\` whose \`subjectProof\` is valid for 24 hours and only for the requesting user. Add the \`requiredConfigFields\`, then call Create service-account credential with the same \`providerId\`; Sim verifies the trust while saving. Google Cloud has no fixed audience: it is the credential's own pool provider. Without keyless support the deployment returns \`400\` with \`details.code\` \`WORKLOAD_IDENTITY_NOT_CONFIGURED\`. ${WORKSPACE_API_KEY_DENIED}`,
+ description: `Start a keyless credential. Returns the issuer, audience, and a fresh subject to configure in the cloud's trust policy, plus \`keylessConfig\`: a prefilled \`serviceAccountJson\` whose \`subjectProof\` is valid for 24 hours and only for the requesting user. Add the \`requiredConfigFields\`, then call Create service-account credential with the same \`providerId\`; Sim verifies the trust while saving. The returned \`audience\` is the default; set \`audience\` in \`keylessConfig\` to have Sim sign tokens for your own value instead, and trust that value in the cloud. Google Cloud has no fixed default: it is the credential's own pool provider. Without keyless support the deployment returns \`400\` with \`details.code\` \`WORKLOAD_IDENTITY_NOT_CONFIGURED\`. ${WORKSPACE_API_KEY_DENIED}`,
errors: RESOURCE_ERRORS,
success: { description: 'A subject and proof for one new keyless credential.' },
}),
diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts
index 0aa62b0001d..ce9558a9b7e 100644
--- a/apps/sim/lib/api/contracts/v2/workflows.ts
+++ b/apps/sim/lib/api/contracts/v2/workflows.ts
@@ -1983,16 +1983,17 @@ export const v2ResumeWorkflowContract = defineRouteContract({
})
const RUN_STATUS_DESCRIPTION =
- "Current or terminal run status. `redacting` is transient, reported while a finished run's output is being scrubbed. `paused` means the run is waiting at a human or time pause, or a resume attempt did not complete. The single-run response provides the pause kind, resume time, and any automatic-resume waiting reason."
+ "Current or terminal run status. `redacting` is transient, reported while a finished run's output is being scrubbed. `paused` means the run has a human or time pause that can still be resumed; once the run has finished and every pause point has resumed or failed, it reports the finished status. The single-run response provides the pause kind, resume time, and any automatic-resume waiting reason."
/**
* The list projection passes `workflow_execution_logs.status` through except where it
* overlays `paused` for a run holding a `paused` or `partially_resumed` row in
- * `paused_executions` — so a reported `paused` is either that overlay or the persisted
- * value a failed resume attempt left behind. Both branches land in the persisted set, so the reported enum is
- * derived from it — a value missing here fails the response parse, and because list
- * validation is whole-page one such row turns an entire page into a 500. `queued` is not
- * reportable: a run still only in the job queue has no log row to list.
+ * `paused_executions` that still has a point to resume (a finished run whose every point
+ * resumed or failed reads its log status). A reported `paused` is either that overlay or the
+ * persisted value a failed resume attempt left behind. Both branches land in the persisted
+ * set, so the reported enum is derived from it — a value missing here fails the response
+ * parse, and because list validation is whole-page one such row turns an entire page into a
+ * 500. `queued` is not reportable: a run still only in the job queue has no log row to list.
*/
export const v2WorkflowRunListStatusValueSchema = z
.enum(PERSISTED_WORKFLOW_EXECUTION_STATUSES)
diff --git a/apps/sim/lib/api/mcp/generated/v2-operations.ts b/apps/sim/lib/api/mcp/generated/v2-operations.ts
index 1b04a5f6016..bf693ccfc18 100644
--- a/apps/sim/lib/api/mcp/generated/v2-operations.ts
+++ b/apps/sim/lib/api/mcp/generated/v2-operations.ts
@@ -665,7 +665,7 @@ export const V2_MCP_OPERATIONS = {
contract: v2CreateCredentialWorkloadIdentitySubjectContract,
summary: 'Create workload identity subject',
description:
- "Start a keyless credential. Returns the issuer, audience, and a fresh subject to configure in the cloud's trust policy, plus `keylessConfig`: a prefilled `serviceAccountJson` whose `subjectProof` is valid for 24 hours and only for the requesting user. Add the `requiredConfigFields`, then call Create service-account credential with the same `providerId`; Sim verifies the trust while saving. Google Cloud has no fixed audience: it is the credential's own pool provider. Without keyless support the deployment returns `400` with `details.code` `WORKLOAD_IDENTITY_NOT_CONFIGURED`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.",
+ "Start a keyless credential. Returns the issuer, audience, and a fresh subject to configure in the cloud's trust policy, plus `keylessConfig`: a prefilled `serviceAccountJson` whose `subjectProof` is valid for 24 hours and only for the requesting user. Add the `requiredConfigFields`, then call Create service-account credential with the same `providerId`; Sim verifies the trust while saving. The returned `audience` is the default; set `audience` in `keylessConfig` to have Sim sign tokens for your own value instead, and trust that value in the cloud. Google Cloud has no fixed default: it is the credential's own pool provider. Without keyless support the deployment returns `400` with `details.code` `WORKLOAD_IDENTITY_NOT_CONFIGURED`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.",
workspaceKeyUnsupported: true,
handler: () =>
import('@/app/api/v2/credentials/workload-identity/subjects/route').then(
diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts
index 31ea8978ad5..0b492da2045 100644
--- a/apps/sim/lib/auth/auth.ts
+++ b/apps/sim/lib/auth/auth.ts
@@ -5,6 +5,7 @@ import { db } from '@sim/db'
import * as schema from '@sim/db/schema'
import { createLogger, setRequestAuth } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
+import { isRecordLike } from '@sim/utils/object'
import { type BetterAuthOptions, betterAuth, type User } from 'better-auth'
import {
APIError,
@@ -159,6 +160,7 @@ import { validateSignupEmailMx } from '@/lib/messaging/email/validation.server'
import { isEmailVerificationEffectivelyEnabled } from '@/lib/messaging/email/verification'
import { scheduleLifecycleEmail } from '@/lib/messaging/lifecycle'
import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
+import { formatApolloAuthorizationUrl } from '@/lib/oauth/apollo'
import { clearRecordedRevocation } from '@/lib/oauth/credential-service'
import {
getMicrosoftRefreshTokenExpiry,
@@ -1242,6 +1244,13 @@ export const auth = betterAuth({
}
}),
after: createAuthMiddleware(async (ctx) => {
+ if (ctx.path === '/oauth2/link' && ctx.body?.providerId === 'apollo') {
+ const returned = ctx.context.returned
+ if (isRecordLike(returned) && typeof returned.url === 'string') {
+ ctx.context.returned = { ...returned, url: formatApolloAuthorizationUrl(returned.url) }
+ }
+ }
+
if (isBillingEnabled) await recordCustomerRestoreAfterHook(ctx)
const recordFinalAuthenticationResult = async (
diff --git a/apps/sim/lib/auth/connectors/providers.ts b/apps/sim/lib/auth/connectors/providers.ts
index 4e9b89522ac..29b8290cb8d 100644
--- a/apps/sim/lib/auth/connectors/providers.ts
+++ b/apps/sim/lib/auth/connectors/providers.ts
@@ -15,6 +15,7 @@ import {
readResponseTextWithLimit,
} from '@/lib/core/utils/stream-limits'
import { getBaseUrl } from '@/lib/core/utils/urls'
+import { createApolloProvider } from '@/lib/oauth/apollo'
import { exchangeAtlassianAuthorizationCode } from '@/lib/oauth/atlassian-token'
import { getDocusignOAuthUrl } from '@/lib/oauth/docusign'
import { createGitHubRepositoriesProvider } from '@/lib/oauth/github-repositories'
@@ -1042,6 +1043,13 @@ export function buildConnectorProviders(): GenericOAuthConfig[] {
},
},
+ createApolloProvider({
+ clientId: env.APOLLO_CLIENT_ID as string,
+ clientSecret: env.APOLLO_CLIENT_SECRET as string,
+ redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/apollo`,
+ scopes: getCanonicalScopesForProvider('apollo'),
+ }),
+
{
providerId: 'pipedrive',
clientId: env.PIPEDRIVE_CLIENT_ID as string,
diff --git a/apps/sim/lib/billing/authorization.ts b/apps/sim/lib/billing/authorization.ts
index da71750cd50..ceb36948e0a 100644
--- a/apps/sim/lib/billing/authorization.ts
+++ b/apps/sim/lib/billing/authorization.ts
@@ -3,7 +3,7 @@ import { subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { APIError } from 'better-auth/api'
import { and, eq, isNotNull } from 'drizzle-orm'
-import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config'
+import { getAwsMarketplaceProducts } from '@/lib/billing/aws-marketplace/config'
import { holdsMarketplaceLicense } from '@/lib/billing/aws-marketplace/repository'
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
import {
@@ -64,8 +64,9 @@ async function assertNoBoundIncompleteSubscription(referenceId: string): Promise
* license lookup fails.
*/
async function assertNotBilledThroughMarketplace(referenceId: string): Promise {
- if (!getAwsMarketplaceProductCode()) return
- if (!(await holdsMarketplaceLicense(referenceId))) return
+ const products = getAwsMarketplaceProducts()
+ if (products.size === 0) return
+ if (!(await holdsMarketplaceLicense(products, referenceId))) return
logger.warn('Blocking checkout - reference is billed through AWS Marketplace', { referenceId })
throw new APIError('CONFLICT', {
diff --git a/apps/sim/lib/billing/aws-marketplace/application/admin.ts b/apps/sim/lib/billing/aws-marketplace/application/admin.ts
index 4154364c62a..ad373adbb8f 100644
--- a/apps/sim/lib/billing/aws-marketplace/application/admin.ts
+++ b/apps/sim/lib/billing/aws-marketplace/application/admin.ts
@@ -1,9 +1,10 @@
-import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
+import { AuditAction, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import type { AdminMutationActor } from '@/lib/admin/dashboard'
import {
- licenseAuditMetadata,
+ licenseAuditEntry,
requireLinkableTo,
+ requireNotBilledThroughStripe,
} from '@/lib/billing/aws-marketplace/application/use-cases'
import {
countUnlinkedLicenses,
@@ -57,6 +58,7 @@ export async function linkAwsMarketplaceLicenseAsAdmin(
if (locked.organizationId === input.organizationId) {
return { license: locked, newlyLinked: false }
}
+ await requireNotBilledThroughStripe(tx, input.organizationId)
return {
license: await linkLicenseForTeam(tx, input.licenseId, input.organizationId),
newlyLinked: true,
@@ -67,12 +69,12 @@ export async function linkAwsMarketplaceLicenseAsAdmin(
actorId: actor.id,
actorName: actor.name,
actorEmail: actor.email,
- action: AuditAction.AWS_MARKETPLACE_LICENSE_LINKED,
- resourceType: AuditResourceType.AWS_MARKETPLACE_LICENSE,
- resourceId: license.id,
- resourceName: license.dimension ?? 'AWS Marketplace license',
- description: 'Admin linked an AWS Marketplace subscription to an organization',
- metadata: { ...licenseAuditMetadata(license), organizationId: input.organizationId },
+ ...licenseAuditEntry(
+ license,
+ AuditAction.AWS_MARKETPLACE_LICENSE_LINKED,
+ 'Admin linked an AWS Marketplace subscription to an organization',
+ { organizationId: input.organizationId }
+ ),
})
}
return { id: license.id, organizationId: input.organizationId }
diff --git a/apps/sim/lib/billing/aws-marketplace/application/use-cases.ts b/apps/sim/lib/billing/aws-marketplace/application/use-cases.ts
index 4e1d3ec5383..be2f5774365 100644
--- a/apps/sim/lib/billing/aws-marketplace/application/use-cases.ts
+++ b/apps/sim/lib/billing/aws-marketplace/application/use-cases.ts
@@ -1,12 +1,16 @@
-import { AuditAction, AuditResourceType } from '@sim/audit'
+import { AuditAction, type AuditActionType, AuditResourceType } from '@sim/audit'
import { db } from '@sim/db'
import type { AwsMarketplaceClaim } from '@/lib/api/contracts/aws-marketplace'
import { awsMarketplaceOperations } from '@/lib/billing/aws-marketplace/application/operations'
import { hashClaimToken } from '@/lib/billing/aws-marketplace/claim'
-import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config'
+import {
+ findAwsMarketplaceProduct,
+ isAwsMarketplaceEnabled,
+} from '@/lib/billing/aws-marketplace/config'
import { verifyLicense } from '@/lib/billing/aws-marketplace/entitlements'
import {
type AwsMarketplaceLicenseRecord,
+ type BuyerContact,
consumeLicenseClaim,
findLicenseByClaimHash,
isNewerRead,
@@ -14,6 +18,7 @@ import {
listAdministeredOrganizations,
listOrganizationLicenses,
lockLicense,
+ paysForSelfServePlan,
recordLicenseVerification,
saveLicenseContact,
} from '@/lib/billing/aws-marketplace/repository'
@@ -25,12 +30,18 @@ import { recordProjectedUseCaseAuditEntries } from '@/lib/core/application/autho
import { assertOperationPrincipal, type OperationUseCase } from '@/lib/core/application/operation'
import { requireOrganizationSubjectMembership } from '@/lib/core/application/organization-authorization'
import { OrchestrationError } from '@/lib/core/orchestration/types'
+import type { DbTransaction } from '@/lib/db/types'
/** The same answer as an unknown route, so a deployment that does not sell on AWS shows nothing. */
-function requireProductCode(): string {
- const productCode = getAwsMarketplaceProductCode()
- if (!productCode) throw new OrchestrationError('not_found', 'Not found')
- return productCode
+function requireMarketplaceEnabled(): void {
+ if (!isAwsMarketplaceEnabled()) throw new OrchestrationError('not_found', 'Not found')
+}
+
+/** The listing a license was sold under; one this deployment no longer sells reads as unknown. */
+function requireLicenseProduct(license: AwsMarketplaceLicenseRecord) {
+ const product = findAwsMarketplaceProduct(license.productCode)
+ if (!product) throw new OrchestrationError('not_found', 'Not found')
+ return product
}
function isClaimOpen(license: AwsMarketplaceLicenseRecord, now: Date): boolean {
@@ -85,6 +96,22 @@ export function requireLinkableTo(
}
}
+/**
+ * AWS bills a linked organization for the listing, so one already paying Stripe for a plan would
+ * pay twice. Its subscription is cancelled first, by its owner or by support.
+ */
+export async function requireNotBilledThroughStripe(
+ tx: DbTransaction,
+ organizationId: string
+): Promise {
+ if (await paysForSelfServePlan(tx, organizationId)) {
+ throw new OrchestrationError(
+ 'conflict',
+ 'This organization already pays for a Sim plan. Cancel that subscription, or contact support, before linking an AWS Marketplace subscription to it.'
+ )
+ }
+}
+
/** Enough for a buyer to recognize their account, and no more for someone holding their claim. */
function maskAwsAccount(accountId: string): string {
return `••••${accountId.slice(-4)}`
@@ -115,7 +142,7 @@ export const readAwsMarketplaceClaim: OperationUseCase<
operation: awsMarketplaceOperations.readClaim,
async execute({ principal, input }) {
assertOperationPrincipal(principal, awsMarketplaceOperations.readClaim)
- requireProductCode()
+ requireMarketplaceEnabled()
if (!input.claimToken) return { status: 'missing' }
const license = await findLicenseByClaimHash(hashClaimToken(input.claimToken))
if (!license || !isClaimOpen(license, new Date())) return { status: 'missing' }
@@ -141,12 +168,6 @@ interface ClaimInput {
claimToken: string | null
}
-interface BuyerContact {
- /** Whom to contact about provisioning; AWS asks the registration page to collect it. */
- contactEmail: string
- companyName: string | null
-}
-
type TakeInput = ClaimInput & BuyerContact
interface LinkLicenseInput extends TakeInput {
@@ -159,8 +180,20 @@ interface LinkedLicense {
newlyLinked: boolean
}
-function contactOf(input: BuyerContact) {
- return { email: input.contactEmail, companyName: input.companyName }
+/** The license this browser's claim names, refused unless the claim is still open. */
+async function findOpenClaim(input: ClaimInput) {
+ const claimHash = input.claimToken ? hashClaimToken(input.claimToken) : null
+ const claimed = requireOpenClaim(
+ claimHash ? await findLicenseByClaimHash(claimHash) : null,
+ input.licenseId,
+ claimHash
+ )
+ return { claimHash, claimed }
+}
+
+/** Locks the claimed license and checks the claim again, so two tabs racing it take it once. */
+async function lockOpenClaim(tx: DbTransaction, input: ClaimInput, claimHash: string | null) {
+ return requireOpenClaim(await lockLicense(tx, input.licenseId), input.licenseId, claimHash)
}
/**
@@ -169,22 +202,17 @@ function contactOf(input: BuyerContact) {
* rechecks the claim under the row lock, so two tabs racing one claim take it at most once.
*/
async function verifyClaimedLicense(input: ClaimInput, organizationId: string | null) {
- const productCode = requireProductCode()
- const claimHash = input.claimToken ? hashClaimToken(input.claimToken) : null
- const claimed = requireOpenClaim(
- claimHash ? await findLicenseByClaimHash(claimHash) : null,
- input.licenseId,
- claimHash
- )
+ requireMarketplaceEnabled()
+ const { claimHash, claimed } = await findOpenClaim(input)
requireLinkableTo(claimed, organizationId)
const verifiedAt = new Date()
- const summary = await verifyLicense(productCode, claimed.licenseArn)
+ const summary = await verifyLicense(requireLicenseProduct(claimed), claimed.licenseArn)
const verification = { summary, verifiedAt }
if (!summary.active) {
const outcome = await recordLicenseVerification({
licenseArn: claimed.licenseArn,
- productCode,
+ productCode: claimed.productCode,
verification,
})
throw new OrchestrationError(
@@ -197,11 +225,25 @@ async function verifyClaimedLicense(input: ClaimInput, organizationId: string |
return { claimHash, verification }
}
-export function licenseAuditMetadata(license: AwsMarketplaceLicenseRecord) {
+/** The audit entry for a license changing hands; `metadata` adds to its terms. */
+export function licenseAuditEntry(
+ license: AwsMarketplaceLicenseRecord,
+ action: AuditActionType,
+ description: string,
+ metadata: Record = {}
+) {
return {
- dimension: license.dimension,
- quantity: license.quantity,
- expiresAt: license.expiresAt?.toISOString() ?? null,
+ action,
+ resourceType: AuditResourceType.AWS_MARKETPLACE_LICENSE,
+ resourceId: license.id,
+ resourceName: license.dimension ?? 'AWS Marketplace license',
+ description,
+ metadata: {
+ dimension: license.dimension,
+ quantity: license.quantity,
+ expiresAt: license.expiresAt?.toISOString() ?? null,
+ ...metadata,
+ },
}
}
@@ -218,11 +260,7 @@ export const linkAwsMarketplaceLicense = defineAuthorizedOrganizationUseCase({
}: OrganizationUseCaseContext): Promise {
const { claimHash, verification } = await verifyClaimedLicense(input, input.organizationId)
return db.transaction(async (tx) => {
- const locked = requireOpenClaim(
- await lockLicense(tx, input.licenseId),
- input.licenseId,
- claimHash
- )
+ const locked = await lockOpenClaim(tx, input, claimHash)
requireLinkableTo(locked, input.organizationId)
requireNotOverruled(locked, verification.verifiedAt)
// Holds the admin's membership until commit, so a concurrent demotion waits for the link.
@@ -235,9 +273,10 @@ export const linkAwsMarketplaceLicense = defineAuthorizedOrganizationUseCase({
{ executor: tx, forShare: true }
)
const newlyLinked = locked.organizationId !== input.organizationId
+ if (newlyLinked) await requireNotBilledThroughStripe(tx, input.organizationId)
const license = await consumeLicenseClaim(tx, locked, {
link: newlyLinked ? { organizationId: input.organizationId, userId: context.userId } : null,
- contact: contactOf(input),
+ contact: input,
verification,
})
return { license, newlyLinked }
@@ -245,14 +284,11 @@ export const linkAwsMarketplaceLicense = defineAuthorizedOrganizationUseCase({
},
projectAudit: ({ result }) =>
result.newlyLinked
- ? {
- action: AuditAction.AWS_MARKETPLACE_LICENSE_LINKED,
- resourceType: AuditResourceType.AWS_MARKETPLACE_LICENSE,
- resourceId: result.license.id,
- resourceName: result.license.dimension ?? 'AWS Marketplace license',
- description: 'Linked an AWS Marketplace subscription to the organization',
- metadata: licenseAuditMetadata(result.license),
- }
+ ? licenseAuditEntry(
+ result.license,
+ AuditAction.AWS_MARKETPLACE_LICENSE_LINKED,
+ 'Linked an AWS Marketplace subscription to the organization'
+ )
: [],
})
@@ -272,17 +308,13 @@ export const registerAwsMarketplaceLicenseToAccount: OperationUseCase<
assertOperationPrincipal(principal, awsMarketplaceOperations.registerToAccount)
const { claimHash, verification } = await verifyClaimedLicense(input, null)
const { license, newlyRegistered } = await db.transaction(async (tx) => {
- const locked = requireOpenClaim(
- await lockLicense(tx, input.licenseId),
- input.licenseId,
- claimHash
- )
+ const locked = await lockOpenClaim(tx, input, claimHash)
requireLinkableTo(locked, null)
requireNotOverruled(locked, verification.verifiedAt)
const newlyRegistered = locked.linkedByUserId !== principal.userId
const consumed = await consumeLicenseClaim(tx, locked, {
link: newlyRegistered ? { organizationId: null, userId: principal.userId } : null,
- contact: contactOf(input),
+ contact: input,
verification,
})
return { license: consumed, newlyRegistered }
@@ -294,14 +326,11 @@ export const registerAwsMarketplaceLicenseToAccount: OperationUseCase<
principal,
request,
[
- {
- action: AuditAction.AWS_MARKETPLACE_LICENSE_REGISTERED,
- resourceType: AuditResourceType.AWS_MARKETPLACE_LICENSE,
- resourceId: license.id,
- resourceName: license.dimension ?? 'AWS Marketplace license',
- description: 'Registered an AWS Marketplace subscription to an account',
- metadata: licenseAuditMetadata(license),
- },
+ licenseAuditEntry(
+ license,
+ AuditAction.AWS_MARKETPLACE_LICENSE_REGISTERED,
+ 'Registered an AWS Marketplace subscription to an account'
+ ),
]
)
}
@@ -321,23 +350,14 @@ export const saveAwsMarketplaceClaimContact: OperationUseCase<
operation: awsMarketplaceOperations.saveClaimContact,
async execute({ principal, input }) {
assertOperationPrincipal(principal, awsMarketplaceOperations.saveClaimContact)
- const productCode = requireProductCode()
- const claimHash = input.claimToken ? hashClaimToken(input.claimToken) : null
- const claimed = requireOpenClaim(
- claimHash ? await findLicenseByClaimHash(claimHash) : null,
- input.licenseId,
- claimHash
- )
+ requireMarketplaceEnabled()
+ const { claimHash, claimed } = await findOpenClaim(input)
const verifiedAt = new Date()
- const summary = await verifyLicense(productCode, claimed.licenseArn)
+ const summary = await verifyLicense(requireLicenseProduct(claimed), claimed.licenseArn)
return db.transaction(async (tx) => {
- const locked = requireOpenClaim(
- await lockLicense(tx, input.licenseId),
- input.licenseId,
- claimHash
- )
+ const locked = await lockOpenClaim(tx, input, claimHash)
const license = await saveLicenseContact(tx, locked, {
- contact: contactOf(input),
+ contact: input,
verification: { summary, verifiedAt },
})
return { status: license.status }
@@ -360,7 +380,7 @@ function toLicenseStatusList(rows: Awaited) {
- if (!getAwsMarketplaceProductCode()) return []
+ if (!isAwsMarketplaceEnabled()) return []
return toLicenseStatusList(await listOrganizationLicenses(context.organizationId))
},
})
@@ -374,7 +394,7 @@ export const listAccountAwsMarketplaceLicenses: OperationUseCase<
operation: awsMarketplaceOperations.listAccountLicenses,
async execute({ principal }) {
assertOperationPrincipal(principal, awsMarketplaceOperations.listAccountLicenses)
- if (!getAwsMarketplaceProductCode()) return []
+ if (!isAwsMarketplaceEnabled()) return []
return toLicenseStatusList(await listAccountLicenses(principal.userId))
},
}
diff --git a/apps/sim/lib/billing/aws-marketplace/checkout-guard.integration.ts b/apps/sim/lib/billing/aws-marketplace/checkout-guard.integration.ts
index 4a7ff2aeaa5..b69b1df157f 100644
--- a/apps/sim/lib/billing/aws-marketplace/checkout-guard.integration.ts
+++ b/apps/sim/lib/billing/aws-marketplace/checkout-guard.integration.ts
@@ -4,15 +4,20 @@ import { generateId } from '@sim/utils/id'
import { inArray } from 'drizzle-orm'
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'
-const PRODUCT = 'product-placeholder'
const ACCOUNT = '000000000000'
const TEAM_PLAN = 'team_6000'
+const RETIRED_PRODUCT = 'retired-product-placeholder'
-const productCode = vi.hoisted(() => ({ current: 'product-placeholder' as string | null }))
+const marketplace = vi.hoisted(() => ({ enabled: true, product: 'product-placeholder' }))
+const PRODUCT = marketplace.product
-vi.mock('@/lib/billing/aws-marketplace/config', () => ({
- getAwsMarketplaceProductCode: () => productCode.current,
-}))
+vi.mock('@/lib/billing/aws-marketplace/config', async () => {
+ const { parseAwsMarketplaceProducts } = await vi.importActual<
+ typeof import('@/lib/billing/aws-marketplace/products')
+ >('@/lib/billing/aws-marketplace/products')
+ const sold = parseAwsMarketplaceProducts(`${marketplace.product}=contract`)
+ return { getAwsMarketplaceProducts: () => (marketplace.enabled ? sold : new Map()) }
+})
import {
assertPersonalCheckoutAllowed,
@@ -61,12 +66,13 @@ async function insertLicense(link: {
organizationId: string | null
linkedByUserId: string
status: LicenseStatus
+ productCode?: string
}) {
const now = new Date()
await db.insert(awsMarketplaceLicense).values({
id: generateId(),
licenseArn: `license-${generateId()}`,
- productCode: PRODUCT,
+ productCode: link.productCode ?? PRODUCT,
customerAwsAccountId: ACCOUNT,
dimension: 'enterprise',
quantity: 25,
@@ -84,7 +90,7 @@ async function organizationWithLicense(status: LicenseStatus | null) {
}
afterEach(() => {
- productCode.current = PRODUCT
+ marketplace.enabled = true
})
afterAll(async () => {
@@ -135,6 +141,21 @@ describe('Stripe checkout for an AWS Marketplace-billed reference', () => {
).resolves.toBe(true)
})
+ it('allows an organization whose only license is on a listing this deployment no longer sells', async () => {
+ const ownerId = await createUser()
+ const organizationId = await createOwnedOrganization(ownerId)
+ await insertLicense({
+ organizationId,
+ linkedByUserId: ownerId,
+ status: 'active',
+ productCode: RETIRED_PRODUCT,
+ })
+
+ await expect(
+ authorizeSubscriptionReference(ownerId, organizationId, 'upgrade-subscription', TEAM_PLAN)
+ ).resolves.toBe(true)
+ })
+
it('allows an organization with no license', async () => {
const { ownerId, organizationId } = await organizationWithLicense(null)
@@ -147,7 +168,7 @@ describe('Stripe checkout for an AWS Marketplace-billed reference', () => {
const { ownerId, organizationId } = await organizationWithLicense('active')
const userId = await createUser()
await insertLicense({ organizationId: null, linkedByUserId: userId, status: 'active' })
- productCode.current = null
+ marketplace.enabled = false
await expect(
authorizeSubscriptionReference(ownerId, organizationId, 'upgrade-subscription', TEAM_PLAN)
diff --git a/apps/sim/lib/billing/aws-marketplace/client.ts b/apps/sim/lib/billing/aws-marketplace/client.ts
index 1e3ec4ee87d..0ff3a712e1a 100644
--- a/apps/sim/lib/billing/aws-marketplace/client.ts
+++ b/apps/sim/lib/billing/aws-marketplace/client.ts
@@ -29,10 +29,10 @@ let queueClient: SQSClient | null = null
/**
* Credentials for the Marketplace and event-queue clients only. On ECS the app's env also carries
* a static IAM user's keys, which the SDK's default chain prefers and which Sim's general AWS
- * clients (`getAwsCredentials()`) sign with. Infra grants ResolveCustomer, GetEntitlements, and the
- * queue permissions to the ECS task role alone, and AWS asks for temporary role credentials here,
- * so whenever the container credential endpoint is present these clients sign as the task role.
- * Elsewhere (local development, tests) they keep the default chain.
+ * clients (`getAwsCredentials()`) sign with. Infra grants ResolveCustomer, GetEntitlements,
+ * BatchMeterUsage, and the queue permissions to the ECS task role alone, and AWS asks for
+ * temporary role credentials here, so whenever the container credential endpoint is present these
+ * clients sign as the task role. Elsewhere (local development, tests) they keep the default chain.
*/
function marketplaceClientConfig() {
const relativeUri = env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI
diff --git a/apps/sim/lib/billing/aws-marketplace/config.ts b/apps/sim/lib/billing/aws-marketplace/config.ts
index 778a029e5b0..0239467d436 100644
--- a/apps/sim/lib/billing/aws-marketplace/config.ts
+++ b/apps/sim/lib/billing/aws-marketplace/config.ts
@@ -1,10 +1,31 @@
+import {
+ type AwsMarketplaceProduct,
+ type AwsMarketplaceProducts,
+ parseAwsMarketplaceProducts,
+} from '@/lib/billing/aws-marketplace/products'
import { env } from '@/lib/core/config/env'
+let products: AwsMarketplaceProducts | undefined
+
/**
- * The SaaS product code, or null when this deployment does not sell through AWS Marketplace:
- * self-hosted installs and every environment but production. Null turns the registration,
- * claim, and reconcile surfaces off.
+ * The products this deployment sells through AWS Marketplace, by product code. Empty on
+ * self-hosted installs and every environment but production, which turns the Marketplace surfaces
+ * off. The container entrypoint refuses to start on a malformed setting.
*/
-export function getAwsMarketplaceProductCode(): string | null {
- return env.AWS_MARKETPLACE_PRODUCT_CODE ?? null
+export function getAwsMarketplaceProducts(): AwsMarketplaceProducts {
+ products ??= env.AWS_MARKETPLACE_PRODUCTS
+ ? parseAwsMarketplaceProducts(env.AWS_MARKETPLACE_PRODUCTS)
+ : new Map()
+ return products
+}
+
+export function isAwsMarketplaceEnabled(): boolean {
+ return getAwsMarketplaceProducts().size > 0
+}
+
+/** The configured product for a code AWS returned or a license row stored; null for any other. */
+export function findAwsMarketplaceProduct(
+ productCode: string | null | undefined
+): AwsMarketplaceProduct | null {
+ return productCode ? (getAwsMarketplaceProducts().get(productCode) ?? null) : null
}
diff --git a/apps/sim/lib/billing/aws-marketplace/container-license.ts b/apps/sim/lib/billing/aws-marketplace/container-license.ts
index 9701df401e5..fa42f646256 100644
--- a/apps/sim/lib/billing/aws-marketplace/container-license.ts
+++ b/apps/sim/lib/billing/aws-marketplace/container-license.ts
@@ -5,6 +5,7 @@ import {
} from '@aws-sdk/client-license-manager'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
+import { randomInt } from '@sim/utils/random'
import { awsRequestHandler } from '@/lib/core/network/proxy-agent.server'
/**
@@ -47,6 +48,19 @@ const ENTERPRISE_ENTITLEMENT = 'enterprise_license'
/** AWS recommends re-checking every 15 minutes to pick up renewals, upgrades, and cancellations. */
const RECHECK_INTERVAL_MS = 15 * 60 * 1000
+/**
+ * Replicas start together, so their re-checks line up; each waits a random part of this before
+ * restarting, so a renewal or lapse never takes every replica down at once.
+ */
+const RESTART_SPREAD_MS = 10 * 60 * 1000
+
+/**
+ * How the buyer's account holds the license. A delegate grant only shows licenses distributed to
+ * other accounts, and a grant still awaiting acceptance, or rejected, disabled, or deleted by the
+ * license administrator, entitles nothing. No status at all is a license the account bought itself.
+ */
+const USABLE_RECEIVED_STATUSES = new Set(['ACTIVE', 'WORKFLOW_COMPLETED'])
+
const ENTERPRISE_FLAGS = ['ENTERPRISE_ENABLED', 'NEXT_PUBLIC_ENTERPRISE_ENABLED'] as const
/** `unknown` is a failed check (network, permissions, throttling), never a verdict on the license. */
@@ -64,14 +78,21 @@ function createClient(): LicenseManagerClient {
})
}
+function isUsableGrant({ ReceivedMetadata: received }: GrantedLicense): boolean {
+ if (received?.GrantType === 'DelegateGrant') return false
+ return !received?.ReceivedStatus || USABLE_RECEIVED_STATUSES.has(received.ReceivedStatus)
+}
+
/**
* An AWS Marketplace license this product's buyer holds is current when License Manager reports it
- * `AVAILABLE`, its issuer is AWS Marketplace, and it carries the Enterprise entitlement. License
- * Manager moves an ended, cancelled, or suspended license out of `AVAILABLE` itself.
+ * `AVAILABLE`, its issuer is AWS Marketplace, it carries the Enterprise entitlement, and the account
+ * holds it through a grant it may use. License Manager moves an ended, cancelled, or suspended
+ * license out of `AVAILABLE` itself.
*/
function isActiveEnterpriseLicense(license: GrantedLicense): boolean {
return (
license.Status === 'AVAILABLE' &&
+ isUsableGrant(license) &&
license.Issuer?.KeyFingerprint === MARKETPLACE_KEY_FINGERPRINT &&
(license.Entitlements ?? []).some((entitlement) => entitlement.Name === ENTERPRISE_ENTITLEMENT)
)
@@ -133,9 +154,13 @@ export async function applyAwsMarketplaceContainerLicense(): Promise {
const state = await checkLicense(client, productSku)
const entitledNow = state === 'entitled'
if (state === 'unknown' || entitledNow === enterpriseAtBoot) return
- logger.info('AWS Marketplace Enterprise license changed; restarting to apply it', { state })
clearInterval(recheck)
- process.kill(process.pid, 'SIGTERM')
+ const delayMs = randomInt(0, RESTART_SPREAD_MS)
+ logger.info('AWS Marketplace Enterprise license changed; restarting to apply it', {
+ state,
+ delayMs,
+ })
+ setTimeout(() => process.kill(process.pid, 'SIGTERM'), delayMs)
}, RECHECK_INTERVAL_MS)
recheck.unref()
}
diff --git a/apps/sim/lib/billing/aws-marketplace/entitlements.test.ts b/apps/sim/lib/billing/aws-marketplace/entitlements.test.ts
index 56088443a8c..eba529d05da 100644
--- a/apps/sim/lib/billing/aws-marketplace/entitlements.test.ts
+++ b/apps/sim/lib/billing/aws-marketplace/entitlements.test.ts
@@ -13,23 +13,42 @@ vi.mock('@aws-sdk/client-marketplace-entitlement-service', () => ({
}))
import { verifyLicense } from '@/lib/billing/aws-marketplace/entitlements'
+import {
+ type AwsMarketplaceProduct,
+ parseAwsMarketplaceProducts,
+} from '@/lib/billing/aws-marketplace/products'
+
+const PRODUCTS = parseAwsMarketplaceProducts(
+ 'product-placeholder=contract,usage-product-placeholder=platform-with-usage'
+)
+
+function product(code: string): AwsMarketplaceProduct {
+ const found = PRODUCTS.get(code)
+ if (!found) throw new Error(`no test product ${code}`)
+ return found
+}
-const PRODUCT = 'product-placeholder'
+const CONTRACT = product('product-placeholder')
+const USAGE = product('usage-product-placeholder')
const LICENSE = 'license-placeholder'
const NOW = new Date('2026-06-01T00:00:00.000Z')
function entitlement(overrides: Partial = {}): Entitlement {
return {
- ProductCode: PRODUCT,
+ ProductCode: CONTRACT.productCode,
Dimension: 'enterprise',
Value: { IntegerValue: 25 },
...overrides,
}
}
-function read(...entitlements: Entitlement[]) {
+function readAs(product: AwsMarketplaceProduct, ...entitlements: Entitlement[]) {
mockSend.mockResolvedValueOnce({ Entitlements: entitlements })
- return verifyLicense(PRODUCT, LICENSE)
+ return verifyLicense(product, LICENSE)
+}
+
+function read(...entitlements: Entitlement[]) {
+ return readAs(CONTRACT, ...entitlements)
}
describe('verifyLicense', () => {
@@ -47,7 +66,7 @@ describe('verifyLicense', () => {
const summary = await read(
entitlement({ ExpirationDate: new Date('2026-05-31T23:59:59.000Z') })
)
- expect(summary).toMatchObject({ active: false, entitlementCount: 1, quantity: 25 })
+ expect(summary).toMatchObject({ active: false, accessReported: true, quantity: 25 })
})
it('reads an entitlement with a future expiry, or none at all, as active', async () => {
@@ -85,6 +104,34 @@ describe('verifyLicense', () => {
expect(summary).toMatchObject({ active: true, dimension: 'b-current', quantity: 50 })
})
+ it('reads a usage entitlement alone as no answer about access, not as access', async () => {
+ const summary = await readAs(USAGE, entitlement({ Dimension: 'llm_credits' }))
+ expect(summary).toMatchObject({
+ active: false,
+ licenseSeen: true,
+ accessReported: false,
+ dimension: null,
+ })
+ })
+
+ it('reads a lapsed platform as inactive even while usage is current', async () => {
+ const summary = await readAs(
+ USAGE,
+ entitlement({ Dimension: 'llm_credits' }),
+ entitlement({ Dimension: 'platform', ExpirationDate: new Date('2026-01-01T00:00:00.000Z') })
+ )
+ expect(summary).toMatchObject({ active: false, accessReported: true, dimension: 'platform' })
+ })
+
+ it('reads a current platform as active and describes it, not the usage dimension', async () => {
+ const summary = await readAs(
+ USAGE,
+ entitlement({ Dimension: 'llm_credits', Value: { IntegerValue: 7 } }),
+ entitlement({ Dimension: 'platform', Value: { IntegerValue: 1 } })
+ )
+ expect(summary).toMatchObject({ active: true, dimension: 'platform', quantity: 1 })
+ })
+
it('reads quantity only from an integer value, never from another value kind', async () => {
expect((await read(entitlement({ Value: { DoubleValue: 1200.5 } }))).quantity).toBeNull()
})
@@ -95,9 +142,9 @@ describe('verifyLicense', () => {
.mockResolvedValueOnce({ Entitlements: [], NextToken: 't2' })
.mockResolvedValueOnce({ Entitlements: [entitlement({ Dimension: 'two' })] })
- const summary = await verifyLicense(PRODUCT, LICENSE)
+ const summary = await verifyLicense(CONTRACT, LICENSE)
- expect(summary).toMatchObject({ entitlementCount: 2, dimension: 'one' })
+ expect(summary).toMatchObject({ accessReported: true, dimension: 'one' })
expect(mockSend.mock.calls.map(([command]) => command.input.NextToken)).toEqual([
undefined,
't1',
diff --git a/apps/sim/lib/billing/aws-marketplace/entitlements.ts b/apps/sim/lib/billing/aws-marketplace/entitlements.ts
index 0b8ef4e454c..47c5dbb085b 100644
--- a/apps/sim/lib/billing/aws-marketplace/entitlements.ts
+++ b/apps/sim/lib/billing/aws-marketplace/entitlements.ts
@@ -4,16 +4,26 @@ import {
} from '@aws-sdk/client-marketplace-entitlement-service'
import { compareStrings } from '@sim/utils/string'
import { getEntitlementClient } from '@/lib/billing/aws-marketplace/client'
+import type { AwsMarketplaceProduct } from '@/lib/billing/aws-marketplace/products'
/** A license carries one entitlement per purchased dimension; this bounds a runaway `NextToken`. */
const MAX_PAGES = 20
/** What the last Entitlement Service read says about one license. */
export interface EntitlementSummary {
- /** At least one entitlement has no expiry or expires in the future. */
+ /**
+ * The product's required dimension (any dimension, for a plan with none) has an entitlement with
+ * no expiry or one in the future.
+ */
active: boolean
- /** Entitlements AWS returned, current or lapsed; zero is ambiguous, see the license's status. */
- entitlementCount: number
+ /** AWS returned an entitlement of any dimension: the license exists and names its account. */
+ licenseSeen: boolean
+ /**
+ * AWS returned an entitlement, current or lapsed, for the dimension that grants access (any
+ * dimension, for a plan with none). A usage dimension alone is not an answer about access, and
+ * no answer is ambiguous: see the license's status.
+ */
+ accessReported: boolean
dimension: string | null
quantity: number | null
expiresAt: Date | null
@@ -25,15 +35,20 @@ function isCurrent(entitlement: Entitlement, now: Date): boolean {
}
/**
- * Reduces a license's entitlements to the one the row records. A current entitlement wins, the
- * lowest dimension name first so repeated reads agree; with none current, the latest-expiring one
- * still describes what lapsed. Quantity is read only from an integer value: a contract's dollar
- * amount is never inferred from it.
+ * Reduces a license's entitlements to the one the row records. Only the plan's required dimension
+ * counts, when it names one; usage dimensions on the same license never grant access. A current
+ * entitlement wins, the lowest dimension name first so repeated reads agree; with none current,
+ * the latest-expiring one still describes what lapsed. Quantity is read only from an integer
+ * value: a contract's dollar amount is never inferred from it.
*/
function summarizeEntitlements(
- entitlements: readonly Entitlement[],
+ allEntitlements: readonly Entitlement[],
+ requiredDimension: string | null,
now: Date
): EntitlementSummary {
+ const entitlements = requiredDimension
+ ? allEntitlements.filter((entitlement) => entitlement.Dimension === requiredDimension)
+ : allEntitlements
const current = entitlements
.filter((entitlement) => isCurrent(entitlement, now))
.sort((left, right) => compareStrings(left.Dimension ?? '', right.Dimension ?? ''))
@@ -45,11 +60,13 @@ function summarizeEntitlements(
const chosen = current[0] ?? lapsed[0]
return {
active: current.length > 0,
- entitlementCount: entitlements.length,
+ licenseSeen: allEntitlements.length > 0,
+ accessReported: entitlements.length > 0,
dimension: chosen?.Dimension ?? null,
quantity: chosen?.Value?.IntegerValue ?? null,
expiresAt: chosen?.ExpirationDate ?? null,
- customerAwsAccountId: chosen?.CustomerAWSAccountId ?? null,
+ customerAwsAccountId:
+ chosen?.CustomerAWSAccountId ?? allEntitlements[0]?.CustomerAWSAccountId ?? null,
}
}
@@ -87,10 +104,10 @@ async function fetchLicenseEntitlements(
/** Reads and summarizes one license's entitlements as of now; `signal` bounds the whole read. */
export async function verifyLicense(
- productCode: string,
+ product: AwsMarketplaceProduct,
licenseArn: string,
signal?: AbortSignal
): Promise {
- const entitlements = await fetchLicenseEntitlements(productCode, licenseArn, signal)
- return summarizeEntitlements(entitlements, new Date())
+ const entitlements = await fetchLicenseEntitlements(product.productCode, licenseArn, signal)
+ return summarizeEntitlements(entitlements, product.plan.requiredDimension, new Date())
}
diff --git a/apps/sim/lib/billing/aws-marketplace/lifecycle.integration.ts b/apps/sim/lib/billing/aws-marketplace/lifecycle.integration.ts
index db0d1d85e7c..a10924df7dd 100644
--- a/apps/sim/lib/billing/aws-marketplace/lifecycle.integration.ts
+++ b/apps/sim/lib/billing/aws-marketplace/lifecycle.integration.ts
@@ -6,6 +6,7 @@ import {
member,
organization,
outboxEvent,
+ subscription,
user,
} from '@sim/db/schema'
import { createSessionPrincipal } from '@sim/testing/factories/principal.factory'
@@ -17,13 +18,16 @@ import { NextRequest } from 'next/server'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
const PRODUCT = 'product-placeholder'
+/** A second listing, sold as platform access plus metered usage. */
+const USAGE_PRODUCT = 'usage-product-placeholder'
/** New SaaS products: ResolveCustomer names the buyer by account ID, never `CustomerIdentifier`. */
const ACCOUNT = '000000000000'
const AGREEMENT = 'agmt-placeholder'
const CONTACT = { contactEmail: 'buyer@example.test', companyName: 'Example Co' }
const { meteringSend, entitlementSend, queueSend } = vi.hoisted(() => {
- process.env.AWS_MARKETPLACE_PRODUCT_CODE = 'product-placeholder'
+ process.env.AWS_MARKETPLACE_PRODUCTS =
+ 'product-placeholder=contract,usage-product-placeholder=platform-with-usage'
process.env.AWS_MARKETPLACE_EVENTS_QUEUE_URL =
'https://queue.example.test/aws-marketplace-placeholder'
process.env.ADMIN_API_KEY = 'admin-key-placeholder'
@@ -300,13 +304,18 @@ function deliverOnce(events: Array>) {
return deleted
}
-function licenseUpdated(licenseArn: string, agreementId?: string, time?: Date) {
+function licenseUpdated(
+ licenseArn: string,
+ agreementId?: string,
+ time?: Date,
+ productCode = PRODUCT
+) {
return {
'detail-type': 'License Updated - Manufacturer',
...(time && { time: time.toISOString() }),
detail: {
license: { arn: licenseArn },
- product: { code: PRODUCT },
+ product: { code: productCode },
...(agreementId && { agreement: { id: agreementId } }),
},
}
@@ -344,6 +353,7 @@ afterAll(async () => {
.delete(awsMarketplaceLicense)
.where(inArray(awsMarketplaceLicense.licenseArn, licenseArns))
if (organizationIds.length) {
+ await db.delete(subscription).where(inArray(subscription.referenceId, organizationIds))
await db.delete(organization).where(inArray(organization.id, organizationIds))
}
if (userIds.length) {
@@ -390,6 +400,39 @@ describe('POST /api/marketplace/aws/register', () => {
expect(meteringSend.mock.calls[0][0].input.RegistrationToken).toBe('opaque+token')
})
+ it('admits a buyer of the usage listing only on a current platform entitlement', async () => {
+ const usageOnly = uniqueArn()
+ resolvesTo(usageOnly, USAGE_PRODUCT)
+ entitlementsByLicense.set(usageOnly, [
+ currentEntitlement({ ProductCode: USAGE_PRODUCT, Dimension: 'llm_credits' }),
+ ])
+
+ const unconfirmed = await registration('x-amzn-marketplace-token=usage-only-token')
+
+ expect(claimTokenOf(unconfirmed)).not.toBeNull()
+ expect(entitlementSend).toHaveBeenCalledTimes(3)
+ expect(await readLicense(usageOnly)).toMatchObject({
+ productCode: USAGE_PRODUCT,
+ status: 'pending',
+ })
+
+ const withPlatform = uniqueArn()
+ resolvesTo(withPlatform, USAGE_PRODUCT)
+ entitlementsByLicense.set(withPlatform, [
+ currentEntitlement({ ProductCode: USAGE_PRODUCT, Dimension: 'llm_credits' }),
+ currentEntitlement({ ProductCode: USAGE_PRODUCT, Dimension: 'platform' }),
+ ])
+
+ const admitted = await registration('x-amzn-marketplace-token=platform-token')
+
+ expect(claimTokenOf(admitted)).not.toBeNull()
+ expect(await readLicense(withPlatform)).toMatchObject({
+ productCode: USAGE_PRODUCT,
+ status: 'active',
+ dimension: 'platform',
+ })
+ })
+
it('records a lapsed license as inactive and issues no claim', async () => {
const licenseArn = uniqueArn()
resolvesTo(licenseArn)
@@ -702,6 +745,28 @@ describe('linking a claimed license', () => {
})
})
+ it('starts the license usage at the first link and keeps it when the same organization links again', async () => {
+ const fresh = await createClaimedLicense()
+ entitlementsByLicense.set(fresh.licenseArn, [currentEntitlement()])
+ const before = Date.now()
+
+ await link(ownerId, { licenseId: fresh.id, claimToken: fresh.token })
+
+ const linkedAt = (await readLicense(fresh.licenseArn))?.organizationLinkedAt
+ expect(linkedAt?.getTime()).toBeGreaterThanOrEqual(before - 1000)
+
+ const firstLinkedAt = new Date(Date.now() - 5 * 60 * 60 * 1000)
+ const relinked = await createClaimedLicense({
+ organizationId,
+ organizationLinkedAt: firstLinkedAt,
+ })
+ entitlementsByLicense.set(relinked.licenseArn, [currentEntitlement()])
+
+ await link(ownerId, { licenseId: relinked.id, claimToken: relinked.token })
+
+ expect((await readLicense(relinked.licenseArn))?.organizationLinkedAt).toEqual(firstLinkedAt)
+ })
+
it('refuses to move a license already linked to another organization', async () => {
const otherOwnerId = await createUser()
const otherOrganizationId = await createOrganization([{ userId: otherOwnerId, role: 'owner' }])
@@ -716,6 +781,72 @@ describe('linking a claimed license', () => {
expect((await readLicense(license.licenseArn))?.organizationId).toBe(otherOrganizationId)
})
+ it('refuses an organization with a live Stripe self-serve subscription, but not Enterprise or an abandoned checkout', async () => {
+ async function organizationWith(sub: { plan: string; status: string; bound: boolean }) {
+ const planOwnerId = await createUser()
+ const id = await createOrganization([{ userId: planOwnerId, role: 'owner' }])
+ await db.insert(subscription).values({
+ id: generateId(),
+ plan: sub.plan,
+ referenceId: id,
+ status: sub.status,
+ stripeSubscriptionId: sub.bound ? `sub_${generateId()}` : null,
+ metadata: { plan: sub.plan, referenceId: id, seats: 10, monthlyPrice: 100 },
+ })
+ return { id, planOwnerId }
+ }
+ async function linkTo(organization: { id: string; planOwnerId: string }) {
+ const license = await createClaimedLicense()
+ entitlementsByLicense.set(license.licenseArn, [currentEntitlement()])
+ const refusal = await linkAwsMarketplaceLicense
+ .execute({
+ principal: createSessionPrincipal({ userId: organization.planOwnerId }),
+ input: {
+ ...CONTACT,
+ organizationId: organization.id,
+ licenseId: license.id,
+ claimToken: license.token,
+ },
+ })
+ .then(
+ () => null,
+ (error: unknown) => error
+ )
+ return { license, refusal }
+ }
+
+ const trial = await linkTo(
+ await organizationWith({ plan: 'team_6000', status: 'trialing', bound: true })
+ )
+ const onEnterprise = await organizationWith({
+ plan: 'enterprise',
+ status: 'active',
+ bound: true,
+ })
+ const enterprise = await linkTo(onEnterprise)
+ const abandoned = await organizationWith({
+ plan: 'team_6000',
+ status: 'incomplete',
+ bound: false,
+ })
+ const afterAbandoned = await linkTo(abandoned)
+
+ expect(trial.refusal).toMatchObject({
+ code: 'conflict',
+ message: expect.stringMatching(/already pays/),
+ })
+ expect(await readLicense(trial.license.licenseArn)).toMatchObject({
+ organizationId: null,
+ claimTokenHash: hashClaimToken(trial.license.token),
+ })
+ expect(enterprise.refusal).toBeNull()
+ expect(afterAbandoned.refusal).toBeNull()
+ expect((await readLicense(enterprise.license.licenseArn))?.organizationId).toBe(onEnterprise.id)
+ expect((await readLicense(afterAbandoned.license.licenseArn))?.organizationId).toBe(
+ abandoned.id
+ )
+ })
+
it('links under a newer active read without overwriting it with its own older one', async () => {
const license = await createClaimedLicense({ lastVerifiedAt: new Date(Date.now() + 60_000) })
entitlementsByLicense.set(license.licenseArn, [
@@ -1382,6 +1513,57 @@ describe('reconcileAwsMarketplaceLicenses', () => {
})
})
+describe('license events across listings', () => {
+ it('records a usage listing license seen first by event as pending until AWS reports platform', async () => {
+ const licenseArn = uniqueArn()
+ entitlementsByLicense.set(licenseArn, [
+ currentEntitlement({ ProductCode: USAGE_PRODUCT, Dimension: 'llm_credits' }),
+ ])
+ const deleted = deliverOnce([licenseUpdated(licenseArn, undefined, undefined, USAGE_PRODUCT)])
+
+ expect(await reconcileAwsMarketplaceLicenses()).toMatchObject({ applied: 1, discarded: 0 })
+ expect(deleted).toEqual(['r-0'])
+ expect(await readLicense(licenseArn)).toMatchObject({
+ productCode: USAGE_PRODUCT,
+ status: 'pending',
+ dimension: null,
+ })
+ expect(await emailsTo(TEAM)).toEqual([
+ expect.objectContaining({ text: expect.stringMatching(/Unconfirmed/) }),
+ ])
+ })
+
+ it("applies a usage listing's event under its own plan: a lapsed platform ends access", async () => {
+ const { licenseArn } = await insertLicense({ productCode: USAGE_PRODUCT, status: 'active' })
+ entitlementsByLicense.set(licenseArn, [
+ currentEntitlement({ ProductCode: USAGE_PRODUCT, Dimension: 'llm_credits' }),
+ currentEntitlement({
+ ProductCode: USAGE_PRODUCT,
+ Dimension: 'platform',
+ ExpirationDate: new Date(Date.now() - 60_000),
+ }),
+ ])
+ const deleted = deliverOnce([licenseUpdated(licenseArn, undefined, undefined, USAGE_PRODUCT)])
+
+ expect(await reconcileAwsMarketplaceLicenses()).toMatchObject({ applied: 1, discarded: 0 })
+ expect(deleted).toEqual(['r-0'])
+ const lapsed = await readLicense(licenseArn)
+ expect(lapsed).toMatchObject({ status: 'inactive', dimension: 'platform' })
+ expect(lapsed?.endedAt).toBeInstanceOf(Date)
+
+ deliverOnce([licenseUpdated(licenseArn, undefined, undefined, USAGE_PRODUCT)])
+ await reconcileAwsMarketplaceLicenses()
+ expect((await readLicense(licenseArn))?.endedAt).toEqual(lapsed?.endedAt)
+
+ entitlementsByLicense.set(licenseArn, [
+ currentEntitlement({ ProductCode: USAGE_PRODUCT, Dimension: 'platform' }),
+ ])
+ deliverOnce([licenseUpdated(licenseArn, undefined, undefined, USAGE_PRODUCT)])
+ await reconcileAwsMarketplaceLicenses()
+ expect(await readLicense(licenseArn)).toMatchObject({ status: 'active', endedAt: null })
+ })
+})
+
describe('agreement events', () => {
it('re-verifies the license of an ended agreement, from an event that names no product', async () => {
const agreementId = uniqueAgreement()
diff --git a/apps/sim/lib/billing/aws-marketplace/metering.integration.ts b/apps/sim/lib/billing/aws-marketplace/metering.integration.ts
new file mode 100644
index 00000000000..30d7ecf63d4
--- /dev/null
+++ b/apps/sim/lib/billing/aws-marketplace/metering.integration.ts
@@ -0,0 +1,554 @@
+import { db } from '@sim/db'
+import {
+ awsMarketplaceLicense,
+ awsMarketplaceUsageReport,
+ awsMarketplaceUsageTally,
+ organization,
+ usageLog,
+ user,
+} from '@sim/db/schema'
+import { generateId } from '@sim/utils/id'
+import { asc, eq, inArray } from 'drizzle-orm'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const CONTRACT_PRODUCT = 'product-placeholder'
+const USAGE_PRODUCT = 'usage-product-placeholder'
+const ACCOUNT = '000000000000'
+const HOUR_MS = 60 * 60 * 1000
+const DAY_MS = 24 * HOUR_MS
+
+interface SentRecord {
+ LicenseArn?: string
+ CustomerAWSAccountId?: string
+ Dimension?: string
+ Quantity?: number
+ Timestamp?: Date
+}
+
+const { meteringSend } = vi.hoisted(() => {
+ process.env.AWS_MARKETPLACE_PRODUCTS =
+ 'product-placeholder=contract,usage-product-placeholder=platform-with-usage'
+ return { meteringSend: vi.fn() }
+})
+
+vi.mock('@aws-sdk/client-marketplace-metering', () => ({
+ MarketplaceMeteringClient: class {
+ send = meteringSend
+ },
+ BatchMeterUsageCommand: class {
+ constructor(public input: { UsageRecords: SentRecord[]; ProductCode?: string }) {}
+ },
+}))
+
+import { meterAwsMarketplaceUsage } from '@/lib/billing/aws-marketplace/metering'
+
+/** Twenty minutes into an hour. */
+const NOW = Math.floor(Date.now() / HOUR_MS) * HOUR_MS + 20 * 60 * 1000
+const floorHour = (time: number) => new Date(Math.floor(time / HOUR_MS) * HOUR_MS)
+const HOUR = floorHour(NOW)
+const NEXT_HOUR = NOW + HOUR_MS
+const minutesAgo = (minutes: number) => new Date(NOW - minutes * 60_000)
+/** The billing period every fixture's usage falls in, unless a test says otherwise. */
+const PERIOD = { start: new Date(NOW - 10 * DAY_MS), end: new Date(NOW + 20 * DAY_MS) }
+
+const userIds: string[] = []
+const organizationIds: string[] = []
+const licenseIds: string[] = []
+
+/** Every record sent for the license, with the request's top-level product code alongside. */
+function sentRecords(licenseArn: string): Array {
+ return meteringSend.mock.calls.flatMap(([command]) =>
+ command.input.UsageRecords.filter((record: SentRecord) => record.LicenseArn === licenseArn).map(
+ (record: SentRecord) => ({ ...record, productCode: command.input.ProductCode })
+ )
+ )
+}
+
+/** AWS accepts every record, echoing it back with a metering record ID. */
+function acceptEverything() {
+ meteringSend.mockImplementation(async (command: { input: { UsageRecords: SentRecord[] } }) => ({
+ Results: command.input.UsageRecords.map((record, index) => ({
+ UsageRecord: record,
+ MeteringRecordId: `record-${index}`,
+ Status: 'Success',
+ })),
+ UnprocessedRecords: [],
+ }))
+}
+
+async function createOrganization(): Promise<{ organizationId: string; userId: string }> {
+ const userId = generateId()
+ userIds.push(userId)
+ await db.insert(user).values({
+ id: userId,
+ name: 'Metering fixture',
+ email: `${userId}@example.test`,
+ emailVerified: true,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+ const organizationId = generateId()
+ organizationIds.push(organizationId)
+ await db.insert(organization).values({
+ id: organizationId,
+ name: 'Metered org',
+ slug: organizationId,
+ createdAt: new Date(),
+ })
+ return { organizationId, userId }
+}
+
+async function insertLicense(
+ organizationId: string,
+ overrides: Partial = {}
+) {
+ const id = generateId()
+ licenseIds.push(id)
+ const licenseArn = `arn:aws:license-manager::000000000000:license:l-${id}`
+ await db.insert(awsMarketplaceLicense).values({
+ id,
+ licenseArn,
+ productCode: USAGE_PRODUCT,
+ customerAwsAccountId: ACCOUNT,
+ status: 'active',
+ dimension: 'platform',
+ organizationId,
+ organizationLinkedAt: new Date(NOW - 5 * HOUR_MS),
+ lastVerifiedAt: new Date(),
+ lastVerificationAttemptAt: new Date(),
+ ...overrides,
+ })
+ return { id, licenseArn }
+}
+
+async function logUsage(
+ owner: { organizationId: string; userId: string },
+ input: {
+ at: Date
+ cost: number
+ category?: 'model' | 'model_unbilled' | 'tool'
+ period?: { start: Date; end: Date }
+ }
+): Promise {
+ const id = generateId()
+ const period = input.period ?? PERIOD
+ await db.insert(usageLog).values({
+ id,
+ userId: owner.userId,
+ category: input.category ?? 'model',
+ source: 'workflow',
+ description: 'metering fixture',
+ cost: input.cost.toString(),
+ billingEntityType: 'organization',
+ billingEntityId: owner.organizationId,
+ billingPeriodStart: period.start,
+ billingPeriodEnd: period.end,
+ createdAt: input.at,
+ })
+ return id
+}
+
+async function reportsFor(organizationId: string) {
+ return db
+ .select()
+ .from(awsMarketplaceUsageReport)
+ .where(eq(awsMarketplaceUsageReport.organizationId, organizationId))
+ .orderBy(asc(awsMarketplaceUsageReport.hourStart))
+}
+
+/** Charges the usage now, then sends it once its hour has closed. */
+async function chargeAndSend() {
+ await meterAwsMarketplaceUsage(NOW)
+ await meterAwsMarketplaceUsage(NEXT_HOUR)
+}
+
+beforeEach(() => {
+ meteringSend.mockReset()
+ acceptEverything()
+})
+
+afterEach(async () => {
+ if (licenseIds.length) {
+ await db
+ .delete(awsMarketplaceLicense)
+ .where(inArray(awsMarketplaceLicense.id, licenseIds.splice(0)))
+ }
+ if (organizationIds.length) {
+ await db.delete(organization).where(inArray(organization.id, organizationIds.splice(0)))
+ }
+ if (userIds.length) await db.delete(user).where(inArray(user.id, userIds.splice(0)))
+})
+
+describe('AWS Marketplace usage metering', () => {
+ it('charges whole credits during the hour, carrying fractions, and sends once the hour closes', async () => {
+ const owner = await createOrganization()
+ const { licenseArn } = await insertLicense(owner.organizationId)
+ await logUsage(owner, { at: minutesAgo(30), cost: 0.015 })
+ await logUsage(owner, { at: minutesAgo(10), cost: 0.01 })
+
+ await meterAwsMarketplaceUsage(NOW)
+ await logUsage(owner, { at: minutesAgo(1), cost: 0.006 })
+ await meterAwsMarketplaceUsage(NOW + 5 * 60_000)
+
+ expect(sentRecords(licenseArn)).toEqual([])
+ expect(await reportsFor(owner.organizationId)).toEqual([
+ expect.objectContaining({ hourStart: HOUR, quantity: 3, status: 'pending' }),
+ ])
+
+ await meterAwsMarketplaceUsage(NEXT_HOUR)
+
+ expect(sentRecords(licenseArn)).toEqual([
+ {
+ LicenseArn: licenseArn,
+ CustomerAWSAccountId: ACCOUNT,
+ Dimension: 'llm_credits',
+ Quantity: 3,
+ Timestamp: HOUR,
+ productCode: undefined,
+ },
+ ])
+ expect(await reportsFor(owner.organizationId)).toEqual([
+ expect.objectContaining({ status: 'reported', meteringRecordId: 'record-0' }),
+ ])
+ })
+
+ it('charges cost raised in place after it was first counted', async () => {
+ const owner = await createOrganization()
+ await insertLicense(owner.organizationId)
+ const usageId = await logUsage(owner, { at: minutesAgo(30), cost: 0.02 })
+ await chargeAndSend()
+
+ await db.update(usageLog).set({ cost: '0.05' }).where(eq(usageLog.id, usageId))
+ await meterAwsMarketplaceUsage(NEXT_HOUR + 5 * 60_000)
+
+ expect(await reportsFor(owner.organizationId)).toEqual([
+ expect.objectContaining({ hourStart: HOUR, quantity: 2, status: 'reported' }),
+ expect.objectContaining({ hourStart: floorHour(NEXT_HOUR), quantity: 3, status: 'pending' }),
+ ])
+ })
+
+ it('never charges bring-your-own-key or tool usage, or usage from before the link', async () => {
+ const owner = await createOrganization()
+ await insertLicense(owner.organizationId, { organizationLinkedAt: minutesAgo(30) })
+ await logUsage(owner, { at: minutesAgo(40), cost: 5 })
+ await logUsage(owner, { at: minutesAgo(20), cost: 7, category: 'model_unbilled' })
+ await logUsage(owner, { at: minutesAgo(15), cost: 9, category: 'tool' })
+ await logUsage(owner, { at: minutesAgo(10), cost: 0.03 })
+
+ await meterAwsMarketplaceUsage(NOW)
+
+ expect(await reportsFor(owner.organizationId)).toEqual([
+ expect.objectContaining({ quantity: 3 }),
+ ])
+ })
+
+ it('charges usage once however often it runs', async () => {
+ const owner = await createOrganization()
+ await insertLicense(owner.organizationId)
+ await logUsage(owner, { at: minutesAgo(10), cost: 0.5 })
+
+ await meterAwsMarketplaceUsage(NOW)
+ await meterAwsMarketplaceUsage(NOW + 60_000)
+ await meterAwsMarketplaceUsage(NOW + 2 * 60_000)
+
+ expect(await reportsFor(owner.organizationId)).toEqual([
+ expect.objectContaining({ quantity: 50 }),
+ ])
+ })
+
+ it('charges the earliest-linked license in force, skipping one past its expiry', async () => {
+ const owner = await createOrganization()
+ const expired = await insertLicense(owner.organizationId, {
+ organizationLinkedAt: new Date(NOW - 9 * HOUR_MS),
+ expiresAt: minutesAgo(5),
+ })
+ const earlier = await insertLicense(owner.organizationId, {
+ organizationLinkedAt: new Date(NOW - 8 * HOUR_MS),
+ })
+ const later = await insertLicense(owner.organizationId, {
+ organizationLinkedAt: new Date(NOW - 7 * HOUR_MS),
+ })
+ await logUsage(owner, { at: minutesAgo(10), cost: 0.07 })
+
+ await chargeAndSend()
+
+ expect(sentRecords(earlier.licenseArn)).toEqual([expect.objectContaining({ Quantity: 7 })])
+ expect(sentRecords(expired.licenseArn)).toEqual([])
+ expect(sentRecords(later.licenseArn)).toEqual([])
+ })
+
+ it('meters nothing for a contract-only listing', async () => {
+ const owner = await createOrganization()
+ await insertLicense(owner.organizationId, { productCode: CONTRACT_PRODUCT })
+ await logUsage(owner, { at: minutesAgo(10), cost: 4 })
+
+ await chargeAndSend()
+
+ expect(await reportsFor(owner.organizationId)).toEqual([])
+ })
+
+ it('flushes an ended license once, at the hour it ended, even with no expiry from AWS', async () => {
+ const owner = await createOrganization()
+ const endedAt = minutesAgo(10)
+ const { licenseArn } = await insertLicense(owner.organizationId, {
+ status: 'inactive',
+ expiresAt: null,
+ endedAt,
+ })
+ await logUsage(owner, { at: minutesAgo(15), cost: 0.02 })
+
+ await meterAwsMarketplaceUsage(NOW)
+
+ expect(sentRecords(licenseArn)).toEqual([
+ expect.objectContaining({ Quantity: 2, Timestamp: floorHour(endedAt.getTime()) }),
+ ])
+
+ await logUsage(owner, { at: minutesAgo(1), cost: 3 })
+ await meterAwsMarketplaceUsage(NOW + 5 * 60_000)
+
+ expect(sentRecords(licenseArn)).toHaveLength(1)
+ expect(await reportsFor(owner.organizationId)).toEqual([
+ expect.objectContaining({ quantity: 2, status: 'reported' }),
+ ])
+ })
+
+ it('starts billing a linked license with no link time from the first run that sees it', async () => {
+ const owner = await createOrganization()
+ const { id } = await insertLicense(owner.organizationId, { organizationLinkedAt: null })
+ await logUsage(owner, { at: minutesAgo(10), cost: 5 })
+
+ await meterAwsMarketplaceUsage(NOW)
+ const [stamped] = await db
+ .select({ at: awsMarketplaceLicense.organizationLinkedAt })
+ .from(awsMarketplaceLicense)
+ .where(eq(awsMarketplaceLicense.id, id))
+ await logUsage(owner, { at: new Date(NOW + 1000), cost: 0.04 })
+ await meterAwsMarketplaceUsage(NOW + 60_000)
+
+ expect(stamped?.at).toEqual(new Date(NOW))
+ expect((await reportsFor(owner.organizationId)).map((row) => row.quantity)).toEqual([4])
+ })
+
+ it('keeps counting a period while a long run can still raise its rows, and leaves a settled one alone', async () => {
+ const owner = await createOrganization()
+ await insertLicense(owner.organizationId, { organizationLinkedAt: new Date(NOW - 70 * DAY_MS) })
+ const justEnded = { start: new Date(NOW - 31 * DAY_MS), end: new Date(NOW - 3 * DAY_MS) }
+ const longEnded = { start: new Date(NOW - 62 * DAY_MS), end: new Date(NOW - 31 * DAY_MS) }
+ await logUsage(owner, { at: new Date(NOW - 4 * DAY_MS), cost: 0.04, period: justEnded })
+ await logUsage(owner, { at: new Date(NOW - 35 * DAY_MS), cost: 9, period: longEnded })
+
+ await meterAwsMarketplaceUsage(NOW)
+
+ expect((await reportsFor(owner.organizationId)).map((row) => row.quantity)).toEqual([4])
+ const tallies = await db
+ .select({ periodStart: awsMarketplaceUsageTally.periodStart })
+ .from(awsMarketplaceUsageTally)
+ .where(eq(awsMarketplaceUsageTally.organizationId, owner.organizationId))
+ expect(tallies).toEqual([{ periodStart: justEnded.start }])
+ })
+
+ it('keeps unprocessed records for the next run and rejects records for an unsubscribed customer', async () => {
+ const retried = await createOrganization()
+ const refused = await createOrganization()
+ const retriedLicense = await insertLicense(retried.organizationId)
+ const refusedLicense = await insertLicense(refused.organizationId)
+ await logUsage(retried, { at: minutesAgo(10), cost: 0.01 })
+ await logUsage(refused, { at: minutesAgo(10), cost: 0.01 })
+ await meterAwsMarketplaceUsage(NOW)
+ meteringSend.mockImplementation(async (command: { input: { UsageRecords: SentRecord[] } }) => ({
+ Results: command.input.UsageRecords.filter(
+ (record) => record.LicenseArn === refusedLicense.licenseArn
+ ).map((record) => ({ UsageRecord: record, Status: 'CustomerNotSubscribed' })),
+ UnprocessedRecords: command.input.UsageRecords.filter(
+ (record) => record.LicenseArn === retriedLicense.licenseArn
+ ),
+ }))
+
+ await meterAwsMarketplaceUsage(NEXT_HOUR)
+
+ expect(await reportsFor(retried.organizationId)).toEqual([
+ expect.objectContaining({ status: 'pending' }),
+ ])
+ expect(await reportsFor(refused.organizationId)).toEqual([
+ expect.objectContaining({ status: 'rejected', error: 'CustomerNotSubscribed' }),
+ ])
+
+ acceptEverything()
+ await meterAwsMarketplaceUsage(NEXT_HOUR + 5 * 60_000)
+
+ expect(await reportsFor(retried.organizationId)).toEqual([
+ expect.objectContaining({ status: 'reported' }),
+ ])
+ expect(sentRecords(retriedLicense.licenseArn)).toHaveLength(2)
+ expect(sentRecords(refusedLicense.licenseArn)).toHaveLength(1)
+ })
+
+ it('rejects a license AWS refuses outright and retries one a transient failure stopped', async () => {
+ const refused = await createOrganization()
+ const throttled = await createOrganization()
+ const refusedLicense = await insertLicense(refused.organizationId)
+ const throttledLicense = await insertLicense(throttled.organizationId)
+ await logUsage(refused, { at: minutesAgo(10), cost: 0.01 })
+ await logUsage(throttled, { at: minutesAgo(10), cost: 0.01 })
+ await meterAwsMarketplaceUsage(NOW)
+ meteringSend.mockImplementation(async (command: { input: { UsageRecords: SentRecord[] } }) => {
+ const arn = command.input.UsageRecords[0]?.LicenseArn
+ if (arn !== refusedLicense.licenseArn && arn !== throttledLicense.licenseArn) {
+ return { Results: [], UnprocessedRecords: [] }
+ }
+ const error = new Error('refused')
+ error.name =
+ arn === refusedLicense.licenseArn ? 'InvalidLicenseException' : 'ThrottlingException'
+ throw error
+ })
+
+ await meterAwsMarketplaceUsage(NEXT_HOUR)
+
+ expect(await reportsFor(refused.organizationId)).toEqual([
+ expect.objectContaining({ status: 'rejected', error: 'InvalidLicenseException' }),
+ ])
+ expect(await reportsFor(throttled.organizationId)).toEqual([
+ expect.objectContaining({ status: 'pending' }),
+ ])
+ })
+
+ it('rejects a pending record once it is older than AWS accepts', async () => {
+ const owner = await createOrganization()
+ const { licenseArn } = await insertLicense(owner.organizationId)
+ await logUsage(owner, { at: minutesAgo(10), cost: 0.01 })
+ await meterAwsMarketplaceUsage(NOW)
+ meteringSend.mockResolvedValue({ Results: [], UnprocessedRecords: [] })
+ await meterAwsMarketplaceUsage(NEXT_HOUR)
+ meteringSend.mockClear()
+
+ await meterAwsMarketplaceUsage(NOW + 25 * HOUR_MS)
+
+ expect(await reportsFor(owner.organizationId)).toEqual([
+ expect.objectContaining({ status: 'rejected', error: expect.stringMatching(/window/) }),
+ ])
+ expect(sentRecords(licenseArn)).toEqual([])
+ })
+
+ it('resends a record at the quantity first sent, keeping later usage off it', async () => {
+ const owner = await createOrganization()
+ const { licenseArn } = await insertLicense(owner.organizationId, {
+ status: 'inactive',
+ expiresAt: null,
+ endedAt: minutesAgo(10),
+ })
+ await logUsage(owner, { at: minutesAgo(15), cost: 0.02 })
+ meteringSend.mockRejectedValueOnce(
+ Object.assign(new Error('timed out'), { name: 'TimeoutError' })
+ )
+
+ await meterAwsMarketplaceUsage(NOW)
+ await logUsage(owner, { at: minutesAgo(1), cost: 3 })
+ await meterAwsMarketplaceUsage(NOW + 5 * 60_000)
+
+ expect(sentRecords(licenseArn).map((record) => record.Quantity)).toEqual([2, 2])
+ expect(await reportsFor(owner.organizationId)).toEqual([
+ expect.objectContaining({ quantity: 2, status: 'reported' }),
+ ])
+ })
+
+ it('rejects only the record whose timestamp AWS finds out of range', async () => {
+ const owner = await createOrganization()
+ const { licenseArn } = await insertLicense(owner.organizationId)
+ const earlierHour = floorHour(NOW - 2 * HOUR_MS)
+ await logUsage(owner, { at: minutesAgo(130), cost: 0.01 })
+ await meterAwsMarketplaceUsage(NOW - 2 * HOUR_MS)
+ await logUsage(owner, { at: minutesAgo(10), cost: 0.02 })
+ meteringSend.mockResolvedValue({ Results: [], UnprocessedRecords: [] })
+ await meterAwsMarketplaceUsage(NOW)
+ acceptEverything()
+ const accept = meteringSend.getMockImplementation()
+ meteringSend.mockImplementation(async (command: { input: { UsageRecords: SentRecord[] } }) => {
+ if (
+ command.input.UsageRecords.some(
+ (record) => record.Timestamp?.getTime() === earlierHour.getTime()
+ )
+ ) {
+ throw Object.assign(new Error('out of range'), { name: 'TimestampOutOfBoundsException' })
+ }
+ return accept?.(command)
+ })
+
+ await meterAwsMarketplaceUsage(NEXT_HOUR)
+
+ expect(await reportsFor(owner.organizationId)).toEqual([
+ expect.objectContaining({
+ hourStart: earlierHour,
+ status: 'rejected',
+ error: 'TimestampOutOfBoundsException',
+ }),
+ expect.objectContaining({ hourStart: HOUR, quantity: 2, status: 'reported' }),
+ ])
+ expect(sentRecords(licenseArn).at(-1)).toEqual(expect.objectContaining({ Timestamp: HOUR }))
+ })
+
+ it("sends last month's records until the month-end grace passes, then rejects them", async () => {
+ const owner = await createOrganization()
+ const today = new Date(NOW)
+ const monthStart = Date.UTC(today.getUTCFullYear(), today.getUTCMonth() + 1, 1)
+ const period = {
+ start: new Date(monthStart - 10 * DAY_MS),
+ end: new Date(monthStart + 20 * DAY_MS),
+ }
+ const { licenseArn } = await insertLicense(owner.organizationId, {
+ organizationLinkedAt: new Date(monthStart - 5 * HOUR_MS),
+ })
+ const lastHourOfMonth = new Date(monthStart - HOUR_MS)
+ await logUsage(owner, { at: new Date(monthStart - 50 * 60_000), cost: 0.01, period })
+ await meterAwsMarketplaceUsage(monthStart - 30 * 60_000)
+ meteringSend.mockResolvedValue({ Results: [], UnprocessedRecords: [] })
+
+ await meterAwsMarketplaceUsage(monthStart + 5 * HOUR_MS)
+
+ expect(sentRecords(licenseArn)).toEqual([
+ expect.objectContaining({ Timestamp: lastHourOfMonth }),
+ ])
+
+ meteringSend.mockClear()
+ await meterAwsMarketplaceUsage(monthStart + 7 * HOUR_MS)
+
+ expect(sentRecords(licenseArn)).toEqual([])
+ expect(await reportsFor(owner.organizationId)).toEqual([
+ expect.objectContaining({
+ hourStart: lastHourOfMonth,
+ status: 'rejected',
+ error: expect.stringMatching(/window/),
+ }),
+ ])
+ })
+
+ it('flushes a license at its expiry before any check marks it inactive', async () => {
+ const owner = await createOrganization()
+ const expiresAt = minutesAgo(30)
+ const { licenseArn } = await insertLicense(owner.organizationId, { expiresAt })
+ await logUsage(owner, { at: minutesAgo(35), cost: 0.03 })
+
+ await meterAwsMarketplaceUsage(NOW)
+
+ expect(sentRecords(licenseArn)).toEqual([
+ expect.objectContaining({ Quantity: 3, Timestamp: floorHour(expiresAt.getTime()) }),
+ ])
+ })
+
+ it('still sends usage already charged after its organization is deleted', async () => {
+ const owner = await createOrganization()
+ const { id, licenseArn } = await insertLicense(owner.organizationId)
+ await logUsage(owner, { at: minutesAgo(10), cost: 0.02 })
+ await meterAwsMarketplaceUsage(NOW)
+
+ await db.delete(organization).where(eq(organization.id, owner.organizationId))
+ await meterAwsMarketplaceUsage(NEXT_HOUR)
+
+ expect(sentRecords(licenseArn)).toEqual([expect.objectContaining({ Quantity: 2 })])
+ expect(
+ await db
+ .select({ status: awsMarketplaceUsageReport.status })
+ .from(awsMarketplaceUsageReport)
+ .where(eq(awsMarketplaceUsageReport.licenseId, id))
+ ).toEqual([{ status: 'reported' }])
+ })
+})
diff --git a/apps/sim/lib/billing/aws-marketplace/metering.ts b/apps/sim/lib/billing/aws-marketplace/metering.ts
new file mode 100644
index 00000000000..4c5468f4af5
--- /dev/null
+++ b/apps/sim/lib/billing/aws-marketplace/metering.ts
@@ -0,0 +1,588 @@
+/**
+ * Bills AWS Marketplace usage. Usage cost is recorded in place and can grow after it is first
+ * written, so each run charges what changed in the books rather than what an hour of rows says:
+ * it re-sums every billing period of an organization's billed usage that can still change,
+ * charges the whole units above what that period's tally already counted, and adds them to the
+ * current hour's record for the license being billed. A record is sent once its hour closes, or at
+ * once for a license that has ended, and AWS keeps the first record it accepts for a license,
+ * dimension and hour, so a record's quantity is fixed once it is first sent.
+ */
+
+import { BatchMeterUsageCommand } from '@aws-sdk/client-marketplace-metering'
+import { db } from '@sim/db'
+import {
+ awsMarketplaceLicense,
+ awsMarketplaceUsageReport,
+ awsMarketplaceUsageTally,
+ usageLog,
+} from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage, toError } from '@sim/utils/errors'
+import { chunkArray } from '@sim/utils/helpers'
+import { generateId } from '@sim/utils/id'
+import { compareStrings } from '@sim/utils/string'
+import {
+ and,
+ asc,
+ eq,
+ gt,
+ gte,
+ inArray,
+ isNotNull,
+ isNull,
+ lt,
+ lte,
+ or,
+ type SQL,
+ sql,
+} from 'drizzle-orm'
+import { getMeteringClient } from '@/lib/billing/aws-marketplace/client'
+import { getAwsMarketplaceProducts } from '@/lib/billing/aws-marketplace/config'
+import type { AwsMarketplaceUsagePricing } from '@/lib/billing/aws-marketplace/products'
+import { MAX_WORKFLOW_EXECUTION_TIMEOUT_SECONDS } from '@/lib/billing/execution-timeout-defaults'
+import { toDecimal, toFixedString } from '@/lib/billing/utils/decimal'
+import { acquireAdvisoryXactLock } from '@/lib/db/advisory-locks'
+import type { DbTransaction } from '@/lib/db/types'
+
+const logger = createLogger('AwsMarketplaceMetering')
+
+const HOUR_MS = 60 * 60 * 1000
+/** AWS refuses a record 24 hours or more after its timestamp. */
+const AWS_ACCEPTS_MS = 24 * HOUR_MS
+/** AWS takes the previous month's records until 06:00 UTC on the first day of the next month. */
+const MONTH_GRACE_MS = 6 * HOUR_MS
+/** AWS takes a license's last usage for about an hour after it ends. */
+const AFTER_END_MS = HOUR_MS
+/**
+ * How long a usage row's cost can keep growing: a request tops up its row in place while it runs,
+ * and no run outlasts the longest execution timeout. A day of margin covers late cost callbacks.
+ */
+const ROW_SETTLE_MS = MAX_WORKFLOW_EXECUTION_TIMEOUT_SECONDS * 1000 + 24 * HOUR_MS
+/** BatchMeterUsage takes at most 25 records. */
+const RECORDS_PER_CALL = 25
+/** Rows sent per run, oldest first; any beyond it go in the next run. */
+const MAX_ROWS_PER_RUN = 500
+
+/**
+ * AWS refuses these for everything the license sends, so retrying cannot help: the records are
+ * rejected and the error is logged for the team.
+ */
+const LICENSE_LEVEL_ERRORS = new Set([
+ 'InvalidLicenseException',
+ 'InvalidCustomerIdentifierException',
+ 'InvalidProductCodeException',
+ 'InvalidUsageDimensionException',
+])
+/** AWS refuses a whole call when any one record's timestamp is out of range. */
+const TIMESTAMP_OUT_OF_BOUNDS = 'TimestampOutOfBoundsException'
+
+export interface AwsMarketplaceMeteringSummary {
+ /** Units newly charged to a license this run. */
+ charged: number
+ reported: number
+ rejected: number
+ /** Rows left pending for the next run: unprocessed by AWS, or a transient failure. */
+ retained: number
+}
+
+interface MeteredLicense {
+ id: string
+ organizationId: string
+ organizationLinkedAt: Date
+ status: 'pending' | 'active' | 'inactive'
+ expiresAt: Date | null
+ endedAt: Date | null
+ usage: AwsMarketplaceUsagePricing
+}
+
+/** A ledger row awaiting AWS, with the license it bills. */
+interface PendingReport {
+ id: string
+ organizationId: string | null
+ dimension: string
+ hourStart: Date
+ quantity: number
+ licenseArn: string
+ customerAwsAccountId: string
+}
+
+function floorHour(time: number): number {
+ return Math.floor(time / HOUR_MS) * HOUR_MS
+}
+
+/** Serializes charging and sending for one organization and dimension. */
+function lockUsage(tx: DbTransaction, organizationId: string, dimension: string): Promise {
+ return acquireAdvisoryXactLock(
+ tx,
+ 'aws_marketplace_usage',
+ `aws-marketplace-usage:${organizationId}:${dimension}`
+ )
+}
+
+/**
+ * Starts billing for linked licenses that have no link time, such as those linked before it was
+ * recorded: their usage counts from the first run that sees them.
+ */
+async function stampUnmarkedLinks(productCodes: string[], now: number): Promise {
+ await db
+ .update(awsMarketplaceLicense)
+ .set({ organizationLinkedAt: new Date(now) })
+ .where(
+ and(
+ inArray(awsMarketplaceLicense.productCode, productCodes),
+ isNotNull(awsMarketplaceLicense.organizationId),
+ isNull(awsMarketplaceLicense.organizationLinkedAt)
+ )
+ )
+}
+
+/** Linked licenses on a plan that bills usage, in force or ended recently enough to still bill. */
+async function listMeteredLicenses(
+ usageByProduct: ReadonlyMap,
+ now: number
+): Promise {
+ const rows = await db
+ .select({
+ id: awsMarketplaceLicense.id,
+ productCode: awsMarketplaceLicense.productCode,
+ organizationId: awsMarketplaceLicense.organizationId,
+ organizationLinkedAt: awsMarketplaceLicense.organizationLinkedAt,
+ status: awsMarketplaceLicense.status,
+ expiresAt: awsMarketplaceLicense.expiresAt,
+ endedAt: awsMarketplaceLicense.endedAt,
+ })
+ .from(awsMarketplaceLicense)
+ .where(
+ and(
+ inArray(awsMarketplaceLicense.productCode, [...usageByProduct.keys()]),
+ isNotNull(awsMarketplaceLicense.organizationId),
+ isNotNull(awsMarketplaceLicense.organizationLinkedAt),
+ or(
+ eq(awsMarketplaceLicense.status, 'active'),
+ gte(awsMarketplaceLicense.endedAt, new Date(now - AFTER_END_MS))
+ )
+ )
+ )
+ .orderBy(asc(awsMarketplaceLicense.organizationLinkedAt), asc(awsMarketplaceLicense.id))
+ return rows.flatMap(({ productCode, organizationId, organizationLinkedAt, ...row }) => {
+ const usage = usageByProduct.get(productCode)
+ if (!usage || !organizationId || !organizationLinkedAt) return []
+ return [{ ...row, organizationId, organizationLinkedAt, usage }]
+ })
+}
+
+/**
+ * When a license stopped being in force, or null while it still is: the earlier of its expiry, once
+ * passed, and the moment a check first found it inactive.
+ */
+function licenseEnd(license: MeteredLicense, now: number): number | null {
+ const expiry = license.expiresAt?.getTime()
+ const ends = [
+ ...(expiry !== undefined && expiry <= now ? [expiry] : []),
+ ...(license.status === 'active' ? [] : [license.endedAt?.getTime() ?? now]),
+ ]
+ return ends.length > 0 ? Math.min(...ends) : null
+}
+
+/**
+ * The license an organization's newly counted usage is charged to, and the hour it is charged in.
+ * A license still in force wins, the earliest-linked first, so overlapping licenses during a
+ * renewal never both bill. With none in force, the license that ended most recently, within the
+ * hour AWS allows, takes the usage at the hour it ended. That record is sent as soon as the end is
+ * known, because AWS counts its hour from the unsubscribe itself, which a check only finds later:
+ * usage that lands after the send, from work still finishing once access is gone, is not billed.
+ */
+function chargeTarget(
+ licenses: MeteredLicense[],
+ now: number
+): { license: MeteredLicense; hourStart: number } | null {
+ let ended: { license: MeteredLicense; end: number } | null = null
+ for (const license of licenses) {
+ const end = licenseEnd(license, now)
+ if (end === null) return { license, hourStart: floorHour(now) }
+ if (end >= now - AFTER_END_MS && (!ended || end > ended.end)) ended = { license, end }
+ }
+ return ended ? { license: ended.license, hourStart: floorHour(ended.end) } : null
+}
+
+/**
+ * Counts the organization's usage in each billing period with a row still able to change, and
+ * returns the whole units not yet charged. Each period's tally is updated to what it now covers; a period's
+ * first tally fixes where its count starts.
+ */
+async function countNewUnits(
+ tx: DbTransaction,
+ organizationId: string,
+ usage: AwsMarketplaceUsagePricing,
+ countFrom: Date,
+ now: number
+): Promise {
+ const tallies = await tx
+ .select()
+ .from(awsMarketplaceUsageTally)
+ .where(
+ and(
+ eq(awsMarketplaceUsageTally.organizationId, organizationId),
+ eq(awsMarketplaceUsageTally.dimension, usage.dimension)
+ )
+ )
+ const tallyByPeriod = new Map(tallies.map((tally) => [tally.periodStart.getTime(), tally]))
+
+ const periods = await tx
+ .selectDistinct({ periodStart: usageLog.billingPeriodStart })
+ .from(usageLog)
+ .where(
+ and(
+ eq(usageLog.billingEntityType, 'organization'),
+ eq(usageLog.billingEntityId, organizationId),
+ gt(usageLog.createdAt, new Date(now - ROW_SETTLE_MS))
+ )
+ )
+
+ let newUnits = 0
+ for (const { periodStart } of periods) {
+ if (!periodStart) continue
+ const tally = tallyByPeriod.get(periodStart.getTime())
+ const countedFrom = tally?.countedFrom ?? countFrom
+ const [{ cost }] = await tx
+ .select({ cost: sql`coalesce(sum(${usageLog.cost}), 0)` })
+ .from(usageLog)
+ .where(
+ and(
+ eq(usageLog.billingEntityType, 'organization'),
+ eq(usageLog.billingEntityId, organizationId),
+ eq(usageLog.billingPeriodStart, periodStart),
+ inArray(usageLog.category, [...usage.categories]),
+ gte(usageLog.createdAt, countedFrom)
+ )
+ )
+ const periodCost = toDecimal(cost)
+ const counted = tally?.units ?? 0
+ const units = Math.max(counted, periodCost.dividedToIntegerBy(usage.usdPerUnit).toNumber())
+ newUnits += units - counted
+ await tx
+ .insert(awsMarketplaceUsageTally)
+ .values({
+ organizationId,
+ dimension: usage.dimension,
+ periodStart,
+ countedFrom,
+ costUsd: toFixedString(periodCost),
+ units,
+ })
+ .onConflictDoUpdate({
+ target: [
+ awsMarketplaceUsageTally.organizationId,
+ awsMarketplaceUsageTally.dimension,
+ awsMarketplaceUsageTally.periodStart,
+ ],
+ set: { costUsd: toFixedString(periodCost), units, updatedAt: new Date() },
+ })
+ }
+ return newUnits
+}
+
+/**
+ * Charges one organization's newly counted usage to its license, under a lock on the organization
+ * and dimension so concurrent runs extend one tally. Returns the units charged. With no license
+ * to charge, or only a record already sent, the usage stays uncounted for the next license that
+ * can take it.
+ */
+async function chargeOrganization(licenses: MeteredLicense[], now: number): Promise {
+ const target = chargeTarget(licenses, now)
+ if (!target) return 0
+ const { license, hourStart } = target
+ const { usage } = license
+ return db.transaction(async (tx) => {
+ await lockUsage(tx, license.organizationId, usage.dimension)
+ const [record] = await tx
+ .select({
+ status: awsMarketplaceUsageReport.status,
+ sentAt: awsMarketplaceUsageReport.sentAt,
+ })
+ .from(awsMarketplaceUsageReport)
+ .where(
+ and(
+ eq(awsMarketplaceUsageReport.licenseId, license.id),
+ eq(awsMarketplaceUsageReport.dimension, usage.dimension),
+ eq(awsMarketplaceUsageReport.hourStart, new Date(hourStart))
+ )
+ )
+ if (record && (record.status !== 'pending' || record.sentAt)) return 0
+
+ const countFrom = licenses.reduce(
+ (earliest, entry) =>
+ entry.organizationLinkedAt < earliest ? entry.organizationLinkedAt : earliest,
+ license.organizationLinkedAt
+ )
+ const units = await countNewUnits(tx, license.organizationId, usage, countFrom, now)
+ if (units <= 0) return 0
+ const [written] = await tx
+ .insert(awsMarketplaceUsageReport)
+ .values({
+ id: generateId(),
+ organizationId: license.organizationId,
+ licenseId: license.id,
+ dimension: usage.dimension,
+ hourStart: new Date(hourStart),
+ quantity: units,
+ })
+ .onConflictDoUpdate({
+ target: [
+ awsMarketplaceUsageReport.licenseId,
+ awsMarketplaceUsageReport.dimension,
+ awsMarketplaceUsageReport.hourStart,
+ ],
+ set: {
+ quantity: sql`${awsMarketplaceUsageReport.quantity} + ${units}`,
+ updatedAt: new Date(),
+ },
+ setWhere: isNull(awsMarketplaceUsageReport.sentAt),
+ })
+ .returning({ id: awsMarketplaceUsageReport.id })
+ // A row frozen under another organization's lock (relinked after its organization was deleted)
+ // keeps its quantity; rolling back leaves these units for the next record.
+ if (!written) throw new Error('AWS Marketplace usage record was sent while it was charged')
+ return units
+ })
+}
+
+/**
+ * Rows AWS can no longer accept are rejected, so they stop being retried and the team hears: those
+ * 24 hours old, and the previous month's once its grace period has passed.
+ */
+async function rejectExpiredReports(now: number): Promise {
+ const today = new Date(now)
+ const monthStart = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), 1)
+ const tooOld: SQL[] = [lte(awsMarketplaceUsageReport.hourStart, new Date(now - AWS_ACCEPTS_MS))]
+ if (now >= monthStart + MONTH_GRACE_MS) {
+ tooOld.push(lt(awsMarketplaceUsageReport.hourStart, new Date(monthStart)))
+ }
+ const expired = await db
+ .update(awsMarketplaceUsageReport)
+ .set({
+ status: 'rejected',
+ error: 'Outside the window AWS accepts usage for',
+ updatedAt: new Date(),
+ })
+ .where(and(eq(awsMarketplaceUsageReport.status, 'pending'), or(...tooOld)))
+ .returning({
+ id: awsMarketplaceUsageReport.id,
+ organizationId: awsMarketplaceUsageReport.organizationId,
+ quantity: awsMarketplaceUsageReport.quantity,
+ })
+ for (const row of expired) {
+ logger.error('AWS Marketplace usage aged out before AWS accepted it', { ...row })
+ }
+ return expired.length
+}
+
+async function settleReport(
+ row: PendingReport,
+ outcome:
+ | { status: 'reported'; meteringRecordId: string | null }
+ | { status: 'rejected'; error: string }
+): Promise {
+ const now = new Date()
+ await db
+ .update(awsMarketplaceUsageReport)
+ .set(
+ outcome.status === 'reported'
+ ? {
+ status: 'reported',
+ meteringRecordId: outcome.meteringRecordId,
+ reportedAt: now,
+ updatedAt: now,
+ }
+ : { status: 'rejected', error: outcome.error, updatedAt: now }
+ )
+ .where(eq(awsMarketplaceUsageReport.id, row.id))
+}
+
+function recordKey(dimension: string | undefined, timestamp: Date | undefined): string {
+ return `${dimension}@${timestamp?.getTime()}`
+}
+
+/**
+ * Fixes the quantity of rows about to go to AWS and returns them as they now stand, dropping any
+ * no longer pending. The usage lock keeps a charge from joining a row between this read and the
+ * send.
+ */
+async function freezeForSending(rows: PendingReport[]): Promise {
+ return db.transaction(async (tx) => {
+ const scopes = new Map()
+ for (const { organizationId, dimension } of rows) {
+ if (organizationId)
+ scopes.set(`${organizationId}:${dimension}`, { organizationId, dimension })
+ }
+ const ordered = [...scopes].sort(([left], [right]) => compareStrings(left, right))
+ for (const [, scope] of ordered) await lockUsage(tx, scope.organizationId, scope.dimension)
+ const frozen = await tx
+ .update(awsMarketplaceUsageReport)
+ .set({ sentAt: sql`coalesce(${awsMarketplaceUsageReport.sentAt}, now())` })
+ .where(
+ and(
+ inArray(
+ awsMarketplaceUsageReport.id,
+ rows.map((row) => row.id)
+ ),
+ eq(awsMarketplaceUsageReport.status, 'pending')
+ )
+ )
+ .returning({ id: awsMarketplaceUsageReport.id, quantity: awsMarketplaceUsageReport.quantity })
+ const quantityById = new Map(frozen.map((row) => [row.id, row.quantity]))
+ return rows.flatMap((row) => {
+ const quantity = quantityById.get(row.id)
+ return quantity === undefined ? [] : [{ ...row, quantity }]
+ })
+ })
+}
+
+/**
+ * Sends one call's worth of a license's rows. A license-level refusal rejects them and is logged;
+ * an out-of-range timestamp, which fails the whole call, is narrowed to its own row by sending
+ * each alone; a record AWS leaves unprocessed, or any other failure, stays pending for the next
+ * run.
+ */
+async function sendBatch(
+ batch: PendingReport[],
+ summary: AwsMarketplaceMeteringSummary
+): Promise {
+ const unsettled = new Map(batch.map((row) => [recordKey(row.dimension, row.hourStart), row]))
+ try {
+ const response = await getMeteringClient().send(
+ new BatchMeterUsageCommand({
+ UsageRecords: batch.map((row) => ({
+ LicenseArn: row.licenseArn,
+ CustomerAWSAccountId: row.customerAwsAccountId,
+ Dimension: row.dimension,
+ Quantity: row.quantity,
+ Timestamp: row.hourStart,
+ })),
+ })
+ )
+ for (const result of response.Results ?? []) {
+ const key = recordKey(result.UsageRecord?.Dimension, result.UsageRecord?.Timestamp)
+ const row = unsettled.get(key)
+ if (!row) continue
+ unsettled.delete(key)
+ if (result.Status === 'CustomerNotSubscribed') {
+ logger.error('AWS Marketplace refused usage for a customer not subscribed', {
+ reportId: row.id,
+ organizationId: row.organizationId,
+ })
+ await settleReport(row, { status: 'rejected', error: 'CustomerNotSubscribed' })
+ summary.rejected += 1
+ } else {
+ await settleReport(row, {
+ status: 'reported',
+ meteringRecordId: result.MeteringRecordId ?? null,
+ })
+ summary.reported += 1
+ }
+ }
+ summary.retained += unsettled.size
+ } catch (error) {
+ const { name } = toError(error)
+ if (name === TIMESTAMP_OUT_OF_BOUNDS && batch.length > 1) {
+ for (const row of batch) await sendBatch([row], summary)
+ return
+ }
+ if (name === TIMESTAMP_OUT_OF_BOUNDS || LICENSE_LEVEL_ERRORS.has(name)) {
+ logger.error('AWS Marketplace refused usage', {
+ reason: name,
+ reportIds: batch.map((row) => row.id),
+ })
+ for (const row of batch) await settleReport(row, { status: 'rejected', error: name })
+ summary.rejected += batch.length
+ return
+ }
+ logger.warn('AWS Marketplace usage could not be sent; retrying next run', {
+ reason: name,
+ error: getErrorMessage(error),
+ })
+ summary.retained += batch.length
+ }
+}
+
+/**
+ * Sends every pending row whose hour has closed, or whose license has ended or expired so no more
+ * usage can join it, oldest first and grouped by license.
+ */
+async function sendPendingReports(now: number, summary: AwsMarketplaceMeteringSummary) {
+ summary.rejected += await rejectExpiredReports(now)
+ const pending: PendingReport[] = await db
+ .select({
+ id: awsMarketplaceUsageReport.id,
+ organizationId: awsMarketplaceUsageReport.organizationId,
+ dimension: awsMarketplaceUsageReport.dimension,
+ hourStart: awsMarketplaceUsageReport.hourStart,
+ quantity: awsMarketplaceUsageReport.quantity,
+ licenseArn: awsMarketplaceLicense.licenseArn,
+ customerAwsAccountId: awsMarketplaceLicense.customerAwsAccountId,
+ })
+ .from(awsMarketplaceUsageReport)
+ .innerJoin(
+ awsMarketplaceLicense,
+ eq(awsMarketplaceUsageReport.licenseId, awsMarketplaceLicense.id)
+ )
+ .where(
+ and(
+ eq(awsMarketplaceUsageReport.status, 'pending'),
+ or(
+ lt(awsMarketplaceUsageReport.hourStart, new Date(floorHour(now))),
+ eq(awsMarketplaceLicense.status, 'inactive'),
+ lte(awsMarketplaceLicense.expiresAt, new Date(now))
+ )
+ )
+ )
+ .orderBy(asc(awsMarketplaceUsageReport.hourStart))
+ .limit(MAX_ROWS_PER_RUN)
+
+ const byLicense = new Map()
+ for (const row of pending) {
+ const rows = byLicense.get(row.licenseArn)
+ if (rows) rows.push(row)
+ else byLicense.set(row.licenseArn, [row])
+ }
+ for (const rows of byLicense.values()) {
+ for (const chunk of chunkArray(rows, RECORDS_PER_CALL)) {
+ const batch = await freezeForSending(chunk)
+ if (batch.length > 0) await sendBatch(batch, summary)
+ }
+ }
+}
+
+/**
+ * Charges every organization's newly counted usage owed through AWS Marketplace and sends AWS the
+ * records it can take. Null when no configured listing bills usage. Safe to replay: a period's
+ * tally only moves forward, and AWS keeps the first record for a license, dimension and hour.
+ */
+export async function meterAwsMarketplaceUsage(
+ now = Date.now()
+): Promise {
+ const usageByProduct = new Map()
+ for (const [productCode, product] of getAwsMarketplaceProducts()) {
+ if (product.plan.usage) usageByProduct.set(productCode, product.plan.usage)
+ }
+ if (usageByProduct.size === 0) return null
+ const summary: AwsMarketplaceMeteringSummary = {
+ charged: 0,
+ reported: 0,
+ rejected: 0,
+ retained: 0,
+ }
+ await stampUnmarkedLinks([...usageByProduct.keys()], now)
+ const licensesByOrganization = new Map()
+ for (const license of await listMeteredLicenses(usageByProduct, now)) {
+ const key = `${license.organizationId}:${license.usage.dimension}`
+ const licenses = licensesByOrganization.get(key)
+ if (licenses) licenses.push(license)
+ else licensesByOrganization.set(key, [license])
+ }
+ for (const licenses of licensesByOrganization.values()) {
+ summary.charged += await chargeOrganization(licenses, now)
+ }
+ await sendPendingReports(now, summary)
+ return summary
+}
diff --git a/apps/sim/lib/billing/aws-marketplace/notifications.ts b/apps/sim/lib/billing/aws-marketplace/notifications.ts
index 81957e7dd3e..ee9baac9d4e 100644
--- a/apps/sim/lib/billing/aws-marketplace/notifications.ts
+++ b/apps/sim/lib/billing/aws-marketplace/notifications.ts
@@ -1,4 +1,5 @@
import { createLogger } from '@sim/logger'
+import { findAwsMarketplaceProduct } from '@/lib/billing/aws-marketplace/config'
import {
AWS_MARKETPLACE_BUYER_NOTICE_EVENT,
AWS_MARKETPLACE_TEAM_NOTICE_EVENT,
@@ -60,6 +61,7 @@ async function describeLicense(license: AwsMarketplaceLicenseRecord): Promise = new Map(
+ PLANS.map((plan) => [plan.id, plan])
+)
+
+export interface AwsMarketplaceProduct {
+ readonly productCode: string
+ readonly plan: AwsMarketplacePlan
+}
+
+/** The products a deployment sells, by product code. */
+export type AwsMarketplaceProducts = ReadonlyMap
+
+/**
+ * Parses `AWS_MARKETPLACE_PRODUCTS`: comma-separated `=` pairs, for example
+ * `abc123=contract,def456=platform-with-usage`. Throws on a malformed entry, an unknown plan, or a
+ * repeated code.
+ */
+export function parseAwsMarketplaceProducts(value: string): AwsMarketplaceProducts {
+ const products = new Map()
+ for (const entry of value.split(',')) {
+ const [productCode, planId, ...rest] = entry.split('=').map((part) => part.trim())
+ if (!productCode || !planId || rest.length > 0) {
+ throw new Error(
+ `AWS_MARKETPLACE_PRODUCTS: expected =, got "${entry.trim()}"`
+ )
+ }
+ const plan = AWS_MARKETPLACE_PLANS.get(planId)
+ if (!plan) {
+ throw new Error(
+ `AWS_MARKETPLACE_PRODUCTS: unknown plan "${planId}"; expected one of ${[...AWS_MARKETPLACE_PLANS.keys()].join(', ')}`
+ )
+ }
+ if (products.has(productCode)) {
+ throw new Error(`AWS_MARKETPLACE_PRODUCTS: product code "${productCode}" is listed twice`)
+ }
+ products.set(productCode, { productCode, plan })
+ }
+ return products
+}
diff --git a/apps/sim/lib/billing/aws-marketplace/reconcile.ts b/apps/sim/lib/billing/aws-marketplace/reconcile.ts
index 5c9a8dc6fea..8a58828a22b 100644
--- a/apps/sim/lib/billing/aws-marketplace/reconcile.ts
+++ b/apps/sim/lib/billing/aws-marketplace/reconcile.ts
@@ -10,11 +10,15 @@ import { getErrorMessage } from '@sim/utils/errors'
import { toRecord } from '@sim/utils/object'
import { truncate } from '@sim/utils/string'
import { getEventsQueueClient } from '@/lib/billing/aws-marketplace/client'
-import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config'
+import { getAwsMarketplaceProducts } from '@/lib/billing/aws-marketplace/config'
import {
isInvalidEntitlementRequest,
verifyLicense,
} from '@/lib/billing/aws-marketplace/entitlements'
+import type {
+ AwsMarketplaceProduct,
+ AwsMarketplaceProducts,
+} from '@/lib/billing/aws-marketplace/products'
import {
applyAgreementRenewal,
enqueueAgreementAdvisory,
@@ -80,7 +84,8 @@ interface AwsMarketplaceReconcileSummary {
type BudgetCheck = (workMs: number) => boolean
interface DrainContext {
- productCode: string
+ /** This run's configured products, by code; an event for any other product is not ours. */
+ products: AwsMarketplaceProducts
queueUrl: string
summary: AwsMarketplaceReconcileSummary
fits: BudgetCheck
@@ -159,25 +164,23 @@ type MessageOutcome = 'applied' | 'discarded' | 'skipped' | 'retained'
/** Stamps the attempt, asks the Entitlement Service, and records its answer. */
async function reverifyLicense(
- productCode: string,
+ product: AwsMarketplaceProduct,
licenseArn: string,
- options: {
- ended?: boolean
- agreement?: { agreementId: string | null; seenAt: Date | null }
- fallbackAccountId?: string | null
- licenseEventReceivedAt?: Date
- } = {}
+ options: Pick<
+ Parameters[0],
+ 'agreement' | 'fallbackAccountId' | 'licenseEventReceivedAt'
+ > & { ended?: boolean } = {}
) {
const verifiedAt = new Date()
await markVerificationAttempted(licenseArn, verifiedAt)
const summary = await verifyLicense(
- productCode,
+ product,
licenseArn,
AbortSignal.timeout(LICENSE_READ_BUDGET_MS)
)
const outcome = await recordLicenseVerification({
licenseArn,
- productCode,
+ productCode: product.productCode,
verification: { summary, verifiedAt, ended: options.ended },
agreement: options.agreement,
fallbackAccountId: options.fallbackAccountId,
@@ -220,20 +223,20 @@ async function applyAgreementEnded(
message: Message
): Promise {
const messageId = message.MessageId
- const licenseArns = await listLicensesForAgreement(context.productCode, agreementId)
- if (licenseArns.length === 0) {
+ const licenses = await listLicensesForAgreement(context.products, agreementId)
+ if (licenses.length === 0) {
logger.info('Ignoring an AWS Marketplace agreement event for no stored license', { messageId })
return 'discarded'
}
let incomplete = false
- for (const [index, licenseArn] of licenseArns.entries()) {
+ for (const [index, { licenseArn, product }] of licenses.entries()) {
if (!context.fits(LICENSE_READ_BUDGET_MS)) {
context.summary.outOfBudget = true
return 'retained'
}
if (index > 0) await extendVisibility(context, message, messageId)
try {
- const { summary } = await reverifyLicense(context.productCode, licenseArn, { ended: true })
+ const { summary } = await reverifyLicense(product, licenseArn, { ended: true })
if (summary.active) {
incomplete = true
logger.info('AWS still reports a license of an ended agreement current', { messageId })
@@ -261,8 +264,8 @@ async function applyLicenseEvent(
event: Extract,
messageId: string | undefined
): Promise {
- const { productCode } = context
- if (event.productCode !== productCode) {
+ const product = event.productCode ? context.products.get(event.productCode) : undefined
+ if (!product) {
logger.warn('Discarding an AWS Marketplace event for another product', { messageId })
return 'discarded'
}
@@ -272,7 +275,7 @@ async function applyLicenseEvent(
})
return 'retained'
}
- const { summary, outcome } = await reverifyLicense(productCode, event.licenseArn, {
+ const { summary, outcome } = await reverifyLicense(product, event.licenseArn, {
ended: event.deprovisioned,
agreement: { agreementId: event.agreementId, seenAt: event.occurredAt },
fallbackAccountId: event.acceptorAccountId,
@@ -298,34 +301,41 @@ async function applyLicenseEvent(
return 'applied'
}
+/** Whether a stored license was involved in an advisory or a renewal. */
+async function applyAgreementEvent(
+ products: AwsMarketplaceProducts,
+ event: Extract,
+ messageId: string | undefined
+): Promise {
+ if (event.kind === 'advisory') {
+ return enqueueAgreementAdvisory({
+ products,
+ agreementId: event.agreementId,
+ state: event.state,
+ detail: event.detail,
+ messageId: messageId ?? event.agreementId,
+ })
+ }
+ if (!event.previousAgreementId) return false
+ return applyAgreementRenewal({
+ products,
+ previousAgreementId: event.previousAgreementId,
+ agreementId: event.agreementId,
+ })
+}
+
async function applyEvent(
context: DrainContext,
event: MarketplaceEvent,
message: Message
): Promise {
- const { productCode } = context
+ const { products } = context
const messageId = message.MessageId
if (event.kind === 'license') return applyLicenseEvent(context, event, messageId)
if (event.kind === 'agreement-ended') {
return applyAgreementEnded(context, event.agreementId, message)
}
- const involved =
- event.kind === 'advisory'
- ? await enqueueAgreementAdvisory({
- productCode,
- agreementId: event.agreementId,
- state: event.state,
- detail: event.detail,
- messageId: messageId ?? event.agreementId,
- })
- : event.previousAgreementId
- ? await applyAgreementRenewal({
- productCode,
- previousAgreementId: event.previousAgreementId,
- agreementId: event.agreementId,
- })
- : false
- if (involved) return 'applied'
+ if (await applyAgreementEvent(products, event, messageId)) return 'applied'
logger.info('Ignoring an AWS Marketplace agreement event for no stored license', { messageId })
return 'discarded'
}
@@ -412,19 +422,19 @@ async function drainEvents(context: DrainContext): Promise {
}
async function reverifyStaleLicenses(
- productCode: string,
+ products: AwsMarketplaceProducts,
summary: AwsMarketplaceReconcileSummary,
fits: BudgetCheck
): Promise {
const cutoff = new Date(Date.now() - REVERIFY_AFTER_MS)
- const licenseArns = await listLicensesDueForReverify(productCode, cutoff, REVERIFY_BATCH_SIZE)
- for (const licenseArn of licenseArns) {
+ const licenses = await listLicensesDueForReverify(products, cutoff, REVERIFY_BATCH_SIZE)
+ for (const { licenseArn, product } of licenses) {
if (!fits(LICENSE_READ_BUDGET_MS)) {
summary.outOfBudget = true
return
}
try {
- await reverifyLicense(productCode, licenseArn)
+ await reverifyLicense(product, licenseArn)
summary.reverified++
} catch (error) {
summary.reverifyFailed++
@@ -442,8 +452,8 @@ async function reverifyStaleLicenses(
* feature is off.
*/
export async function reconcileAwsMarketplaceLicenses(): Promise {
- const productCode = getAwsMarketplaceProductCode()
- if (!productCode) return null
+ const products = getAwsMarketplaceProducts()
+ if (products.size === 0) return null
const summary: AwsMarketplaceReconcileSummary = {
received: 0,
applied: 0,
@@ -457,7 +467,7 @@ export async function reconcileAwsMarketplaceLicenses(): Promise Date.now() - startedAt + workMs <= RUN_BUDGET_MS
const queueUrl = env.AWS_MARKETPLACE_EVENTS_QUEUE_URL
- if (queueUrl) await drainEvents({ productCode, queueUrl, summary, fits })
- await reverifyStaleLicenses(productCode, summary, fits)
+ if (queueUrl) await drainEvents({ products, queueUrl, summary, fits })
+ await reverifyStaleLicenses(products, summary, fits)
return summary
}
diff --git a/apps/sim/lib/billing/aws-marketplace/registration.ts b/apps/sim/lib/billing/aws-marketplace/registration.ts
index 0bbce158a0b..61888f75581 100644
--- a/apps/sim/lib/billing/aws-marketplace/registration.ts
+++ b/apps/sim/lib/billing/aws-marketplace/registration.ts
@@ -7,8 +7,10 @@ import { getErrorMessage } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
import { AWS_MARKETPLACE_CLAIM_TTL_MS, issueClaimToken } from '@/lib/billing/aws-marketplace/claim'
import { getMeteringClient } from '@/lib/billing/aws-marketplace/client'
+import { findAwsMarketplaceProduct } from '@/lib/billing/aws-marketplace/config'
import { type EntitlementSummary, verifyLicense } from '@/lib/billing/aws-marketplace/entitlements'
import type { AwsMarketplaceOnboardingError } from '@/lib/billing/aws-marketplace/onboarding'
+import type { AwsMarketplaceProduct } from '@/lib/billing/aws-marketplace/products'
import { saveRegisteredLicense } from '@/lib/billing/aws-marketplace/repository'
const logger = createLogger('AwsMarketplaceRegistration')
@@ -27,30 +29,30 @@ function errorName(error: unknown): string | undefined {
return error instanceof Error ? error.name : undefined
}
-/** AWS may not have finished right after a purchase, so an empty answer is asked again briefly. */
+/** AWS may not have finished right after a purchase, so a read with no answer about access is asked again briefly. */
async function readEntitlements(
- productCode: string,
+ product: AwsMarketplaceProduct,
licenseArn: string
): Promise<{ summary: EntitlementSummary; verifiedAt: Date }> {
let verifiedAt = new Date()
- let summary = await verifyLicense(productCode, licenseArn)
+ let summary = await verifyLicense(product, licenseArn)
for (const delayMs of NO_ENTITLEMENT_RETRY_DELAYS_MS) {
- if (summary.entitlementCount > 0) break
+ if (summary.accessReported) break
await sleep(delayMs)
verifiedAt = new Date()
- summary = await verifyLicense(productCode, licenseArn)
+ summary = await verifyLicense(product, licenseArn)
}
return { summary, verifiedAt }
}
/**
- * Turns the registration token AWS posts to the fulfillment URL into a license row. Every
- * registration reads the license's entitlements. A license AWS has not confirmed yet still gets a
- * claim, so the buyer can leave contact details; only a lapsed one does not. The token itself is
- * never logged.
+ * Turns the registration token AWS posts to the fulfillment URL into a license row. Every listing
+ * shares the one fulfillment URL, so the product comes from the code AWS resolves the token to,
+ * and a code this deployment does not sell is refused. Every registration reads the license's
+ * entitlements. A license AWS has not confirmed yet still gets a claim, so the buyer can leave
+ * contact details; only a lapsed one does not. The token itself is never logged.
*/
export async function registerAwsMarketplaceBuyer(
- productCode: string,
registrationToken: string
): Promise {
let customer: ResolveCustomerCommandOutput
@@ -68,7 +70,8 @@ export async function registerAwsMarketplaceBuyer(
return { kind: 'unavailable' }
}
- if (customer.ProductCode !== productCode) {
+ const product = findAwsMarketplaceProduct(customer.ProductCode)
+ if (!product) {
logger.warn('AWS Marketplace registration is for another product')
return { kind: 'invalid' }
}
@@ -84,11 +87,11 @@ export async function registerAwsMarketplaceBuyer(
}
try {
- const verification = await readEntitlements(productCode, customer.LicenseArn)
+ const verification = await readEntitlements(product, customer.LicenseArn)
const claim = issueClaimToken()
const saved = await saveRegisteredLicense({
licenseArn: customer.LicenseArn,
- productCode,
+ productCode: product.productCode,
customerAwsAccountId: customer.CustomerAWSAccountId,
agreementId: customer.Metadata?.AgreementId ?? null,
verification,
diff --git a/apps/sim/lib/billing/aws-marketplace/repository.ts b/apps/sim/lib/billing/aws-marketplace/repository.ts
index 0b3a8ff2ef4..15636142926 100644
--- a/apps/sim/lib/billing/aws-marketplace/repository.ts
+++ b/apps/sim/lib/billing/aws-marketplace/repository.ts
@@ -1,7 +1,27 @@
import { db } from '@sim/db'
-import { awsMarketplaceLicense, member, organization, outboxEvent, user } from '@sim/db/schema'
+import {
+ awsMarketplaceLicense,
+ member,
+ organization,
+ outboxEvent,
+ subscription,
+ user,
+} from '@sim/db/schema'
import { generateId } from '@sim/utils/id'
-import { and, asc, count, desc, eq, inArray, isNotNull, isNull, lt, ne, or } from 'drizzle-orm'
+import {
+ and,
+ asc,
+ count,
+ desc,
+ eq,
+ inArray,
+ isNotNull,
+ isNull,
+ lt,
+ ne,
+ notInArray,
+ or,
+} from 'drizzle-orm'
import type { EntitlementSummary } from '@/lib/billing/aws-marketplace/entitlements'
import {
AWS_MARKETPLACE_BUYER_NOTICE_EVENT,
@@ -10,6 +30,11 @@ import {
type AwsMarketplaceNoticeTerms,
type AwsMarketplaceTeamNotice,
} from '@/lib/billing/aws-marketplace/notice-events'
+import type {
+ AwsMarketplaceProduct,
+ AwsMarketplaceProducts,
+} from '@/lib/billing/aws-marketplace/products'
+import { TERMINAL_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
import { enqueueOutboxEvent } from '@/lib/core/outbox/service'
import type { DbTransaction } from '@/lib/db/types'
@@ -40,6 +65,20 @@ function enqueueTeamNotice(tx: DbTransaction, notice: AwsMarketplaceTeamNotice,
return enqueueOutboxEvent(tx, AWS_MARKETPLACE_TEAM_NOTICE_EVENT, notice, { id })
}
+/** Enqueues a team notice under a fixed id unless one with that id was already sent. */
+async function enqueueTeamNoticeOnce(
+ tx: DbTransaction,
+ notice: AwsMarketplaceTeamNotice,
+ id: string
+): Promise {
+ const [sent] = await tx
+ .select({ id: outboxEvent.id })
+ .from(outboxEvent)
+ .where(eq(outboxEvent.id, id))
+ .limit(1)
+ if (!sent) await enqueueTeamNotice(tx, notice, id)
+}
+
function enqueueBuyerNotice(tx: DbTransaction, notice: AwsMarketplaceBuyerNotice) {
return enqueueOutboxEvent(tx, AWS_MARKETPLACE_BUYER_NOTICE_EVENT, notice)
}
@@ -50,21 +89,22 @@ export function isNewerRead(license: AwsMarketplaceLicenseRecord, verifiedAt: Da
}
/**
- * No entitlements after AWS has said the license ended, or on a license that was already confirmed
- * once, is a lapse. Otherwise AWS may simply not have finished, so the license stays pending.
+ * No answer about access after AWS has said the license ended, or on a license that was already
+ * confirmed once, is a lapse. Otherwise AWS may simply not have finished, so the license stays pending.
*/
function statusFromRead(
prior: AwsMarketplaceLicenseStatus | null,
{ summary, ended }: LicenseVerification
): AwsMarketplaceLicenseStatus {
if (summary.active) return 'active'
- if (summary.entitlementCount > 0 || ended) return 'inactive'
+ if (summary.accessReported || ended) return 'inactive'
return prior === null || prior === 'pending' ? 'pending' : 'inactive'
}
/**
- * The columns a read writes. With no entitlements the last-known dimension, quantity, and expiry
- * stay as they were, and only the status can change. An inactive license has nothing to claim.
+ * The columns a read writes. With no answer about access the last-known dimension, quantity, and
+ * expiry stay as they were, and only the status can change. The read that first finds a license
+ * inactive records when it ended.
*/
function verificationFields(
prior: AwsMarketplaceLicenseStatus | null,
@@ -74,18 +114,23 @@ function verificationFields(
const { summary, verifiedAt } = verification
return {
status,
+ ...(status === 'inactive'
+ ? prior !== 'inactive' && { endedAt: verifiedAt }
+ : { endedAt: null }),
lastVerifiedAt: verifiedAt,
lastVerificationAttemptAt: verifiedAt,
updatedAt: new Date(),
- ...(summary.entitlementCount > 0 && {
+ ...(summary.accessReported && {
dimension: summary.dimension,
quantity: summary.quantity,
expiresAt: summary.expiresAt,
}),
- ...(status === 'inactive' && { claimTokenHash: null, claimExpiresAt: null }),
}
}
+/** An inactive license has nothing to claim, whatever else the writer sets. */
+const NO_CLAIM = { claimTokenHash: null, claimExpiresAt: null }
+
function termsOf(license: AwsMarketplaceLicenseRecord): AwsMarketplaceNoticeTerms {
return {
status: license.status,
@@ -107,7 +152,8 @@ function termsDiffer(before: AwsMarketplaceNoticeTerms, after: AwsMarketplaceNot
/**
* The one place a read reaches a stored license, for every writer: under the caller's row lock it
* applies the read unless a newer one is stored, and when that moves the terms, enqueues the team's
- * notice in the same transaction. `alongside` is written either way.
+ * notice in the same transaction. `alongside` is written either way, except a claim on a license
+ * that ends up inactive.
*/
async function applyVerification(
tx: DbTransaction,
@@ -116,11 +162,13 @@ async function applyVerification(
alongside: Partial = {}
): Promise {
const fresh = isNewerRead(locked, verification.verifiedAt)
+ const status = fresh ? statusFromRead(locked.status, verification) : locked.status
const [license] = await tx
.update(awsMarketplaceLicense)
.set({
...(fresh ? verificationFields(locked.status, verification) : { updatedAt: new Date() }),
...alongside,
+ ...(status === 'inactive' && NO_CLAIM),
})
.where(eq(awsMarketplaceLicense.id, locked.id))
.returning()
@@ -182,7 +230,7 @@ async function inheritFromRenewedLicense(
const [inherited] = await tx
.update(awsMarketplaceLicense)
.set({
- organizationId: predecessor.organizationId,
+ ...organizationLink(predecessor.organizationId),
linkedByUserId: predecessor.linkedByUserId,
contactEmail: predecessor.contactEmail,
companyName: predecessor.companyName,
@@ -193,6 +241,43 @@ async function inheritFromRenewedLicense(
return inherited
}
+/**
+ * Creates the row for a license nobody stored yet, carrying on a renewed license's owner, and tells
+ * the team. Null when the ARN already has a row, which the caller then updates under its lock.
+ */
+async function insertNewLicense(
+ tx: DbTransaction,
+ input: {
+ licenseArn: string
+ productCode: string
+ customerAwsAccountId: string
+ seen: AgreementObservation
+ verification: LicenseVerification
+ alongside: Partial
+ },
+ source: 'registration' | 'event'
+): Promise {
+ const status = statusFromRead(null, input.verification)
+ const [created] = await tx
+ .insert(awsMarketplaceLicense)
+ .values({
+ id: generateId(),
+ licenseArn: input.licenseArn,
+ productCode: input.productCode,
+ customerAwsAccountId: input.customerAwsAccountId,
+ ...agreementFields(null, input.seen),
+ ...input.alongside,
+ ...verificationFields(null, input.verification),
+ ...(status === 'inactive' && NO_CLAIM),
+ })
+ .onConflictDoNothing({ target: awsMarketplaceLicense.licenseArn })
+ .returning()
+ if (!created) return null
+ const license = await inheritFromRenewedLicense(tx, created)
+ await enqueueTeamNotice(tx, { kind: 'new-license', licenseId: license.id, source })
+ return license
+}
+
/**
* Records a registration: the license row keyed by its ARN, with `claim` unless the newest answer
* is inactive. A new row tells the team at once, so a buyer who pays and abandons setup is seen.
@@ -209,45 +294,25 @@ export async function saveRegisteredLicense(input: {
const claimFields = { claimTokenHash: claim.hash, claimExpiresAt: claim.expiresAt }
const seen = { agreementId: input.agreementId, seenAt: verification.verifiedAt }
return db.transaction(async (tx) => {
- const status = statusFromRead(null, verification)
- const [created] = await tx
- .insert(awsMarketplaceLicense)
- .values({
- id: generateId(),
- licenseArn: input.licenseArn,
- productCode: input.productCode,
- customerAwsAccountId: input.customerAwsAccountId,
- ...agreementFields(null, seen),
- ...(status === 'inactive' ? {} : claimFields),
- ...verificationFields(null, verification),
- })
- .onConflictDoNothing({ target: awsMarketplaceLicense.licenseArn })
- .returning()
- if (created) {
- const license = await inheritFromRenewedLicense(tx, created)
- await enqueueTeamNotice(tx, {
- kind: 'new-license',
- licenseId: license.id,
- source: 'registration',
- })
- return { id: license.id, status: license.status }
- }
+ const created = await insertNewLicense(
+ tx,
+ { ...input, seen, alongside: claimFields },
+ 'registration'
+ )
+ if (created) return { id: created.id, status: created.status }
const locked = await lockByArn(tx, input.licenseArn)
if (!locked) throw new Error('AWS Marketplace license vanished during registration')
- const effective = isNewerRead(locked, verification.verifiedAt)
- ? statusFromRead(locked.status, verification)
- : locked.status
const written = await applyVerification(tx, locked, verification, {
...agreementFields(locked, seen),
- ...(effective === 'inactive' ? {} : claimFields),
+ ...claimFields,
})
const license = await inheritFromRenewedLicense(tx, written)
return { id: license.id, status: license.status }
})
}
-export type LicenseVerificationOutcome =
+type LicenseVerificationOutcome =
| { kind: 'created' | 'updated'; license: AwsMarketplaceLicenseRecord }
/** No row, and AWS described nothing to create one from. */
| { kind: 'skipped' }
@@ -273,25 +338,20 @@ export async function recordLicenseVerification(input: {
const stamp = licenseEventReceivedAt ? { licenseEventReceivedAt } : {}
return db.transaction(async (tx) => {
const accountId = verification.summary.customerAwsAccountId ?? input.fallbackAccountId
- if (accountId && verification.summary.entitlementCount > 0) {
- const [created] = await tx
- .insert(awsMarketplaceLicense)
- .values({
- id: generateId(),
+ if (accountId && verification.summary.licenseSeen) {
+ const created = await insertNewLicense(
+ tx,
+ {
licenseArn: input.licenseArn,
productCode: input.productCode,
customerAwsAccountId: accountId,
- ...agreementFields(null, seen),
- ...stamp,
- ...verificationFields(null, verification),
- })
- .onConflictDoNothing({ target: awsMarketplaceLicense.licenseArn })
- .returning()
- if (created) {
- const license = await inheritFromRenewedLicense(tx, created)
- await enqueueTeamNotice(tx, { kind: 'new-license', licenseId: license.id, source: 'event' })
- return { kind: 'created', license }
- }
+ seen,
+ verification,
+ alongside: stamp,
+ },
+ 'event'
+ )
+ if (created) return { kind: 'created', license: created }
}
const locked = await lockByArn(tx, input.licenseArn)
if (!locked) return { kind: 'skipped' }
@@ -323,24 +383,16 @@ export async function recordLicenseVerification(input: {
* Returns whether any stored license was involved.
*/
export async function applyAgreementRenewal(input: {
- productCode: string
+ products: AwsMarketplaceProducts
previousAgreementId: string
agreementId: string
}): Promise {
- const { productCode, previousAgreementId, agreementId } = input
+ const { products, previousAgreementId, agreementId } = input
return db.transaction(async (tx) => {
const predecessors = await tx
.select()
.from(awsMarketplaceLicense)
- .where(
- and(
- eq(awsMarketplaceLicense.productCode, productCode),
- or(
- eq(awsMarketplaceLicense.agreementId, previousAgreementId),
- eq(awsMarketplaceLicense.renewedAgreementId, previousAgreementId)
- )
- )
- )
+ .where(belongsToAgreement(products, previousAgreementId))
.for('update')
const accepted = predecessors.filter(
(row) => row.renewedAgreementId === null || row.renewedAgreementId === previousAgreementId
@@ -359,37 +411,24 @@ export async function applyAgreementRenewal(input: {
const successors = await tx
.select()
.from(awsMarketplaceLicense)
- .where(
- and(
- eq(awsMarketplaceLicense.productCode, productCode),
- eq(awsMarketplaceLicense.agreementId, agreementId)
- )
- )
+ .where(and(ofProducts(products), eq(awsMarketplaceLicense.agreementId, agreementId)))
.for('update')
for (const successor of successors) await inheritFromRenewedLicense(tx, successor)
const licenseIds = [...accepted, ...successors].map((row) => row.id)
if (licenseIds.length === 0) return false
- const noticeId = `aws-marketplace-renewal-${agreementId}`
- const [sent] = await tx
- .select({ id: outboxEvent.id })
- .from(outboxEvent)
- .where(eq(outboxEvent.id, noticeId))
- .limit(1)
- if (!sent) {
- await enqueueTeamNotice(
- tx,
- { kind: 'renewed', licenseIds, previousAgreementId, agreementId },
- noticeId
- )
- }
+ await enqueueTeamNoticeOnce(
+ tx,
+ { kind: 'renewed', licenseIds, previousAgreementId, agreementId },
+ `aws-marketplace-renewal-${agreementId}`
+ )
return true
})
}
/** Tells the team of an AWS advisory on an agreement we hold; redelivery cannot send it twice. */
export async function enqueueAgreementAdvisory(input: {
- productCode: string
+ products: AwsMarketplaceProducts
agreementId: string
state: 'issued' | 'resolved'
detail: string
@@ -399,35 +438,31 @@ export async function enqueueAgreementAdvisory(input: {
const rows = await tx
.select({ id: awsMarketplaceLicense.id })
.from(awsMarketplaceLicense)
- .where(belongsToAgreement(input.productCode, input.agreementId))
+ .where(belongsToAgreement(input.products, input.agreementId))
.limit(MAX_LICENSES_PER_AGREEMENT)
if (rows.length === 0) return false
- const noticeId = `aws-marketplace-advisory-${input.messageId}`
- const [sent] = await tx
- .select({ id: outboxEvent.id })
- .from(outboxEvent)
- .where(eq(outboxEvent.id, noticeId))
- .limit(1)
- if (!sent) {
- await enqueueTeamNotice(
- tx,
- {
- kind: 'advisory',
- licenseIds: rows.map((row) => row.id),
- agreementId: input.agreementId,
- state: input.state,
- detail: input.detail,
- },
- noticeId
- )
- }
+ await enqueueTeamNoticeOnce(
+ tx,
+ {
+ kind: 'advisory',
+ licenseIds: rows.map((row) => row.id),
+ agreementId: input.agreementId,
+ state: input.state,
+ detail: input.detail,
+ },
+ `aws-marketplace-advisory-${input.messageId}`
+ )
return true
})
}
-function belongsToAgreement(productCode: string, agreementId: string) {
+function ofProducts(products: AwsMarketplaceProducts) {
+ return inArray(awsMarketplaceLicense.productCode, [...products.keys()])
+}
+
+function belongsToAgreement(products: AwsMarketplaceProducts, agreementId: string) {
return and(
- eq(awsMarketplaceLicense.productCode, productCode),
+ ofProducts(products),
or(
eq(awsMarketplaceLicense.agreementId, agreementId),
eq(awsMarketplaceLicense.renewedAgreementId, agreementId)
@@ -435,41 +470,67 @@ function belongsToAgreement(productCode: string, agreementId: string) {
)
}
+/** A license to re-read, with the product it was sold under. */
+interface LicenseToVerify {
+ licenseArn: string
+ product: AwsMarketplaceProduct
+}
+
+const LICENSE_TO_VERIFY_COLUMNS = {
+ licenseArn: awsMarketplaceLicense.licenseArn,
+ productCode: awsMarketplaceLicense.productCode,
+}
+
+/**
+ * The columns that point a license at `organizationId`, stamping when, since usage counts from
+ * the link. Every caller links only when the organization changes, so the stamp is the start of
+ * this organization's tenure on the license.
+ */
+function organizationLink(organizationId: string | null) {
+ return { organizationId, organizationLinkedAt: organizationId ? new Date() : null }
+}
+
+/** Pairs each row with its product; the queries already keep to configured products. */
+function withProducts(
+ rows: Array<{ licenseArn: string; productCode: string }>,
+ products: AwsMarketplaceProducts
+): LicenseToVerify[] {
+ return rows.flatMap(({ licenseArn, productCode }) => {
+ const product = products.get(productCode)
+ return product ? [{ licenseArn, product }] : []
+ })
+}
+
/**
* The licenses an agreement covers, least recently attempted first, so a redelivered message moves
* on to the ones an earlier delivery did not reach.
*/
export async function listLicensesForAgreement(
- productCode: string,
+ products: AwsMarketplaceProducts,
agreementId: string
-): Promise {
+): Promise {
const rows = await db
- .select({ licenseArn: awsMarketplaceLicense.licenseArn })
+ .select(LICENSE_TO_VERIFY_COLUMNS)
.from(awsMarketplaceLicense)
- .where(belongsToAgreement(productCode, agreementId))
+ .where(belongsToAgreement(products, agreementId))
.orderBy(asc(awsMarketplaceLicense.lastVerificationAttemptAt))
.limit(MAX_LICENSES_PER_AGREEMENT)
- return rows.map((row) => row.licenseArn)
+ return withProducts(rows, products)
}
-/** This product's licenses longest since an attempt, up to `limit`, none attempted after `cutoff`. */
+/** Licenses of these products longest since an attempt, up to `limit`, none after `cutoff`. */
export async function listLicensesDueForReverify(
- productCode: string,
+ products: AwsMarketplaceProducts,
cutoff: Date,
limit: number
-): Promise {
+): Promise {
const rows = await db
- .select({ licenseArn: awsMarketplaceLicense.licenseArn })
+ .select(LICENSE_TO_VERIFY_COLUMNS)
.from(awsMarketplaceLicense)
- .where(
- and(
- eq(awsMarketplaceLicense.productCode, productCode),
- lt(awsMarketplaceLicense.lastVerificationAttemptAt, cutoff)
- )
- )
+ .where(and(ofProducts(products), lt(awsMarketplaceLicense.lastVerificationAttemptAt, cutoff)))
.orderBy(asc(awsMarketplaceLicense.lastVerificationAttemptAt))
.limit(limit)
- return rows.map((row) => row.licenseArn)
+ return withProducts(rows, products)
}
/** Stamps an attempt to verify a license, before its outcome is known. */
@@ -504,13 +565,14 @@ export async function lockLicense(
return row ?? null
}
-interface BuyerContact {
- email: string
+/** Whom to contact about provisioning; AWS asks the registration page to collect it. */
+export interface BuyerContact {
+ contactEmail: string
companyName: string | null
}
function contactChanged(locked: AwsMarketplaceLicenseRecord, contact: BuyerContact): boolean {
- return locked.contactEmail !== contact.email || locked.companyName !== contact.companyName
+ return locked.contactEmail !== contact.contactEmail || locked.companyName !== contact.companyName
}
/**
@@ -529,10 +591,10 @@ export async function consumeLicenseClaim(
}
): Promise {
const license = await applyVerification(tx, locked, input.verification, {
- contactEmail: input.contact.email,
+ contactEmail: input.contact.contactEmail,
companyName: input.contact.companyName,
...(input.link && {
- organizationId: input.link.organizationId,
+ ...organizationLink(input.link.organizationId),
linkedByUserId: input.link.userId,
}),
claimTokenHash: null,
@@ -561,7 +623,7 @@ export async function saveLicenseContact(
): Promise {
const changed = contactChanged(locked, input.contact)
const license = await applyVerification(tx, locked, input.verification, {
- contactEmail: input.contact.email,
+ contactEmail: input.contact.contactEmail,
companyName: input.contact.companyName,
})
if (changed) await enqueueTeamNotice(tx, { kind: 'contact', licenseId: license.id })
@@ -613,15 +675,20 @@ export async function listAccountLicenses(userId: string) {
}
/**
- * Whether AWS Marketplace bills this billing reference: a license that is not inactive, linked to
- * the organization, or registered to the user's account with no organization yet.
+ * Whether AWS Marketplace bills this billing reference: a license of a listing this deployment
+ * sells that is not inactive, linked to the organization, or registered to the user's account with
+ * no organization yet.
*/
-export async function holdsMarketplaceLicense(referenceId: string): Promise {
+export async function holdsMarketplaceLicense(
+ products: AwsMarketplaceProducts,
+ referenceId: string
+): Promise {
const [row] = await db
.select({ id: awsMarketplaceLicense.id })
.from(awsMarketplaceLicense)
.where(
and(
+ ofProducts(products),
ne(awsMarketplaceLicense.status, 'inactive'),
or(
eq(awsMarketplaceLicense.organizationId, referenceId),
@@ -666,12 +733,41 @@ export async function linkLicenseForTeam(
): Promise {
const [license] = await tx
.update(awsMarketplaceLicense)
- .set({ organizationId, claimTokenHash: null, claimExpiresAt: null, updatedAt: new Date() })
+ .set({
+ ...organizationLink(organizationId),
+ claimTokenHash: null,
+ claimExpiresAt: null,
+ updatedAt: new Date(),
+ })
.where(eq(awsMarketplaceLicense.id, licenseId))
.returning()
return license
}
+/**
+ * Whether the organization has a live Stripe subscription to a self-serve plan: any it is bound to
+ * that has not finished, since a trial or a payment still pending turns into a charge. Enterprise
+ * is provisioned by the team, who pause its collection for a Marketplace customer.
+ */
+export async function paysForSelfServePlan(
+ tx: DbTransaction,
+ organizationId: string
+): Promise {
+ const [row] = await tx
+ .select({ id: subscription.id })
+ .from(subscription)
+ .where(
+ and(
+ eq(subscription.referenceId, organizationId),
+ isNotNull(subscription.stripeSubscriptionId),
+ notInArray(subscription.status, [...TERMINAL_SUBSCRIPTION_STATUSES]),
+ ne(subscription.plan, 'enterprise')
+ )
+ )
+ .limit(1)
+ return Boolean(row)
+}
+
export async function organizationExists(tx: DbTransaction, organizationId: string) {
const [row] = await tx
.select({ id: organization.id })
@@ -717,10 +813,12 @@ export async function getLicensesByIds(licenseIds: readonly string[]) {
.where(inArray(awsMarketplaceLicense.id, [...licenseIds]))
}
-/** Organizations already linked to another license of this AWS account, for the team to match. */
+/**
+ * Organizations already linked to another license of this AWS account, on any listing, for the
+ * team to match: a buyer moving to a new listing usually wants the organization they already have.
+ */
export async function listOrganizationsLinkedToAccount(input: {
customerAwsAccountId: string
- productCode: string
excludingLicenseArn: string
}): Promise> {
return db
@@ -730,7 +828,6 @@ export async function listOrganizationsLinkedToAccount(input: {
.where(
and(
eq(awsMarketplaceLicense.customerAwsAccountId, input.customerAwsAccountId),
- eq(awsMarketplaceLicense.productCode, input.productCode),
ne(awsMarketplaceLicense.licenseArn, input.excludingLicenseArn)
)
)
diff --git a/apps/sim/lib/block-metadata/names.generated.ts b/apps/sim/lib/block-metadata/names.generated.ts
index 763ae48f0a0..c064312cb2b 100644
--- a/apps/sim/lib/block-metadata/names.generated.ts
+++ b/apps/sim/lib/block-metadata/names.generated.ts
@@ -12,7 +12,7 @@ export const BLOCK_NAMES: Readonly> = {
ahrefs_v2: 'Ahrefs',
airtable: 'Airtable',
algolia_v2: 'Algolia',
- amplitude: 'Amplitude',
+ amplitude_v2: 'Amplitude',
api: 'API',
apify_v2: 'Apify',
apollo_v2: 'Apollo',
@@ -22,7 +22,7 @@ export const BLOCK_NAMES: Readonly> = {
ashby_v2: 'Ashby',
athena_v2: 'Athena',
attio: 'Attio',
- azure_data_explorer: 'Azure Data Explorer',
+ azure_data_explorer_v2: 'Azure Data Explorer',
azure_devops_v2: 'Azure DevOps',
bitbucket: 'Bitbucket',
box_v2: 'Box',
@@ -33,7 +33,7 @@ export const BLOCK_NAMES: Readonly> = {
buffer_v2: 'Buffer',
calcom: 'Cal.com',
calendly_v2: 'Calendly',
- cbinsights: 'CB Insights',
+ cbinsights_v2: 'CB Insights',
checkr_v2: 'Checkr',
circleback_v2: 'Circleback',
clay: 'Clay',
@@ -149,7 +149,7 @@ export const BLOCK_NAMES: Readonly> = {
jira_service_management: 'Jira Service Management',
jotform_v2: 'Jotform',
jupyter_v3: 'Jupyter',
- kalshi_v2: 'Kalshi',
+ kalshi_v3: 'Kalshi',
ketch: 'Ketch',
knowledge: 'Knowledge',
lambda_v2: 'Lambda',
@@ -189,8 +189,8 @@ export const BLOCK_NAMES: Readonly> = {
monday: 'Monday',
mongodb: 'MongoDB',
mothership: 'Sim Chat',
- mssql: 'Microsoft SQL Server',
- mysql: 'MySQL',
+ mssql_v2: 'Microsoft SQL Server',
+ mysql_v2: 'MySQL',
neo4j: 'Neo4j',
netsuite: 'Oracle NetSuite',
neverbounce: 'NeverBounce',
@@ -218,18 +218,18 @@ export const BLOCK_NAMES: Readonly> = {
plane_v2: 'Plane',
planetscale_v2: 'PlanetScale',
polymarket: 'Polymarket',
- postgresql: 'PostgreSQL',
+ postgresql_v2: 'PostgreSQL',
posthog: 'PostHog',
powerbi: 'Power BI',
profound_v2: 'Profound',
prospeo: 'Prospeo',
pulse_v3: 'Pulse',
- qdrant: 'Qdrant',
+ qdrant_v2: 'Qdrant',
quartr_v2: 'Quartr',
quickbooks: 'QuickBooks',
quiver_v3: 'Quiver',
rabbitmq: 'RabbitMQ',
- railway: 'Railway',
+ railway_v2: 'Railway',
ramp: 'Ramp',
rb2b_v2: 'RB2B',
rds_v2: 'Amazon RDS',
@@ -269,7 +269,7 @@ export const BLOCK_NAMES: Readonly> = {
smartlead_v2: 'Smartlead',
smtp: 'SMTP',
snowflake: 'Snowflake',
- splunk: 'Splunk',
+ splunk_v2: 'Splunk',
sportmonks_v2: 'Sportmonks',
spotify: 'Spotify',
sqs_v2: 'Amazon SQS',
diff --git a/apps/sim/lib/catalog/projection/connector-type.ts b/apps/sim/lib/catalog/projection/connector-type.ts
index 4e0c7ee6237..1f65fc62c60 100644
--- a/apps/sim/lib/catalog/projection/connector-type.ts
+++ b/apps/sim/lib/catalog/projection/connector-type.ts
@@ -16,7 +16,14 @@ import type {
/** How a connector authenticates against its source. */
export type CatalogConnectorAuth =
| { mode: 'oauth'; provider: string; requiredScopes?: string[] }
- | { mode: 'apiKey'; label?: string; placeholder?: string; optional: boolean }
+ | {
+ mode: 'apiKey'
+ label?: string
+ placeholder?: string
+ optional: boolean
+ /** A saved credential of this service may be sent as `credentialId` instead of the key. */
+ savedCredential?: { provider: string; label: string }
+ }
/**
* One field of a connector's `sourceConfig`.
@@ -48,6 +55,8 @@ export interface CatalogConnectorConfigField {
mode?: 'basic' | 'advanced'
canonicalParamId?: string
multi?: boolean
+ /** Applies only with the connector's API key; omit it when sending a saved `credentialId`. */
+ apiKeyOnly?: boolean
}
/** A tag slot a connector populates on the documents it syncs. */
@@ -83,6 +92,14 @@ function projectAuth(auth: ConnectorMeta['auth']): CatalogConnectorAuth {
optional: auth.optional === true,
...(auth.label !== undefined ? { label: auth.label } : {}),
...(auth.placeholder !== undefined ? { placeholder: auth.placeholder } : {}),
+ ...(auth.savedCredential
+ ? {
+ savedCredential: {
+ provider: auth.savedCredential.provider,
+ label: auth.savedCredential.label,
+ },
+ }
+ : {}),
}
}
@@ -120,6 +137,7 @@ function projectConfigField(field: ConnectorConfigField): CatalogConnectorConfig
if (field.mode !== undefined) projected.mode = field.mode
if (field.canonicalParamId !== undefined) projected.canonicalParamId = field.canonicalParamId
if (field.multi !== undefined) projected.multi = field.multi
+ if (field.apiKeyOnly) projected.apiKeyOnly = true
return projected
}
diff --git a/apps/sim/lib/computer-use/application/authorize.test.ts b/apps/sim/lib/computer-use/application/authorize.test.ts
index b4549ca00b4..51afc06f4a5 100644
--- a/apps/sim/lib/computer-use/application/authorize.test.ts
+++ b/apps/sim/lib/computer-use/application/authorize.test.ts
@@ -7,23 +7,26 @@ import {
organizationAuthorizationMock,
organizationAuthorizationMockFns,
} from '@sim/testing/mocks/organization-authorization.mock'
+import {
+ permissionGroupScopeMock,
+ permissionGroupScopeMockFns,
+ resetPermissionGroupScopeMock,
+} from '@sim/testing/mocks/permission-group-scope.mock'
import { workspaceAuthzMock, workspaceAuthzMockFns } from '@sim/testing/mocks/workspace-authz.mock'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
ownedChat: vi.fn(),
- available: vi.fn(),
claim: vi.fn(),
}))
vi.mock('@/lib/mothership/chat/application/context', () => ({
resolveOwnedChatContext: mocks.ownedChat,
}))
-vi.mock('@/lib/computer-use/availability.server', () => ({
- isComputerUseAvailable: mocks.available,
-}))
+vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock)
vi.mock('@/lib/computer-use/repository', () => ({ claimComputerUseTool: mocks.claim }))
import { authorizeComputerUse } from '@/lib/computer-use/application/authorize'
+import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields'
const principal = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } as const
const input = { toolCallId: 'call-1' }
@@ -56,7 +59,7 @@ describe('native computer authorization', () => {
mothershipAsyncRunsMockFns.mockGetRunSegment.mockResolvedValue(run)
mocks.ownedChat.mockResolvedValue(context)
workspaceAuthzMockFns.mockResolveEffectiveWorkspacePermission.mockResolvedValue('read')
- mocks.available.mockResolvedValue(true)
+ resetPermissionGroupScopeMock()
mocks.claim.mockResolvedValue({ args: { action: 'list_apps' } })
})
it('returns canonical arguments only after current chat access and an atomic claim', async () => {
@@ -81,11 +84,20 @@ describe('native computer authorization', () => {
).rejects.toThrow()
expect(mothershipAsyncRunsMockFns.mockGetAsyncToolCall).not.toHaveBeenCalled()
})
- it('rejects disabled rollout without claiming an action', async () => {
- mocks.available.mockResolvedValue(false)
- await expect(authorizeComputerUse.execute({ principal, input })).rejects.toThrow('unavailable')
- expect(mocks.claim).not.toHaveBeenCalled()
- })
+ it.each(['disableComputerUse', 'hideCopilot'] as const)(
+ 'rejects a permission group with %s without claiming an action',
+ async (key) => {
+ mocks.ownedChat.mockResolvedValue({ ...context, workspaceOrganizationId: 'org-1' })
+ permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({
+ ...DEFAULT_PERMISSION_GROUP_CONFIG,
+ [key]: true,
+ })
+ await expect(authorizeComputerUse.execute({ principal, input })).rejects.toThrow(
+ 'Computer use is not available'
+ )
+ expect(mocks.claim).not.toHaveBeenCalled()
+ }
+ )
it('rechecks current workspace membership', async () => {
workspaceAuthzMockFns.mockResolveEffectiveWorkspacePermission.mockResolvedValue(null)
await expect(authorizeComputerUse.execute({ principal, input })).rejects.toThrow()
diff --git a/apps/sim/lib/computer-use/application/authorize.ts b/apps/sim/lib/computer-use/application/authorize.ts
index 5ba96e12911..20f0e0f1f0a 100644
--- a/apps/sim/lib/computer-use/application/authorize.ts
+++ b/apps/sim/lib/computer-use/application/authorize.ts
@@ -1,6 +1,5 @@
import type { SessionPrincipal } from '@sim/auth/principal'
import { ComputerUseSchema } from '@sim/desktop-bridge/computer-use'
-import { isComputerUseAvailable } from '@/lib/computer-use/availability.server'
import { claimComputerUseTool } from '@/lib/computer-use/repository'
import { defineOrganizationOperation } from '@/lib/core/application/organization-operation'
import { defineWorkspaceOperation } from '@/lib/core/application/workspace-operation'
@@ -19,13 +18,13 @@ export const authorizeComputerUse = defineAuthorizedChatUseCase({
id: 'desktop.computer.execute',
minimumRole: 'read',
workspaceApiKey: 'deny',
- capability: 'copilot.use',
+ capability: 'computer_use.use',
principalKinds: ['session'],
}),
organizationOperation: defineOrganizationOperation({
id: 'desktop.computer.execute',
minimumRole: 'member',
- capability: 'copilot.use',
+ capability: 'computer_use.use',
principalKinds: ['session'],
}),
async resolveContext({
@@ -60,8 +59,6 @@ export const authorizeComputerUse = defineAuthorizedChatUseCase({
},
authorizationOptions: {},
async execute({ principal, context, input }) {
- if (!(await isComputerUseAvailable(context.organizationId ?? context.workspaceOrganizationId)))
- throw new OrchestrationError('forbidden', 'Computer use is unavailable on this deployment')
const claimed = await claimComputerUseTool({
toolCallId: input.toolCallId,
runId: context.runId,
diff --git a/apps/sim/lib/computer-use/application/availability.ts b/apps/sim/lib/computer-use/application/availability.ts
index 36e84c9c3a4..370c4c02d53 100644
--- a/apps/sim/lib/computer-use/application/availability.ts
+++ b/apps/sim/lib/computer-use/application/availability.ts
@@ -1,17 +1,14 @@
import type { Principal } from '@sim/auth/principal'
-import type { ComputerUseOwner } from '@/lib/api/contracts/computer-use'
-import { isComputerUseAvailable } from '@/lib/computer-use/availability.server'
import { defineAuthorizedWorkspaceUseCase, defineWorkspaceOperation } from '@/lib/core/application'
import { defineAuthorizedOrganizationUseCase } from '@/lib/core/application/authorized-organization-use-case'
-import {
- assertOperationPrincipal,
- defineOperation,
- type OperationUseCase,
-} from '@/lib/core/application/operation'
import { defineOrganizationOperation } from '@/lib/core/application/organization-operation'
+import {
+ isOrganizationCapabilityWithheld,
+ isWorkspaceCapabilityWithheld,
+} from '@/lib/permission-groups/capability-assertions'
import { resolveActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/context'
-/** permission-group-exempt: reports the rollout for a workspace the caller can read; actions require chat authorization. */
+/** permission-group-exempt: reports whether computer_use.use is withheld instead of refusing; actions declare it. */
const workspaceAvailabilityOperation = defineWorkspaceOperation({
id: 'desktop.computer.availability',
minimumRole: 'read',
@@ -20,7 +17,7 @@ const workspaceAvailabilityOperation = defineWorkspaceOperation({
principalKinds: ['session'],
})
-/** permission-group-exempt: reports the rollout for an organization the caller belongs to; actions require chat authorization. */
+/** permission-group-exempt: reports whether computer_use.use is withheld instead of refusing; actions declare it. */
const organizationAvailabilityOperation = defineOrganizationOperation({
id: 'desktop.computer.availability',
minimumRole: 'member',
@@ -33,19 +30,29 @@ const workspaceAvailability = defineAuthorizedWorkspaceUseCase({
resolveContext: ({ input }: { input: { workspaceId: string } }) =>
resolveActiveWorkspaceApplicationContext(input.workspaceId),
authorizationOptions: {},
- execute: ({ context }) => isComputerUseAvailable(context.workspaceOrganizationId),
+ execute: async ({ principal, input, context }) =>
+ // permission-group-enforced: computer_use.use — an availability read answers false rather than 403
+ !(await isWorkspaceCapabilityWithheld(
+ principal.userId,
+ input.workspaceId,
+ 'computer_use.use',
+ context.workspaceOrganizationId
+ )),
})
const organizationAvailability = defineAuthorizedOrganizationUseCase({
operation: organizationAvailabilityOperation,
- execute: ({ input }: { input: { organizationId: string } }) =>
- isComputerUseAvailable(input.organizationId),
+ execute: async ({ input }: { input: { organizationId: string } }) =>
+ // permission-group-enforced: computer_use.use — an availability read answers false rather than 403
+ !(await isOrganizationCapabilityWithheld(input.organizationId, 'computer_use.use')),
})
+/** The chat whose permission group applies: exactly one of a workspace or an organization. */
+type ComputerUseOwner = { workspaceId: string } | { organizationId: string }
+
/**
- * Whether computer use is rolled out for a chat's owner: an organization chat follows its
- * organization, a workspace chat its workspace's organization, and a personal workspace the
- * global switch.
+ * Whether the chat owner's permission group allows computer use: an organization chat follows
+ * its organization's default group, a workspace chat the group governing that workspace.
*/
export function isComputerUseAvailableFor(
principal: Principal,
@@ -55,23 +62,3 @@ export function isComputerUseAvailableFor(
? organizationAvailability.execute({ principal, input: owner })
: workspaceAvailability.execute({ principal, input: owner })
}
-
-/** permission-group-exempt: dispatches to the owner's authorized rollout read above. */
-const availabilityOperation = defineOperation({
- id: 'desktop.computer.availability',
- capability: 'none',
- principalKinds: ['session'],
-})
-
-/** The desktop's rollout read before it touches the native helper; no device or app data leaves the desktop. */
-export const readComputerUseAvailability: OperationUseCase<
- typeof availabilityOperation,
- ComputerUseOwner,
- { enabled: boolean }
-> = {
- operation: availabilityOperation,
- async execute({ principal, input }) {
- assertOperationPrincipal(principal, availabilityOperation)
- return { enabled: await isComputerUseAvailableFor(principal, input) }
- },
-}
diff --git a/apps/sim/lib/computer-use/availability.server.ts b/apps/sim/lib/computer-use/availability.server.ts
deleted file mode 100644
index c42a84fb23d..00000000000
--- a/apps/sim/lib/computer-use/availability.server.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
-
-/** Computer use rolls out by organization; personal workspaces follow the global switch. */
-export function isComputerUseAvailable(orgId?: string | null): Promise {
- return isFeatureEnabled('mothership-computer-use', orgId ? { orgId } : {})
-}
diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts
index 99ce75e3603..b87c640130d 100644
--- a/apps/sim/lib/core/config/env.ts
+++ b/apps/sim/lib/core/config/env.ts
@@ -224,7 +224,7 @@ export const env = createEnv({
STRIPE_PRICE_TEAM_100_MO: z.string().min(1).optional(), // Team Max: $100/seat/mo
STRIPE_PRICE_TEAM_100_YR: z.string().min(1).optional(), // Team Max: $1,020/seat/yr
OVERAGE_THRESHOLD_DOLLARS: z.number().optional().default(100), // Dollar threshold for incremental overage billing (default: $100)
- AWS_MARKETPLACE_PRODUCT_CODE: z.string().min(1).optional(), // AWS Marketplace SaaS product code; unset turns the Marketplace registration, claim, and reconcile surfaces off
+ AWS_MARKETPLACE_PRODUCTS: z.string().min(1).optional(), // AWS Marketplace SaaS products this deployment sells, as = pairs (lib/billing/aws-marketplace/products.ts); unset turns the Marketplace surfaces off
AWS_MARKETPLACE_EVENTS_QUEUE_URL: z.string().url().optional(), // SQS queue receiving the Marketplace agreement EventBridge events the reconcile cron drains
// Email & Communication
@@ -564,6 +564,8 @@ export const env = createEnv({
ZOHO_CLIENT_SECRET: z.string().optional(), // Zoho OAuth client secret (Zoho Desk)
WEALTHBOX_CLIENT_ID: z.string().optional(), // WealthBox OAuth client ID
WEALTHBOX_CLIENT_SECRET: z.string().optional(), // WealthBox OAuth client secret
+ APOLLO_CLIENT_ID: z.string().optional(),
+ APOLLO_CLIENT_SECRET: z.string().optional(),
PIPEDRIVE_CLIENT_ID: z.string().optional(), // Pipedrive OAuth client ID
PIPEDRIVE_CLIENT_SECRET: z.string().optional(), // Pipedrive OAuth client secret
LINEAR_CLIENT_ID: z.string().optional(), // Linear OAuth client ID
@@ -616,7 +618,6 @@ export const env = createEnv({
AGENTMAIL_DOMAIN: z.string().optional(), // Custom domain for AgentMail inboxes (default: agentmail.to)
DASHBOARDS: z.boolean().optional(),
PROJECTS: z.boolean().optional(), // Fallback for the `projects` feature flag off AppConfig
- MSHIP_COMPUTER_USE: z.boolean().optional(),
WORKFLOW_TESTS: z.boolean().optional(), // Fallback for the `workflow-tests` feature flag off AppConfig
CHANGELOG: z.boolean().optional(), // Fallback for the `changelog` feature flag off AppConfig
SIM_LINKS: z.boolean().optional(), // Fallback for the `sim-links` feature flag off AppConfig
@@ -665,6 +666,7 @@ export const env = createEnv({
AGENT_MEMORY_HISTORY: z.boolean().optional(),
CREDENTIAL_GROUPS: z.boolean().optional(), // Enable Credential Groups on self-hosted (bypasses hosted requirements)
KNOWLEDGE_MEMBER_ACCESS: z.boolean().optional(), // Enable per-member knowledge connectors and hybrid-by-default retrieval globally
+ KNOWLEDGE_CONNECTOR_SAVED_CREDENTIALS: z.boolean().optional(), // Fallback for the `knowledge-connector-saved-credentials` feature flag off AppConfig
// Organizations - for self-hosted deployments
ORGANIZATIONS_ENABLED: z.boolean().optional(), // Enable organizations on self-hosted (bypasses plan requirements)
diff --git a/apps/sim/lib/core/config/feature-flags.test.ts b/apps/sim/lib/core/config/feature-flags.test.ts
index 881de5a4fca..4a7295d4440 100644
--- a/apps/sim/lib/core/config/feature-flags.test.ts
+++ b/apps/sim/lib/core/config/feature-flags.test.ts
@@ -41,7 +41,6 @@ setEnv({
APPCONFIG_ENVIRONMENT: 'staging',
DASHBOARDS: undefined,
MSHIP_MODEL_SELECTOR: undefined,
- MSHIP_COMPUTER_USE: undefined,
AGENT_MEMORY_HISTORY: undefined,
KNOWLEDGE_MEMBER_ACCESS: undefined,
SLACK_SEARCH_SHARED_APP: undefined,
@@ -64,16 +63,16 @@ const enabled = (flag: string, ctx?: FeatureFlagContext) =>
afterAll(resetEnvFlagsMock)
describe('getFeatureFlags', () => {
- it('gates computer use globally and defaults off without AppConfig', async () => {
- withAppConfig({ 'mothership-computer-use': { enabled: true } })
- expect(await isFeatureEnabled('mothership-computer-use')).toBe(true)
- withAppConfig({ 'mothership-computer-use': { enabled: false, userIds: ['user-1'] } })
- expect(await isFeatureEnabled('mothership-computer-use')).toBe(false)
+ it('gates a flag globally and defaults off without AppConfig', async () => {
+ withAppConfig({ dashboards: { enabled: true } })
+ expect(await isFeatureEnabled('dashboards')).toBe(true)
+ withAppConfig({ dashboards: { enabled: false, userIds: ['user-1'] } })
+ expect(await isFeatureEnabled('dashboards')).toBe(false)
setEnvFlags({ isAppConfigEnabled: false })
- expect(await isFeatureEnabled('mothership-computer-use')).toBe(false)
- envRef.MSHIP_COMPUTER_USE = true
- expect(await isFeatureEnabled('mothership-computer-use')).toBe(true)
- envRef.MSHIP_COMPUTER_USE = undefined
+ expect(await isFeatureEnabled('dashboards')).toBe(false)
+ envRef.DASHBOARDS = true
+ expect(await isFeatureEnabled('dashboards')).toBe(true)
+ envRef.DASHBOARDS = undefined
})
beforeEach(() => {
diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts
index 5394541ae6e..f2c79eb2d10 100644
--- a/apps/sim/lib/core/config/feature-flags.ts
+++ b/apps/sim/lib/core/config/feature-flags.ts
@@ -51,12 +51,6 @@ const FEATURE_FLAGS = {
'Enable dashboard resources, rendering, analytics, and Mothership authoring. Supports global and organization rollout; disabled by default.',
fallback: 'DASHBOARDS',
},
- 'mothership-computer-use': {
- description:
- 'Enable native macOS computer use in Mothership. Supports global and organization rollout; ' +
- 'personal workspaces follow the global switch. Each device must also opt in.',
- fallback: 'MSHIP_COMPUTER_USE',
- },
'workflow-tests': {
description:
'Enable workflow tests: the workspace Tests page, tests/.test.js files, and the Sim tests tool. Supports global and organization rollout; disabled by default.',
@@ -114,6 +108,17 @@ const FEATURE_FLAGS = {
'Off-AppConfig falls back to PROJECTS.',
fallback: 'PROJECTS',
},
+ 'knowledge-connector-saved-credentials': {
+ description:
+ 'Let a key-based knowledge connector that declares a saved-credential alternative (Amazon S3 ' +
+ 'with a saved AWS credential) be set up with that credential. Global on/off only; gates new ' +
+ 'setup and credential changes, never syncing of connectors already using a credential. Turn ' +
+ 'on only after the release that adds it is live on both the app and Trigger.dev workers. ' +
+ 'Once any connector uses a saved credential, rolling back to an earlier release is unsafe ' +
+ 'even with the flag off: older workers unschedule those connectors as credential-removed. ' +
+ 'Off-AppConfig falls back to KNOWLEDGE_CONNECTOR_SAVED_CREDENTIALS.',
+ fallback: 'KNOWLEDGE_CONNECTOR_SAVED_CREDENTIALS',
+ },
'knowledge-member-access': {
description:
'Organization Search (live) and the permission-aware workspace connector modes: members ' +
diff --git a/apps/sim/lib/credentials/__integration__/database-connection.integration.ts b/apps/sim/lib/credentials/__integration__/database-connection.integration.ts
new file mode 100644
index 00000000000..6a9964f4e55
--- /dev/null
+++ b/apps/sim/lib/credentials/__integration__/database-connection.integration.ts
@@ -0,0 +1,172 @@
+/**
+ * A saved PostgreSQL connection end to end against a real server: saving
+ * proves the login with SELECT 1 through the PostgreSQL tools' own guarded
+ * connection path, and a run through the registered tool operation reads the
+ * stored connection, bound to the run's workspace, whatever connection fields the
+ * call carries.
+ */
+
+import { db } from '@sim/db'
+import { credential, permissions, user, workspace } from '@sim/db/schema'
+import { readTestDatabaseUrl } from '@sim/db/testing/test-infrastructure'
+import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures'
+import { posthogServerMock } from '@sim/testing/mocks/posthog-server.mock'
+import { generateId } from '@sim/utils/id'
+import { eq, inArray } from 'drizzle-orm'
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/lib/posthog/server', () => posthogServerMock)
+
+import { env } from '@/lib/core/config/env'
+import { createCredentialRecord } from '@/lib/credentials/orchestration/credential-create'
+import { getInternalToolOperationHandler } from '@/lib/internal/tool-operations/registry.server'
+import { POSTGRESQL_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types'
+
+const databaseUrl = new URL(readTestDatabaseUrl())
+const connection = {
+ host: '127.0.0.1',
+ port: Number(databaseUrl.port || 5432),
+ database: decodeURIComponent(databaseUrl.pathname.slice(1)),
+ username: decodeURIComponent(databaseUrl.username),
+ // The disposable server trusts loopback logins, so any non-empty password is accepted.
+ password: decodeURIComponent(databaseUrl.password) || 'unused-under-trust-auth',
+ ssl: 'disabled',
+}
+const userId = generateId()
+const workspaceId = generateId()
+const otherWorkspaceId = generateId()
+
+function save(displayName: string, serviceAccountJson: string) {
+ return createCredentialRecord(
+ {
+ workspaceId,
+ type: 'service_account',
+ userId,
+ providerId: POSTGRESQL_SERVICE_ACCOUNT_PROVIDER_ID,
+ displayName,
+ serviceAccountJson,
+ },
+ { authorizeWorkspace: true }
+ )
+}
+
+async function run(credentialId: string, runWorkspaceId: string) {
+ const handler = await getInternalToolOperationHandler('postgresql_query')
+ if (!handler) throw new Error('postgresql_query has no registered operation')
+ const result = await handler({
+ toolId: 'postgresql_query',
+ input: {
+ host: 'unreachable.invalid',
+ port: 1,
+ database: 'other',
+ username: 'other',
+ password: 'inline',
+ ssl: 'required',
+ query: 'SELECT current_user AS who, current_database() AS db',
+ },
+ headers: new Headers(),
+ context: {
+ workflowId: generateId(),
+ workspaceId: runWorkspaceId,
+ authorizedServiceAccountCredential: { credentialId, service: 'postgresql' },
+ },
+ requestId: generateId(),
+ })
+ if (!(result instanceof Response)) throw new Error('postgresql_query returned a file')
+ return result
+}
+
+describe('saved PostgreSQL connection', () => {
+ let credentialId: string
+
+ beforeAll(async () => {
+ Object.assign(env, { EGRESS_ALLOWED_HOSTS: '127.0.0.1' })
+ const now = new Date()
+ await db.insert(user).values({
+ id: userId,
+ name: 'Database connection fixture',
+ email: `${userId}@database-connection.test`,
+ emailVerified: true,
+ createdAt: now,
+ updatedAt: now,
+ })
+ for (const id of [workspaceId, otherWorkspaceId]) {
+ await insertWorkspaceFixture(db, {
+ id,
+ name: 'Database connection fixture',
+ ownerId: userId,
+ billedAccountUserId: userId,
+ })
+ await db.insert(permissions).values({
+ id: generateId(),
+ userId,
+ entityType: 'workspace',
+ entityId: id,
+ permissionType: 'admin',
+ })
+ }
+ }, 60_000)
+
+ afterAll(async () => {
+ await db.delete(credential).where(eq(credential.workspaceId, workspaceId))
+ await deleteWorkspaceFixture(db, inArray(workspace.id, [workspaceId, otherWorkspaceId]))
+ await db.delete(user).where(eq(user.id, userId))
+ })
+
+ it('saves a connection the server accepts', async () => {
+ const result = await save('Primary database', JSON.stringify(connection))
+
+ expect(result).toMatchObject({ success: true })
+ const [row] = await db
+ .select()
+ .from(credential)
+ .where(eq(credential.displayName, 'Primary database'))
+ expect(row).toMatchObject({
+ type: 'service_account',
+ providerId: POSTGRESQL_SERVICE_ACCOUNT_PROVIDER_ID,
+ workspaceId,
+ })
+ expect(row.encryptedServiceAccountKey).not.toContain(connection.password)
+ credentialId = row.id
+ })
+
+ it('refuses to save a connection the server rejects', async () => {
+ const result = await save(
+ 'Missing database',
+ JSON.stringify({ ...connection, database: `${connection.database}_missing` })
+ )
+
+ expect(result).toMatchObject({ success: false })
+ const rows = await db
+ .select({ id: credential.id })
+ .from(credential)
+ .where(eq(credential.displayName, 'Missing database'))
+ expect(rows).toHaveLength(0)
+ })
+
+ it('runs on the saved connection whatever connection fields the call carries', async () => {
+ const response = await run(credentialId, workspaceId)
+
+ expect(response.status).toBe(200)
+ expect(await response.json()).toMatchObject({
+ rows: [{ who: connection.username, db: connection.database }],
+ })
+ })
+
+ it('refuses the connection to a run in another workspace', async () => {
+ const response = await run(credentialId, otherWorkspaceId)
+
+ expect(response.status).toBe(403)
+ })
+
+ it('refuses a revoked connection', async () => {
+ await db
+ .update(credential)
+ .set({ revokedAt: new Date() })
+ .where(eq(credential.id, credentialId))
+
+ const response = await run(credentialId, workspaceId)
+
+ expect(response.status).toBe(403)
+ })
+})
diff --git a/apps/sim/lib/credentials/__integration__/saved-token-expiry.integration.ts b/apps/sim/lib/credentials/__integration__/saved-token-expiry.integration.ts
new file mode 100644
index 00000000000..1310caa2248
--- /dev/null
+++ b/apps/sim/lib/credentials/__integration__/saved-token-expiry.integration.ts
@@ -0,0 +1,283 @@
+/**
+ * A saved token whose vendor states an expiry at verification records it on the credential row,
+ * the workspace credential list reports it, and replacing the token replaces (or clears) it, so a
+ * reconnected credential never keeps the previous token's expiry. A provider refusing a token past
+ * that expiry is explained as the expiry, with the provider's own failure kept underneath.
+ */
+
+import { db } from '@sim/db'
+import { credential, member, organization, permissions, user, workspace } from '@sim/db/schema'
+import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures'
+import { jsonResponse } from '@sim/testing/helpers/http'
+import { posthogServerMock } from '@sim/testing/mocks/posthog-server.mock'
+import { generateId } from '@sim/utils/id'
+import { eq } from 'drizzle-orm'
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/lib/posthog/server', () => posthogServerMock)
+
+import {
+ toOrganizationCredential,
+ toWorkspaceCredential,
+} from '@/lib/credentials/application/presentation'
+import { updateCredentialRecord } from '@/lib/credentials/orchestration'
+import { createCredentialRecord } from '@/lib/credentials/orchestration/credential-create'
+import { listVisibleWorkspaceCredentials } from '@/lib/credentials/queries'
+import { explainExpiredSavedToken } from '@/lib/credentials/saved-token-expiry'
+import { GITHUB_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/credentials/token-service-accounts/descriptors'
+
+const userId = generateId()
+const workspaceId = generateId()
+const organizationId = generateId()
+const originalFetch = globalThis.fetch
+const DAY_MS = 24 * 60 * 60 * 1000
+
+/** Expiry GitHub reports for each fixture token; a token without one never expires. */
+const tokenExpirations = new Map()
+
+/** GitHub's header form, whole seconds with a numeric offset, here UTC's. */
+function githubExpirationHeader(expiresAt: Date): string {
+ return `${expiresAt.toISOString().slice(0, 19).replace('T', ' ')} +0000`
+}
+
+function expiringToken(daysAhead: number): { token: string; expiresAt: Date } {
+ const token = `github_pat_${generateId()}`
+ const expiresAt = new Date(Math.floor((Date.now() + daysAhead * DAY_MS) / 1000) * 1000)
+ tokenExpirations.set(token, expiresAt)
+ return { token, expiresAt }
+}
+
+async function storedExpiry(credentialId: string): Promise {
+ const [row] = await db
+ .select({ expiresAt: credential.accessTokenExpiresAt })
+ .from(credential)
+ .where(eq(credential.id, credentialId))
+ return row?.expiresAt ?? null
+}
+
+async function listedExpiry(credentialId: string): Promise {
+ const page = await listVisibleWorkspaceCredentials({
+ workspaceId,
+ userId,
+ workspaceAccess: { canAdmin: true },
+ })
+ const row = page.data.find((entry) => entry.id === credentialId)
+ if (!row) throw new Error('Expected the credential in the workspace list')
+ return toWorkspaceCredential(row).expiresAt
+}
+
+function create(displayName: string, apiToken: string) {
+ return createCredentialRecord(
+ {
+ workspaceId,
+ type: 'service_account',
+ userId,
+ providerId: GITHUB_SERVICE_ACCOUNT_PROVIDER_ID,
+ displayName,
+ apiToken,
+ },
+ { authorizeWorkspace: true }
+ )
+}
+
+async function reconnect(credentialId: string, apiToken: string) {
+ const [row] = await db.select().from(credential).where(eq(credential.id, credentialId))
+ return updateCredentialRecord({ credentialId, credential: row, actorUserId: userId, apiToken })
+}
+
+describe('saved token expiry', () => {
+ beforeAll(async () => {
+ const now = new Date()
+ await db.insert(user).values({
+ id: userId,
+ name: 'Token expiry fixture',
+ email: `${userId}@token-expiry.test`,
+ emailVerified: true,
+ createdAt: now,
+ updatedAt: now,
+ })
+ await insertWorkspaceFixture(db, {
+ id: workspaceId,
+ name: 'Token expiry fixture',
+ ownerId: userId,
+ billedAccountUserId: userId,
+ })
+ await db.insert(organization).values({
+ id: organizationId,
+ name: 'Token expiry fixture',
+ slug: organizationId,
+ createdAt: now,
+ updatedAt: now,
+ })
+ await db.insert(member).values({
+ id: generateId(),
+ userId,
+ organizationId,
+ role: 'owner',
+ createdAt: now,
+ })
+ await db.insert(permissions).values({
+ id: generateId(),
+ userId,
+ entityType: 'workspace',
+ entityId: workspaceId,
+ permissionType: 'admin',
+ })
+
+ vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => {
+ const target = typeof input === 'string' || input instanceof URL ? String(input) : input.url
+ if (target === 'https://api.github.com/user') {
+ const authorization = new Headers(init?.headers).get('Authorization') ?? ''
+ const expiresAt = tokenExpirations.get(authorization.replace(/^Bearer /, ''))
+ return jsonResponse(
+ { login: 'octo-bot', id: 1 },
+ expiresAt
+ ? {
+ headers: {
+ 'GitHub-Authentication-Token-Expiration': githubExpirationHeader(expiresAt),
+ },
+ }
+ : 200
+ )
+ }
+ return originalFetch(input, init)
+ })
+ }, 60_000)
+
+ afterAll(async () => {
+ vi.unstubAllGlobals()
+ await db.delete(credential).where(eq(credential.workspaceId, workspaceId))
+ await db.delete(credential).where(eq(credential.organizationId, organizationId))
+ await db.delete(organization).where(eq(organization.id, organizationId))
+ await deleteWorkspaceFixture(db, eq(workspace.id, workspaceId))
+ await db.delete(user).where(eq(user.id, userId))
+ })
+
+ it('records the expiry GitHub states for a new token and lists it', async () => {
+ const { token, expiresAt } = expiringToken(30)
+
+ const created = await create('Expiring token', token)
+
+ expect(created.success).toBe(true)
+ const credentialId = created.credential?.id ?? ''
+ expect(await storedExpiry(credentialId)).toEqual(expiresAt)
+ expect(await listedExpiry(credentialId)).toBe(expiresAt.toISOString())
+ })
+
+ it('records no expiry for a token that never expires', async () => {
+ const created = await create('Lasting token', `ghp_${generateId()}`)
+
+ const credentialId = created.credential?.id ?? ''
+ expect(await storedExpiry(credentialId)).toBeNull()
+ expect(await listedExpiry(credentialId)).toBeUndefined()
+ })
+
+ it('reports the expiry of an organization-owned token', async () => {
+ const { token, expiresAt } = expiringToken(30)
+
+ const created = await createCredentialRecord(
+ {
+ organizationId,
+ type: 'service_account',
+ userId,
+ providerId: GITHUB_SERVICE_ACCOUNT_PROVIDER_ID,
+ displayName: 'Organization token',
+ apiToken: token,
+ },
+ { authorizeWorkspace: false }
+ )
+
+ expect(created.success).toBe(true)
+ const [row] = await db
+ .select()
+ .from(credential)
+ .where(eq(credential.id, created.credential?.id ?? ''))
+ expect(toOrganizationCredential(row).expiresAt).toBe(expiresAt.toISOString())
+ })
+
+ it('replaces the expiry when the token is replaced with another expiring token', async () => {
+ const first = expiringToken(5)
+ const created = await create('Rotated token', first.token)
+ const credentialId = created.credential?.id ?? ''
+ const second = expiringToken(90)
+
+ expect((await reconnect(credentialId, second.token)).success).toBe(true)
+
+ expect(await storedExpiry(credentialId)).toEqual(second.expiresAt)
+ })
+
+ it('clears the expiry when the replacement token never expires', async () => {
+ const created = await create('Made permanent', expiringToken(5).token)
+ const credentialId = created.credential?.id ?? ''
+
+ expect((await reconnect(credentialId, `ghp_${generateId()}`)).success).toBe(true)
+
+ expect(await storedExpiry(credentialId)).toBeNull()
+ expect(await listedExpiry(credentialId)).toBeUndefined()
+ })
+
+ describe('explaining a refused token', () => {
+ const refusal = {
+ status: 401,
+ statusText: 'Unauthorized',
+ data: { message: 'Bad credentials' },
+ }
+
+ async function savedCredential(expiresAt: Date | null) {
+ const created = await create(`Explained ${generateId()}`, `ghp_${generateId()}`)
+ const credentialId = created.credential?.id ?? ''
+ await db
+ .update(credential)
+ .set({ accessTokenExpiresAt: expiresAt })
+ .where(eq(credential.id, credentialId))
+ return credentialId
+ }
+
+ it('names the expiry when the refused token is past it, keeping the provider failure', async () => {
+ const credentialId = await savedCredential(new Date('2026-01-15T00:00:00.000Z'))
+
+ const explained = await explainExpiredSavedToken(credentialId, refusal)
+
+ expect(explained?.message).toBe(
+ 'The saved GitHub personal access token expired on 2026-01-15 UTC. Create a new personal access token and reconnect the credential.'
+ )
+ expect(explained).toMatchObject({
+ status: 401,
+ statusText: 'Unauthorized',
+ data: { message: 'Bad credentials' },
+ })
+ })
+
+ it.each([
+ ['an expiry still ahead', new Date(Date.now() + 30 * DAY_MS)],
+ ['no recorded expiry', null],
+ ])('leaves the refusal to stand for %s', async (_, expiresAt) => {
+ const credentialId = await savedCredential(expiresAt)
+
+ expect(await explainExpiredSavedToken(credentialId, refusal)).toBeNull()
+ })
+
+ it('ignores the expiry of a credential that is not a saved service account', async () => {
+ const credentialId = generateId()
+ await db.insert(credential).values({
+ id: credentialId,
+ workspaceId,
+ type: 'personal_token',
+ providerId: 'gitlab',
+ displayName: 'Personal GitLab token',
+ providerSubjectId: '1',
+ providerTenantId: 'https://gitlab.example.com',
+ encryptedPersonalToken: 'sealed',
+ grantedScopes: ['api'],
+ accessTokenExpiresAt: new Date('2026-01-15T00:00:00.000Z'),
+ createdBy: userId,
+ })
+
+ expect(await explainExpiredSavedToken(credentialId, refusal)).toBeNull()
+ })
+
+ it('leaves the refusal to stand for a credential that no longer exists', async () => {
+ expect(await explainExpiredSavedToken(generateId(), refusal)).toBeNull()
+ })
+ })
+})
diff --git a/apps/sim/lib/credentials/application/prepare-workload-identity.test.ts b/apps/sim/lib/credentials/application/prepare-workload-identity.test.ts
index 9d6f5799f2b..eedb4b1dabc 100644
--- a/apps/sim/lib/credentials/application/prepare-workload-identity.test.ts
+++ b/apps/sim/lib/credentials/application/prepare-workload-identity.test.ts
@@ -197,6 +197,65 @@ const STORED_MODES = [
},
] as const
+const CUSTOM_AUDIENCE = 'sim-123456789012'
+
+/** One stored keyless secret per provider, each carrying {@link CUSTOM_AUDIENCE}. */
+const CUSTOM_AUDIENCE_SECRETS = [
+ {
+ provider: 'aws',
+ providerId: 'aws-service-account',
+ secret: {
+ type: 'aws_credential_v1',
+ config: { authType: 'oidc_role', roleArn, subject, audience: CUSTOM_AUDIENCE },
+ },
+ },
+ {
+ provider: 'databricks',
+ providerId: 'databricks-service-account',
+ secret: {
+ type: 'databricks_credential_v1',
+ config: {
+ authType: 'oidc_federation',
+ host: databricksHost,
+ clientId,
+ subject,
+ audience: CUSTOM_AUDIENCE,
+ },
+ },
+ },
+ {
+ provider: 'snowflake',
+ providerId: 'snowflake-service-account',
+ secret: {
+ ...keylessSecret,
+ config: { ...keylessSecret.config, audience: CUSTOM_AUDIENCE },
+ },
+ },
+ {
+ provider: 'microsoft',
+ providerId: 'microsoft-app-service-account',
+ secret: {
+ type: 'microsoft_app_credential_v1',
+ config: { ...appConfig, authType: 'federated', subject, audience: CUSTOM_AUDIENCE },
+ },
+ },
+ {
+ provider: 'google',
+ providerId: 'google-service-account',
+ secret: {
+ type: 'google_workload_identity_v1',
+ config: {
+ projectNumber: '123456789012',
+ poolId: 'sim-pool',
+ providerId: 'sim-oidc',
+ serviceAccountEmail: 'runner@analytics-prod.iam.gserviceaccount.com',
+ subject,
+ audience: CUSTOM_AUDIENCE,
+ },
+ },
+ },
+] as const
+
describe('preparing a reconnect', () => {
beforeEach(() => {
workspaceContextMockFns.mockLoadActiveWorkspaceApplicationContext.mockResolvedValue({
@@ -219,6 +278,25 @@ describe('preparing a reconnect', () => {
}
)
+ it.each(CUSTOM_AUDIENCE_SECRETS)(
+ 'reopens a $provider keyless credential on its custom audience',
+ async ({ provider, providerId, secret }) => {
+ reconnecting(secret, { providerId })
+ await expect(prepare(provider)).resolves.toMatchObject({
+ available: true,
+ reconnectAudience: CUSTOM_AUDIENCE,
+ })
+ }
+ )
+
+ it.each(STORED_MODES)(
+ 'reports no audience for a $provider credential stored as $expected',
+ async ({ provider, providerId, secret }) => {
+ reconnecting(secret, { providerId })
+ expect(await prepare(provider)).not.toHaveProperty('reconnectAudience')
+ }
+ )
+
it('reports the stored mode when this deployment cannot sign, so keyed modes still reopen', async () => {
Reflect.set(env, 'WORKLOAD_IDENTITY_SIGNING_KEY', undefined)
try {
@@ -235,11 +313,53 @@ describe('preparing a reconnect', () => {
}
})
+ it('reports a national-cloud Microsoft credential’s cloud, so the dialog reopens on it', async () => {
+ reconnecting(
+ {
+ type: 'microsoft_app_credential_v1',
+ config: { ...appConfig, authType: 'client_secret', clientSecret: 'value', cloud: 'usgov' },
+ },
+ { providerId: 'microsoft-app-service-account' }
+ )
+ await expect(prepare('microsoft')).resolves.toMatchObject({
+ reconnectAuthType: 'client_secret',
+ reconnectCloud: 'usgov',
+ })
+ })
+
+ it('reports no cloud for a global Microsoft credential or another provider', async () => {
+ reconnecting(STORED_MODES[9].secret, { providerId: 'microsoft-app-service-account' })
+ expect(await prepare('microsoft')).not.toHaveProperty('reconnectCloud')
+ reconnecting(keylessSecret)
+ expect(await prepare()).not.toHaveProperty('reconnectCloud')
+ })
+
it('gives Google no fixed audience', async () => {
reconnecting(STORED_MODES[11].secret, { providerId: 'google-service-account' })
expect(await prepare('google')).not.toHaveProperty('audience')
})
+ it('reports a keyless Google credential’s delegation so a reconnect keeps it', async () => {
+ reconnecting(
+ {
+ type: 'google_workload_identity_v1',
+ config: {
+ projectNumber: '123456789012',
+ poolId: 'sim-pool',
+ providerId: 'sim-oidc',
+ serviceAccountEmail: 'runner@analytics-prod.iam.gserviceaccount.com',
+ subject,
+ delegation: { user: 'alice@example.com', services: ['gmail', 'google-calendar'] },
+ },
+ },
+ { providerId: 'google-service-account' }
+ )
+ await expect(prepare('google')).resolves.toMatchObject({
+ reconnectAuthType: 'workload_identity',
+ reconnectDelegation: { user: 'alice@example.com', services: ['gmail', 'google-calendar'] },
+ })
+ })
+
it('reveals nothing about a credential the caller does not administer', async () => {
reconnecting(keylessSecret, { isAdmin: false })
expect(await prepare()).not.toHaveProperty('reconnectAuthType')
diff --git a/apps/sim/lib/credentials/application/prepare-workload-identity.ts b/apps/sim/lib/credentials/application/prepare-workload-identity.ts
index acf280b864e..2cee3688fde 100644
--- a/apps/sim/lib/credentials/application/prepare-workload-identity.ts
+++ b/apps/sim/lib/credentials/application/prepare-workload-identity.ts
@@ -4,11 +4,15 @@ import { OrchestrationError } from '@/lib/core/orchestration/types'
import { decryptSecret } from '@/lib/core/security/encryption'
import { getCredentialActorContext } from '@/lib/credentials/access'
import { credentialOperations } from '@/lib/credentials/application/operations'
-import { storedAwsAuthType } from '@/lib/credentials/aws-service-account.server'
-import { storedDatabricksAuthType } from '@/lib/credentials/databricks-service-account.server'
-import { isGoogleWorkloadIdentitySecret } from '@/lib/credentials/google-workload-identity'
-import { storedMicrosoftAppAuthType } from '@/lib/credentials/microsoft-app-service-account.server'
-import { storedSnowflakeAuthType } from '@/lib/credentials/snowflake-service-account.server'
+import { storedAwsReconnectFields } from '@/lib/credentials/aws-service-account.server'
+import { storedDatabricksReconnectFields } from '@/lib/credentials/databricks-service-account.server'
+import {
+ type GoogleStoredDelegation,
+ storedGoogleReconnectFields,
+} from '@/lib/credentials/google-workload-identity.server'
+import { storedMicrosoftAppReconnectFields } from '@/lib/credentials/microsoft-app-service-account.server'
+import type { MicrosoftCloudId } from '@/lib/credentials/microsoft-clouds'
+import { storedSnowflakeReconnectFields } from '@/lib/credentials/snowflake-service-account.server'
import {
fixedWorkloadIdentityAudience,
isWorkloadIdentityServiceAccountProviderId,
@@ -30,13 +34,23 @@ export interface PrepareWorkloadIdentityInput {
credentialId?: string
}
-/** The reconnected credential's stored mode; absent without one, or for anyone but its admin. */
-interface ReconnectAuthTypeField {
- reconnectAuthType?: WorkloadIdentityProviderAuthType
+/**
+ * What a reconnect reopens on, read from the stored secret: absent without a
+ * credential being reconnected, or for anyone but its admin.
+ */
+interface ReconnectFields {
+ /** The stored mode. */
+ reconnectAuthType?: WorkloadIdentityProviderAuthType
+ /** A keyless Google credential's domain-wide delegation, so a reconnect keeps it. */
+ reconnectDelegation?: GoogleStoredDelegation
+ /** A Microsoft app credential's national cloud; absent for a global one. */
+ reconnectCloud?: MicrosoftCloudId
+ /** A keyless credential's custom audience; absent when it uses the default. */
+ reconnectAudience?: string
}
export type PrepareWorkloadIdentityResult =
- | ({ available: false } & ReconnectAuthTypeField)
+ | ({ available: false } & ReconnectFields)
| ({
available: true
issuer: string
@@ -45,37 +59,59 @@ export type PrepareWorkloadIdentityResult =
subject: string
subjectProof: string
expiresAt: string
- } & ReconnectAuthTypeField)
+ } & ReconnectFields)
+
+/** What a provider's stored-secret reader returns: the non-secret fields a reconnect keeps. */
+interface StoredReconnectRead
{
+ authType?: WorkloadIdentityProviderAuthType
+ audience?: string
+ cloud?: MicrosoftCloudId
+ delegation?: GoogleStoredDelegation
+}
+
+function reconnectFrom
(
+ read: (decrypted: string) => StoredReconnectRead
+): (decrypted: string) => ReconnectFields
{
+ return (decrypted) => {
+ const { authType, audience, cloud, delegation } = read(decrypted)
+ return {
+ ...(authType ? { reconnectAuthType: authType } : {}),
+ ...(audience ? { reconnectAudience: audience } : {}),
+ ...(cloud ? { reconnectCloud: cloud } : {}),
+ ...(delegation ? { reconnectDelegation: delegation } : {}),
+ }
+ }
+}
/**
* Every keyless provider also has key-based modes under the same provider ID, so a
* reconnect reads the stored secret's mode and the dialog reopens on it rather than
- * on a default that would drop the credential's role, secret, or certificate.
+ * on a default that would drop the credential's role, secret, or certificate. A
+ * provider whose mode has further non-secret settings a reconnect must keep (a
+ * Google credential's domain-wide delegation, a Microsoft app credential's national
+ * cloud, a keyless credential's custom audience) reads those here too.
*/
-const STORED_AUTH_TYPE: {
- [P in WorkloadIdentityProvider]: (
- decrypted: string
- ) => WorkloadIdentityProviderAuthType
| undefined
+const STORED_RECONNECT: {
+ [P in WorkloadIdentityProvider]: (decrypted: string) => ReconnectFields
} = {
- aws: storedAwsAuthType,
- databricks: storedDatabricksAuthType,
- snowflake: storedSnowflakeAuthType,
- microsoft: storedMicrosoftAppAuthType,
- google: (decrypted) =>
- isGoogleWorkloadIdentitySecret(decrypted) ? 'workload_identity' : 'json_key',
+ aws: reconnectFrom<'aws'>(storedAwsReconnectFields),
+ databricks: reconnectFrom<'databricks'>(storedDatabricksReconnectFields),
+ snowflake: reconnectFrom<'snowflake'>(storedSnowflakeReconnectFields),
+ microsoft: reconnectFrom<'microsoft'>(storedMicrosoftAppReconnectFields),
+ google: reconnectFrom<'google'>(storedGoogleReconnectFields),
}
/**
- * The stored auth mode of a credential of this provider in this workspace, which
- * this person administers. Only the mode leaves the stored secret, and only for
- * someone allowed to reconnect the credential.
+ * What a reconnect of this provider's credential in this workspace, which this
+ * person administers, reopens on. Only these non-secret fields leave the stored
+ * secret, and only for someone allowed to reconnect the credential.
*/
-async function storedReconnectAuthType(
+async function storedReconnectFields(
provider: WorkloadIdentityProvider,
credentialId: string,
workspaceId: string,
userId: string
-): Promise {
+): Promise {
const access = await getCredentialActorContext(credentialId, userId, { workspaceId })
const row = access.credential
if (
@@ -87,10 +123,10 @@ async function storedReconnectAuthType(
workloadIdentityProviderFor(row.providerId) !== provider ||
!row.encryptedServiceAccountKey
) {
- return undefined
+ return {}
}
const { decrypted } = await decryptSecret(row.encryptedServiceAccountKey)
- return STORED_AUTH_TYPE[provider](decrypted)
+ return STORED_RECONNECT[provider](decrypted)
}
/**
@@ -110,15 +146,14 @@ export const prepareWorkloadIdentity = defineAuthorizedWorkspaceUseCase({
},
authorizationOptions: {},
execute: async ({ principal, input }): Promise => {
- const reconnectAuthType = input.credentialId
- ? await storedReconnectAuthType(
+ const reconnect = input.credentialId
+ ? await storedReconnectFields(
input.provider,
input.credentialId,
input.workspaceId,
requirePrincipalSubjectUserId(principal)
)
- : undefined
- const reconnect = reconnectAuthType ? { reconnectAuthType } : {}
+ : {}
// A deployment without a signing key offers only key- and secret-based credentials.
if (!(await isWorkloadIdentityConfigured())) return { available: false, ...reconnect }
const userId = requirePrincipalSubjectUserId(principal)
diff --git a/apps/sim/lib/credentials/application/presentation.ts b/apps/sim/lib/credentials/application/presentation.ts
index 50a45db01dd..c652fa13bf3 100644
--- a/apps/sim/lib/credentials/application/presentation.ts
+++ b/apps/sim/lib/credentials/application/presentation.ts
@@ -31,6 +31,15 @@ export function toV2Credential(row: PublicCredentialSource): V2Credential {
}
}
+/**
+ * When a credential's saved token stops working. Only a service account's pasted token and a
+ * personal token keep their expiry on the row; an OAuth grant's tokens live on its account.
+ */
+function savedTokenExpiresAt(row: CredentialRow | VisibleWorkspaceCredential): Date | null {
+ if (row.type !== 'service_account' && row.type !== 'personal_token') return null
+ return row.accessTokenExpiresAt ?? null
+}
+
/** Serializes credential metadata for the internal workspace surface. */
export function toWorkspaceCredential(
row: CredentialRow | VisibleWorkspaceCredential,
@@ -44,6 +53,7 @@ export function toWorkspaceCredential(
? 'admin'
: (access?.member?.role ?? ('role' in row ? row.role : undefined))
const status = access?.member?.status ?? (access?.isAdmin ? 'active' : undefined)
+ const expiresAt = savedTokenExpiresAt(row)
return {
id: row.id,
workspaceId: row.workspaceId,
@@ -59,6 +69,7 @@ export function toWorkspaceCredential(
...(row.type === 'personal_token' && row.providerTenantId
? { instanceUrl: row.providerTenantId }
: {}),
+ ...(expiresAt ? { expiresAt: expiresAt.toISOString() } : {}),
createdBy: row.createdBy,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
@@ -77,6 +88,7 @@ export function toOrganizationCredential(row: CredentialRow): OrganizationCreden
) {
throw new Error('Organization credential presentation requires an organization connection')
}
+ const expiresAt = savedTokenExpiresAt(row)
return {
id: row.id,
organizationId: row.organizationId,
@@ -89,6 +101,7 @@ export function toOrganizationCredential(row: CredentialRow): OrganizationCreden
accountId: row.accountId,
envKey: null,
envOwnerUserId: null,
+ ...(expiresAt ? { expiresAt: expiresAt.toISOString() } : {}),
createdBy: row.createdBy,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
diff --git a/apps/sim/lib/credentials/application/provider-catalog-contract.test.ts b/apps/sim/lib/credentials/application/provider-catalog-contract.test.ts
index af9fef25fec..bcb7b3290c0 100644
--- a/apps/sim/lib/credentials/application/provider-catalog-contract.test.ts
+++ b/apps/sim/lib/credentials/application/provider-catalog-contract.test.ts
@@ -25,6 +25,7 @@ import { v2ListCredentialProvidersContract } from '@/lib/api/contracts/v2/creden
import { env } from '@/lib/core/config/env'
import { listCredentialProviders } from '@/lib/credentials/application/list-credential-providers'
import { listCredentialProviderCatalog } from '@/lib/credentials/application/provider-catalog'
+import { DATABASE_CONNECTION_DESCRIPTORS } from '@/lib/credentials/database-connections/descriptors'
import { getMissingServiceAccountFields } from '@/lib/credentials/service-account-fields'
import {
isWorkloadIdentityServiceAccountProviderId,
@@ -79,6 +80,24 @@ describe('v2 credential provider catalog contract', () => {
).toBe(false)
})
+ it('tells API callers the region an optional region field takes when omitted', async () => {
+ const providers = await listProviders()
+ const sentry = providers.find(
+ (provider) =>
+ provider.type === 'service_account' && provider.providerId === 'sentry-service-account'
+ )
+
+ expect(sentry?.fields.find((field) => field.id === 'domain')).toMatchObject({
+ required: false,
+ defaultValue: 'us',
+ options: [
+ { value: 'us', label: 'US (sentry.io)' },
+ { value: 'de', label: 'EU (de.sentry.io)' },
+ ],
+ })
+ expect(responseSchema.safeParse({ data: providers, nextCursor: null }).success).toBe(true)
+ })
+
it('keeps the field-less GitHub installation in the internal catalog for existing credentials', async () => {
const catalog = await listCredentialProviderCatalog(createSessionPrincipal(), context)
const installation = catalog.find(
@@ -127,10 +146,12 @@ describe('v2 credential provider catalog contract', () => {
const snowflake = keyless.find(
(provider) => provider.providerId === 'snowflake-service-account'
)
- expect(snowflake?.fields.find((field) => field.id === 'serviceAccountJson')).toMatchObject({
- required: false,
- secret: true,
- })
+ const snowflakeConfiguration = snowflake?.fields.find(
+ (field) => field.id === 'serviceAccountJson'
+ )
+ expect(snowflakeConfiguration).toMatchObject({ required: false, secret: true })
+ expect(snowflakeConfiguration?.hint).toContain('"key_pair"')
+ expect(snowflakeConfiguration?.hint).toContain('Keyless')
})
it('offers no keyless mode where this deployment cannot sign', async () => {
@@ -151,7 +172,49 @@ describe('v2 credential provider catalog contract', () => {
(provider) =>
provider.type === 'service_account' && provider.providerId === 'snowflake-service-account'
)
- expect(snowflake?.fields.map((field) => field.id)).toEqual(['domain', 'apiToken'])
+ expect(snowflake?.fields.map((field) => field.id)).toEqual([
+ 'domain',
+ 'apiToken',
+ 'serviceAccountJson',
+ ])
+ const configuration = snowflake?.fields.find((field) => field.id === 'serviceAccountJson')
+ expect(configuration).toMatchObject({ required: false, secret: true, multiline: true })
+ expect(configuration?.hint).toContain('"key_pair"')
+ expect(configuration?.hint).not.toContain('Keyless')
+ })
+
+ it('offers the Snowflake key pair to organization credentials, which have no keyless mode', async () => {
+ Reflect.set(env, 'WORKLOAD_IDENTITY_SIGNING_KEY', SIGNING_KEY)
+ Reflect.set(env, 'WORKLOAD_IDENTITY_ISSUER', 'https://sim.example.com/oidc/workload')
+ const catalog = await listCredentialProviderCatalog(createSessionPrincipal(), {
+ organizationId: 'org-1',
+ })
+ const snowflake = catalog.find(
+ (provider) =>
+ provider.type === 'service_account' && provider.providerId === 'snowflake-service-account'
+ )
+
+ expect(snowflake?.type === 'service_account' && snowflake.keyless).toBeFalsy()
+ const configuration = snowflake?.fields.find((field) => field.id === 'serviceAccountJson')
+ expect(configuration?.hint).toContain('"key_pair"')
+ expect(configuration?.hint).not.toContain('Keyless')
+ })
+
+ it('tells headless clients the ServiceNow username belongs to the password sign-in only', async () => {
+ const providers = await listProviders('servicenow')
+ const servicenow = providers.find(
+ (provider) =>
+ provider.type === 'service_account' && provider.providerId === 'servicenow-service-account'
+ )
+
+ expect(responseSchema.safeParse({ data: providers, nextCursor: null }).success).toBe(true)
+ expect(servicenow?.fields.find((field) => field.id === 'providerWorkspaceId')).toMatchObject({
+ required: true,
+ requiredForAuthMethods: ['token'],
+ })
+ expect(
+ servicenow?.fields.find((field) => field.id === 'domain')?.requiredForAuthMethods
+ ).toBeUndefined()
})
it('lists the native Claude Platform provider when filtered', async () => {
@@ -180,4 +243,18 @@ describe('v2 credential provider catalog contract', () => {
expect.objectContaining({ providerId: CLAUDE_PROVIDER_ID, available: false })
)
})
+
+ it('tells API callers what each saved database connection transport-security mode trades away', async () => {
+ const providers = await listProviders()
+
+ for (const descriptor of Object.values(DATABASE_CONNECTION_DESCRIPTORS)) {
+ const provider = providers.find(
+ (entry) => entry.type === 'service_account' && entry.providerId === descriptor.providerId
+ )
+ const hint = provider?.fields.find((field) => field.id === 'serviceAccountJson')?.hint
+ for (const field of descriptor.tls) {
+ expect(hint).toContain(field.hint)
+ }
+ }
+ })
})
diff --git a/apps/sim/lib/credentials/application/provider-catalog.ts b/apps/sim/lib/credentials/application/provider-catalog.ts
index 4e00fb90985..1933f5089cc 100644
--- a/apps/sim/lib/credentials/application/provider-catalog.ts
+++ b/apps/sim/lib/credentials/application/provider-catalog.ts
@@ -5,7 +5,9 @@ import {
CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS,
type ClientCredentialAccountField,
} from '@/lib/credentials/client-credential-accounts/descriptors'
+import { getDatabaseConnectionDescriptor } from '@/lib/credentials/database-connections/descriptors'
import {
+ SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID,
TOKEN_SERVICE_ACCOUNT_DESCRIPTORS,
type TokenServiceAccountField,
} from '@/lib/credentials/token-service-accounts/descriptors'
@@ -54,6 +56,8 @@ export interface CredentialProviderField {
multiline: boolean
requiredForAuthMethods?: string[]
options?: CredentialProviderFieldOption[]
+ /** The value an optional field takes when it is omitted (a region's default). */
+ defaultValue?: string
hint?: string
}
@@ -119,13 +123,18 @@ function providerField(
id: field.id,
label: field.label,
placeholder: field.placeholder,
- required: !('optional' in field && field.optional),
+ required:
+ !('optional' in field && field.optional) &&
+ !('defaultValue' in field && field.defaultValue !== undefined),
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] } : {}),
+ ...('defaultValue' in field && field.defaultValue !== undefined
+ ? { defaultValue: field.defaultValue }
+ : {}),
...('hint' in field && field.hint
? { hint: field.hint }
: 'hintMessage' in field && field.hintMessage
@@ -157,8 +166,8 @@ function keylessModeFor(providerId: string): KeylessCatalogMode | undefined {
/**
* Adds a provider's keyless mode to its key-based descriptor, the same way for
* every keyless provider: one description suffix, and a hint on the
- * `serviceAccountJson` field — which a token provider such as Snowflake gains
- * as an optional alternative to its token fields.
+ * `serviceAccountJson` field, which is added as an optional alternative to the
+ * other fields when the descriptor has none.
*/
function withKeylessMode(
descriptor: ServiceAccountDescriptor,
@@ -167,7 +176,7 @@ function withKeylessMode(
if (!mode) return descriptor
const { keyless, scope } = mode
const scopeNote = scope ? ` ${scope}` : ''
- const hint = `Keyless: send the keylessConfig from Create workload identity subject with ${keyless.requiredConfigFields.join(', ')} added.${scopeNote}`
+ const hint = `Keyless: send the keylessConfig from Create workload identity subject with ${keyless.requiredConfigFields.join(', ')} added. Optionally add audience to have Sim sign for your own value instead of the default audience (for Google Cloud, the workload identity provider's resource name), and trust that value.${scopeNote}`
const configurationField = descriptor.fields.find((field) => field.id === 'serviceAccountJson')
const fields = configurationField
? descriptor.fields.map((field) =>
@@ -269,6 +278,39 @@ function getServiceAccountDescriptor(providerId: string): ServiceAccountDescript
],
}
}
+ const database = getDatabaseConnectionDescriptor(providerId)
+ if (database) {
+ const tls = database.tls
+ .map(
+ (field) =>
+ `${field.id} (${field.options.map((option) => option.value).join(', ')}; default ${field.defaultValue})`
+ )
+ .join(' and ')
+ const tlsTradeoffs = database.tls.map((field) => `${field.id}: ${field.hint}`).join(' ')
+ return {
+ name: `${database.label} connection`,
+ description: `Connect to ${database.label} with a saved host, database, login, and transport-security settings.`,
+ docsUrl: database.docsUrl,
+ fields: [
+ {
+ id: 'serviceAccountJson',
+ label: 'Connection configuration',
+ placeholder: JSON.stringify({
+ host: 'db.example.com',
+ port: database.defaultPort,
+ database: 'app',
+ username: database.usernamePlaceholder,
+ password: '...',
+ ...Object.fromEntries(database.tls.map((field) => [field.id, field.defaultValue])),
+ }),
+ required: true,
+ secret: true,
+ multiline: true,
+ hint: `JSON object with host, port, database, username, password, and optional ${tls}. Sim verifies it with SELECT 1 before saving. ${tlsTradeoffs}`,
+ },
+ ],
+ }
+ }
if (providerId === ORACLE_DATABASE_SERVICE_ACCOUNT_PROVIDER_ID) {
return {
name: 'Oracle Database connection',
@@ -434,6 +476,29 @@ function getServiceAccountDescriptor(providerId: string): ServiceAccountDescript
}
}
+ if (providerId === SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID) {
+ const tokenDescriptor = TOKEN_SERVICE_ACCOUNT_DESCRIPTORS[SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID]
+ return {
+ name: 'Snowflake connection',
+ description: 'Connect Snowflake with a programmatic access token or an RSA key pair.',
+ docsUrl: tokenDescriptor.docsUrl,
+ helpText: tokenDescriptor.helpText,
+ fields: [
+ ...tokenDescriptor.fields.map(providerField),
+ {
+ id: 'serviceAccountJson',
+ label: 'Connection configuration',
+ placeholder:
+ '{"authType":"key_pair","host":"myorg-myaccount.snowflakecomputing.com","user":"SIM_SERVICE","privateKey":"-----BEGIN PRIVATE KEY-----..."}',
+ required: false,
+ secret: true,
+ multiline: true,
+ hint: 'Send instead of the other fields. Key pair: JSON object with authType: "key_pair", host, user, and privateKey (an unencrypted RSA private key as PEM, 2048 to 4096 bits, whose public key is set on the user).',
+ },
+ ],
+ }
+ }
+
const tokenDescriptor = Object.hasOwn(TOKEN_SERVICE_ACCOUNT_DESCRIPTORS, providerId)
? TOKEN_SERVICE_ACCOUNT_DESCRIPTORS[
providerId as keyof typeof TOKEN_SERVICE_ACCOUNT_DESCRIPTORS
diff --git a/apps/sim/lib/credentials/aws-service-account.server.test.ts b/apps/sim/lib/credentials/aws-service-account.server.test.ts
index 9479f69c189..f99f068f03d 100644
--- a/apps/sim/lib/credentials/aws-service-account.server.test.ts
+++ b/apps/sim/lib/credentials/aws-service-account.server.test.ts
@@ -136,6 +136,75 @@ describe('saving a keyless AWS role', () => {
})
})
+describe('choosing the audience of a keyless AWS role', () => {
+ const CUSTOM_AUDIENCE = 'sim-123456789012'
+
+ beforeEach(() => {
+ hoisted.send.mockReset()
+ hoisted.mint.mockClear()
+ encryptionMockFns.mockEncryptSecret.mockReset()
+ encryptionMockFns.mockEncryptSecret.mockResolvedValue({ encrypted: 'ciphertext', iv: 'iv' })
+ roleTrusting(true)
+ })
+
+ function reconnectWith(audience?: string) {
+ return JSON.stringify({
+ authType: 'oidc_role',
+ roleArn: ROLE_ARN,
+ ...(audience ? { audience } : {}),
+ })
+ }
+
+ function storedConfig() {
+ return JSON.parse(encryptionMockFns.mockEncryptSecret.mock.calls[0][0]).config
+ }
+
+ it('verifies and probes the trust pin with a custom audience, and stores it', async () => {
+ await verifyAndEncryptAwsCredential(reconnectWith(CUSTOM_AUDIENCE), keylessReconnect.owner)
+
+ expect(hoisted.mint.mock.calls.length).toBeGreaterThanOrEqual(2)
+ for (const [input] of hoisted.mint.mock.calls) {
+ expect(input).toMatchObject({ audience: CUSTOM_AUDIENCE })
+ }
+ expect(hoisted.mint).toHaveBeenCalledWith(expect.objectContaining({ subject: SUBJECT }))
+ expect(hoisted.mint).toHaveBeenCalledWith(
+ expect.objectContaining({ subject: expect.not.stringMatching(SUBJECT) })
+ )
+ expect(storedConfig()).toMatchObject({ audience: CUSTOM_AUDIENCE })
+ })
+
+ it.each([undefined, 'sts.amazonaws.com'])(
+ 'mints the default audience and stores no audience key when given %s',
+ async (audience) => {
+ await verifyAndEncryptAwsCredential(reconnectWith(audience), keylessReconnect.owner)
+
+ expect(hoisted.mint).toHaveBeenCalled()
+ for (const [input] of hoisted.mint.mock.calls) {
+ expect(input).toMatchObject({ audience: 'sts.amazonaws.com' })
+ }
+ expect(storedConfig()).not.toHaveProperty('audience')
+ }
+ )
+
+ it('refuses another cloud’s default audience before signing anything', async () => {
+ await expect(
+ verifyAndEncryptAwsCredential(
+ reconnectWith('api://AzureADTokenExchange'),
+ keylessReconnect.owner
+ )
+ ).rejects.toThrow(/another cloud/)
+ expect(hoisted.mint).not.toHaveBeenCalled()
+ })
+
+ it('names the custom audience when the role refuses it', async () => {
+ hoisted.send.mockRejectedValue(accessDenied())
+
+ await expect(
+ verifyAndEncryptAwsCredential(reconnectWith(CUSTOM_AUDIENCE), keylessReconnect.owner)
+ ).rejects.toThrow(`audience ${CUSTOM_AUDIENCE}`)
+ })
+})
+
describe('loading a saved AWS credential for a run', () => {
beforeEach(() => resetDbChainMock())
@@ -194,4 +263,20 @@ describe("resolving a run's AWS session", () => {
expect(hoisted.send.mock.calls[0][0].input.DurationSeconds).toBe(durationSeconds)
}
)
+
+ it.each([
+ { stored: {}, audience: 'sts.amazonaws.com' },
+ { stored: { audience: 'sim-123456789012' }, audience: 'sim-123456789012' },
+ ])('mints a run token for the stored audience $audience', async ({ stored, audience }) => {
+ hoisted.mint.mockClear()
+ hoisted.send.mockResolvedValue({ Credentials: { ...session, Expiration: expiration } })
+
+ await resolveAwsSessionCredentials(
+ { authType: 'oidc_role', roleArn: ROLE_ARN, subject: SUBJECT, ...stored },
+ owner,
+ options
+ )
+
+ expect(hoisted.mint).toHaveBeenCalledWith(expect.objectContaining({ audience }))
+ })
})
diff --git a/apps/sim/lib/credentials/aws-service-account.server.ts b/apps/sim/lib/credentials/aws-service-account.server.ts
index 6bb817a7edb..513bd7ab74b 100644
--- a/apps/sim/lib/credentials/aws-service-account.server.ts
+++ b/apps/sim/lib/credentials/aws-service-account.server.ts
@@ -12,7 +12,12 @@ import { z } from 'zod'
import { awsRequestHandler } from '@/lib/core/network/proxy-agent.server'
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
import { failureClause, parseServiceAccountConfig } from '@/lib/credentials/service-account-config'
-import { WORKLOAD_IDENTITY_AUDIENCES } from '@/lib/credentials/workload-identity/audiences'
+import {
+ storedAudienceField,
+ storedWorkloadIdentityAudienceSchema,
+ WORKLOAD_IDENTITY_AUDIENCES,
+ workloadIdentityAudienceSchema,
+} from '@/lib/credentials/workload-identity/audiences'
import {
isWorkloadIdentityConfigured,
mintWorkloadIdentityToken,
@@ -43,6 +48,8 @@ import { AWS_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types'
* - `assume_role`: a key pair that assumes a role, optionally with an external ID.
* - `oidc_role`: no stored key at all — Sim signs a short-lived OIDC token for
* this credential's subject and exchanges it with STS for the customer's role.
+ * Its `aud` is the stored custom `audience`, else `sts.amazonaws.com`; either
+ * must be a client ID on the customer's IAM OIDC identity provider.
*/
const AWS_WORKLOAD_IDENTITY_AUDIENCE = WORKLOAD_IDENTITY_AUDIENCES.aws
@@ -85,6 +92,8 @@ const oidcRoleShape = {
authType: z.literal('oidc_role'),
roleArn: roleArnSchema,
subject: z.string().min(1).max(512),
+ /** Absent for the default audience, so earlier releases' strict schemas still parse it. */
+ audience: storedWorkloadIdentityAudienceSchema,
...commonShape,
}
@@ -108,6 +117,7 @@ const submittedConfigSchema = z.discriminatedUnion('authType', [
...oidcRoleShape,
subject: oidcRoleShape.subject.optional(),
subjectProof: z.string().min(1).max(512).optional(),
+ audience: workloadIdentityAudienceSchema('aws').optional(),
})
.strict(),
])
@@ -200,7 +210,7 @@ export async function resolveAwsSessionCredentials(
}
const webIdentityToken = await mintWorkloadIdentityToken({
subject: config.subject,
- audience: AWS_WORKLOAD_IDENTITY_AUDIENCE,
+ audience: config.audience ?? AWS_WORKLOAD_IDENTITY_AUDIENCE,
owner,
})
/**
@@ -225,13 +235,23 @@ export async function resolveAwsSessionCredentials(
return toSessionCredentials(response.Credentials, durationSeconds)
}
-/** The auth mode a decrypted AWS secret holds, or undefined when it is not one. */
-export function storedAwsAuthType(decrypted: string): AwsCredentialConfig['authType'] | undefined {
+/**
+ * A decrypted AWS secret's auth mode and, for a keyless role, its custom
+ * audience, for a reconnect to prefill. Empty when the secret is not one.
+ */
+export function storedAwsReconnectFields(decrypted: string): {
+ authType?: AwsCredentialConfig['authType']
+ audience?: string
+} {
try {
const parsed = storedSecretSchema.safeParse(JSON.parse(decrypted))
- return parsed.success ? parsed.data.config.authType : undefined
+ if (!parsed.success) return {}
+ const { config } = parsed.data
+ return config.authType === 'oidc_role'
+ ? { authType: config.authType, audience: config.audience }
+ : { authType: config.authType }
} catch {
- return undefined
+ return {}
}
}
@@ -263,7 +283,7 @@ function verificationFailure(config: AwsCredentialConfig, error: unknown): Error
const detail = failureClause(error)
if (config.authType === 'oidc_role') {
return new AwsCredentialError(
- `Sim could not assume this role: ${detail}. Check that the role trusts the Sim OIDC provider with this exact subject and audience ${AWS_WORKLOAD_IDENTITY_AUDIENCE}.`
+ `Sim could not assume this role: ${detail}. Check that the role trusts the Sim OIDC provider with this exact subject and audience ${config.audience ?? AWS_WORKLOAD_IDENTITY_AUDIENCE}, and that the IAM identity provider lists that audience.`
)
}
if (config.authType === 'assume_role') {
@@ -292,10 +312,11 @@ export async function verifyAndEncryptAwsCredential(
if (!(await isWorkloadIdentityConfigured())) {
throw new WorkloadIdentityNotConfiguredError('Use access keys.')
}
- const { subject, subjectProof, ...rest } = submitted
+ const { subject, subjectProof, audience, ...rest } = submitted
config = {
...rest,
subject: bindSubject(subject, subjectProof, readStoredSubject(owner.storedSecret), owner),
+ ...storedAudienceField(audience, AWS_WORKLOAD_IDENTITY_AUDIENCE),
}
} else {
config = submitted
diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/box.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/box.test.ts
index 4a46a16d2bb..e7ff2e4c558 100644
--- a/apps/sim/lib/credentials/client-credential-accounts/minters/box.test.ts
+++ b/apps/sim/lib/credentials/client-credential-accounts/minters/box.test.ts
@@ -99,12 +99,18 @@ describe('mintBoxServiceAccountToken', () => {
})
})
- it('throws provider_unavailable (not invalid_credentials) on a 429 rate limit', async () => {
- mockFetch.mockResolvedValueOnce(jsonResponse({ error: 'rate_limit_exceeded' }, 429))
+ it('throws provider_unavailable (not invalid_credentials) on a 429 rate limit, with its Retry-After', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(
+ { error: 'rate_limit_exceeded' },
+ { status: 429, headers: { 'Retry-After': '7' } }
+ )
+ )
await expect(mintBoxServiceAccountToken(FIELDS)).rejects.toMatchObject({
code: 'provider_unavailable',
status: 429,
+ retryAfterMs: 7_000,
})
})
})
diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/box.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/box.ts
index 58a17d48fc3..0cf59991317 100644
--- a/apps/sim/lib/credentials/client-credential-accounts/minters/box.ts
+++ b/apps/sim/lib/credentials/client-credential-accounts/minters/box.ts
@@ -14,6 +14,7 @@ import {
readProviderErrorSnippet,
requireClientSecret,
TokenServiceAccountValidationError,
+ throttledRetryAfterMs,
} from '@/lib/credentials/token-service-accounts/errors'
const logger = createLogger('BoxServiceAccountMinter')
@@ -176,10 +177,12 @@ export async function mintBoxServiceAccountToken(
...(hint ? { hint } : {}),
})
}
- throw new TokenServiceAccountValidationError('provider_unavailable', res.status, {
- step: 'box_token_mint',
- body,
- })
+ throw new TokenServiceAccountValidationError(
+ 'provider_unavailable',
+ res.status,
+ { step: 'box_token_mint', body },
+ { retryAfterMs: throttledRetryAfterMs(res) }
+ )
}
const payload = await parseProviderJson(res, 'box_token_mint')
diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/crowdstrike.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/crowdstrike.test.ts
index 8a6b2bbf57a..795bdc4ab3f 100644
--- a/apps/sim/lib/credentials/client-credential-accounts/minters/crowdstrike.test.ts
+++ b/apps/sim/lib/credentials/client-credential-accounts/minters/crowdstrike.test.ts
@@ -84,6 +84,39 @@ describe('CrowdStrike API client token exchange', () => {
await expect(mintCrowdStrikeServiceAccountToken(FIELDS)).rejects.toMatchObject({ code })
})
+ it("carries the token endpoint's Retry-After on a throttled answer", async () => {
+ mockFetch.mockResolvedValue(
+ jsonResponse(
+ { errors: [{ code: 429, message: 'rate limited' }] },
+ { status: 429, headers: { 'Retry-After': '7' } }
+ )
+ )
+
+ await expect(mintCrowdStrikeServiceAccountToken(FIELDS)).rejects.toMatchObject({
+ code: 'provider_unavailable',
+ retryAfterMs: 7_000,
+ })
+ })
+
+ it("keeps a throttled answer's status and Retry-After when its error body cannot be read", async () => {
+ mockFetch.mockResolvedValue(
+ new Response(
+ new ReadableStream({
+ start(controller) {
+ controller.error(new TypeError('terminated'))
+ },
+ }),
+ { status: 429, headers: { 'Retry-After': '7' } }
+ )
+ )
+
+ await expect(mintCrowdStrikeServiceAccountToken(FIELDS)).rejects.toMatchObject({
+ code: 'provider_unavailable',
+ status: 429,
+ retryAfterMs: 7_000,
+ })
+ })
+
it("reports a redirect to the API client's own cloud as a wrong cloud and never follows it", async () => {
mockFetch.mockResolvedValue(
new Response(null, {
diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/crowdstrike.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/crowdstrike.ts
index 6bf77c4c8fb..85474bf2121 100644
--- a/apps/sim/lib/credentials/client-credential-accounts/minters/crowdstrike.ts
+++ b/apps/sim/lib/credentials/client-credential-accounts/minters/crowdstrike.ts
@@ -82,7 +82,8 @@ export async function mintCrowdStrikeServiceAccountToken(
throw new TokenServiceAccountValidationError(
providerFailureCode(error.status),
error.status,
- { step: STEP, cloud }
+ { step: STEP, cloud },
+ { retryAfterMs: error.retryAfterMs }
)
}
throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/docusign.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/docusign.test.ts
index 51912c73c5a..ed68e865831 100644
--- a/apps/sim/lib/credentials/client-credential-accounts/minters/docusign.test.ts
+++ b/apps/sim/lib/credentials/client-credential-accounts/minters/docusign.test.ts
@@ -254,6 +254,17 @@ describe('mintDocuSignServiceAccountToken', () => {
})
})
+ it("carries the token endpoint's Retry-After on a throttled answer", async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse({ error: 'busy' }, { status: 429, headers: { 'Retry-After': '7' } })
+ )
+
+ await expect(mintDocuSignServiceAccountToken(FIELDS)).rejects.toMatchObject({
+ code: 'provider_unavailable',
+ retryAfterMs: 7_000,
+ })
+ })
+
it('keeps the key, assertion, and access token out of every failure', async () => {
const failures: unknown[] = []
mockFetch.mockResolvedValueOnce(
diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/docusign.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/docusign.ts
index 2a0aee7ee39..f7e7e300541 100644
--- a/apps/sim/lib/credentials/client-credential-accounts/minters/docusign.ts
+++ b/apps/sim/lib/credentials/client-credential-accounts/minters/docusign.ts
@@ -19,6 +19,7 @@ import {
parseProviderJson,
readProviderErrorSnippet,
TokenServiceAccountValidationError,
+ throttledRetryAfterMs,
} from '@/lib/credentials/token-service-accounts/errors'
const TOKEN_STEP = 'docusign_token_mint'
@@ -266,11 +267,12 @@ export async function mintDocuSignServiceAccountToken(
{ step: TOKEN_STEP, environment, body, ...(hint ? { hint } : {}) }
)
}
- throw new TokenServiceAccountValidationError('provider_unavailable', tokenRes.status, {
- step: TOKEN_STEP,
- environment,
- body,
- })
+ throw new TokenServiceAccountValidationError(
+ 'provider_unavailable',
+ tokenRes.status,
+ { step: TOKEN_STEP, environment, body },
+ { retryAfterMs: throttledRetryAfterMs(tokenRes) }
+ )
}
const payload = await parseProviderJson(tokenRes, TOKEN_STEP)
@@ -297,11 +299,12 @@ export async function mintDocuSignServiceAccountToken(
)
if (!userinfoRes.ok) {
const body = await readProviderErrorSnippet(userinfoRes)
- throw new TokenServiceAccountValidationError('provider_unavailable', userinfoRes.status, {
- step: USERINFO_STEP,
- environment,
- body,
- })
+ throw new TokenServiceAccountValidationError(
+ 'provider_unavailable',
+ userinfoRes.status,
+ { step: USERINFO_STEP, environment, body },
+ { retryAfterMs: throttledRetryAfterMs(userinfoRes) }
+ )
}
const userinfo = await parseProviderJson(userinfoRes, USERINFO_STEP)
const accounts = (Array.isArray(userinfo.accounts) ? userinfo.accounts : []).filter(
diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/helpscout.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/helpscout.test.ts
new file mode 100644
index 00000000000..9266a12175c
--- /dev/null
+++ b/apps/sim/lib/credentials/client-credential-accounts/minters/helpscout.test.ts
@@ -0,0 +1,24 @@
+import { jsonResponse } from '@sim/testing/helpers/http'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { mintHelpScoutServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/helpscout'
+
+const FIELDS = { clientId: 'helpscout-client', clientSecret: 'helpscout-secret', orgId: '' }
+
+const mockFetch = vi.fn()
+
+describe('Help Scout client credential exchange', () => {
+ beforeEach(() => {
+ mockFetch.mockReset()
+ vi.stubGlobal('fetch', mockFetch)
+ })
+
+ it("carries the token endpoint's Retry-After on a throttled answer", async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse({ error: 'busy' }, { status: 429, headers: { 'Retry-After': '7' } })
+ )
+
+ await expect(
+ mintHelpScoutServiceAccountToken(FIELDS, { skipIdentity: true })
+ ).rejects.toMatchObject({ code: 'provider_unavailable', status: 429, retryAfterMs: 7_000 })
+ })
+})
diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/helpscout.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/helpscout.ts
index 3ae63521c17..8b256d8dad7 100644
--- a/apps/sim/lib/credentials/client-credential-accounts/minters/helpscout.ts
+++ b/apps/sim/lib/credentials/client-credential-accounts/minters/helpscout.ts
@@ -11,6 +11,7 @@ import {
isTransientProviderStatus,
requireClientSecret,
TokenServiceAccountValidationError,
+ throttledRetryAfterMs,
} from '@/lib/credentials/token-service-accounts/errors'
const MAX_HELPSCOUT_AUTH_RESPONSE_BYTES = 64 * 1024
@@ -28,7 +29,8 @@ async function readHelpScoutAuthResponse(
? 'invalid_credentials'
: 'provider_unavailable',
response.status,
- { step }
+ { step },
+ { retryAfterMs: throttledRetryAfterMs(response) }
)
}
diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/jama.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/jama.test.ts
index 66592bf3efa..e7163b7dda9 100644
--- a/apps/sim/lib/credentials/client-credential-accounts/minters/jama.test.ts
+++ b/apps/sim/lib/credentials/client-credential-accounts/minters/jama.test.ts
@@ -30,4 +30,18 @@ describe('Jama credential validation', () => {
).rejects.toMatchObject({ code: expectedCode, status })
}
)
+
+ it("carries the token endpoint's Retry-After on a throttled answer", async () => {
+ inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValueOnce(
+ Response.json({ error: 'busy' }, { status: 429, headers: { 'Retry-After': '7' } })
+ )
+
+ await expect(
+ mintJamaServiceAccountToken({
+ orgId: 'https://example.jamacloud.com',
+ clientId: 'client-id',
+ clientSecret: 'client-secret',
+ })
+ ).rejects.toMatchObject({ code: 'provider_unavailable', status: 429, retryAfterMs: 7_000 })
+ })
})
diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/jama.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/jama.ts
index 22038795a26..463b9371dea 100644
--- a/apps/sim/lib/credentials/client-credential-accounts/minters/jama.ts
+++ b/apps/sim/lib/credentials/client-credential-accounts/minters/jama.ts
@@ -10,6 +10,7 @@ import {
isTransientProviderStatus,
requireClientSecret,
TokenServiceAccountValidationError,
+ throttledRetryAfterMs,
} from '@/lib/credentials/token-service-accounts/errors'
import { normalizeJamaInstanceUrl } from '@/lib/jama/client'
@@ -49,7 +50,9 @@ export async function mintJamaServiceAccountToken(
!isTransientProviderStatus(response.status)
? 'invalid_credentials'
: 'provider_unavailable',
- response.status
+ response.status,
+ undefined,
+ { retryAfterMs: throttledRetryAfterMs(response) }
)
}
return toRecord(await response.json())
diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/netsuite.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/netsuite.test.ts
index d211bde2cf0..57ffd1443f7 100644
--- a/apps/sim/lib/credentials/client-credential-accounts/minters/netsuite.test.ts
+++ b/apps/sim/lib/credentials/client-credential-accounts/minters/netsuite.test.ts
@@ -148,6 +148,22 @@ describe('mintNetSuiteServiceAccountToken', () => {
await expect(mintNetSuiteServiceAccountToken(FIELDS)).rejects.toMatchObject({ code, status })
})
+ it("carries the token endpoint's Retry-After on a throttled answer", async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi
+ .fn()
+ .mockResolvedValue(
+ jsonResponse({ error: 'busy' }, { status: 429, headers: { 'Retry-After': '7' } })
+ )
+ )
+
+ await expect(mintNetSuiteServiceAccountToken(FIELDS)).rejects.toMatchObject({
+ code: 'provider_unavailable',
+ retryAfterMs: 7_000,
+ })
+ })
+
it('redacts credentials and a reflected assertion from provider error metadata', async () => {
let assertion = ''
vi.stubGlobal(
diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/netsuite.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/netsuite.ts
index d0737e1d68a..9e3738e5970 100644
--- a/apps/sim/lib/credentials/client-credential-accounts/minters/netsuite.ts
+++ b/apps/sim/lib/credentials/client-credential-accounts/minters/netsuite.ts
@@ -17,6 +17,7 @@ import { tenantPrincipal } from '@/lib/credentials/principal'
import {
isTransientProviderStatus,
TokenServiceAccountValidationError,
+ throttledRetryAfterMs,
} from '@/lib/credentials/token-service-accounts/errors'
const TOKEN_PATH = '/services/rest/auth/oauth2/v1/token'
@@ -141,10 +142,12 @@ async function exchangeNetSuiteToken(
response.status >= 400 && response.status < 500 && !isTransientProviderStatus(response.status)
? 'invalid_credentials'
: 'provider_unavailable'
- throw new TokenServiceAccountValidationError(code, response.status, {
- step: TOKEN_MINT_STEP,
- body: sanitizeNetSuiteTokenError(body, fields, assertion),
- })
+ throw new TokenServiceAccountValidationError(
+ code,
+ response.status,
+ { step: TOKEN_MINT_STEP, body: sanitizeNetSuiteTokenError(body, fields, assertion) },
+ { retryAfterMs: throttledRetryAfterMs(response) }
+ )
}
let payload: NetSuiteTokenResponse
diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/ramp.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/ramp.test.ts
index f620f90fd59..4a32ae86b6a 100644
--- a/apps/sim/lib/credentials/client-credential-accounts/minters/ramp.test.ts
+++ b/apps/sim/lib/credentials/client-credential-accounts/minters/ramp.test.ts
@@ -207,6 +207,16 @@ describe('Ramp client credential exchange', () => {
}
)
+ it("carries the token endpoint's Retry-After on a throttled answer", async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse({ error: 'busy' }, { status: 429, headers: { 'Retry-After': '7' } })
+ )
+
+ await expect(mintRampServiceAccountToken(FIELDS, { skipIdentity: true })).rejects.toMatchObject(
+ { code: 'provider_unavailable', retryAfterMs: 7_000 }
+ )
+ })
+
it.each([400, 401, 403, 408, 429, 500])(
'classifies provider failure %s without retaining its secret-bearing body',
async (status) => {
diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/ramp.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/ramp.ts
index 65921857245..994c2b6bfdd 100644
--- a/apps/sim/lib/credentials/client-credential-accounts/minters/ramp.ts
+++ b/apps/sim/lib/credentials/client-credential-accounts/minters/ramp.ts
@@ -11,6 +11,7 @@ import {
isTransientProviderStatus,
requireClientSecret,
TokenServiceAccountValidationError,
+ throttledRetryAfterMs,
} from '@/lib/credentials/token-service-accounts/errors'
import { getScopesForService } from '@/lib/oauth/utils'
@@ -29,7 +30,8 @@ async function readRampAuthResponse(
? 'invalid_credentials'
: 'provider_unavailable',
response.status,
- { step }
+ { step },
+ { retryAfterMs: throttledRetryAfterMs(response) }
)
}
diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/sailpoint.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/sailpoint.test.ts
index 186612668a7..912da47f22d 100644
--- a/apps/sim/lib/credentials/client-credential-accounts/minters/sailpoint.test.ts
+++ b/apps/sim/lib/credentials/client-credential-accounts/minters/sailpoint.test.ts
@@ -102,6 +102,92 @@ describe('SailPoint API client token exchange', () => {
await expect(mintSailPointServiceAccountToken(FIELDS)).rejects.toMatchObject({ code })
})
+ it("carries the token endpoint's Retry-After on an unavailable answer", async () => {
+ mockFetch.mockResolvedValue(
+ jsonResponse({ error: 'unavailable' }, { status: 503, headers: { 'Retry-After': '7' } })
+ )
+
+ await expect(mintSailPointServiceAccountToken(FIELDS)).rejects.toMatchObject({
+ code: 'provider_unavailable',
+ retryAfterMs: 7_000,
+ })
+ })
+
+ it('waits out one short throttle and mints on the retry', async () => {
+ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
+ try {
+ mockFetch
+ .mockResolvedValueOnce(
+ jsonResponse({ error: 'rate limited' }, { status: 429, headers: { 'Retry-After': '2' } })
+ )
+ .mockResolvedValueOnce(jsonResponse({ access_token: 'sp-access', expires_in: 749 }))
+
+ const minted = mintSailPointServiceAccountToken(FIELDS)
+ await vi.advanceTimersByTimeAsync(1_999)
+ expect(mockFetch).toHaveBeenCalledOnce()
+ await vi.advanceTimersByTimeAsync(1)
+
+ await expect(minted).resolves.toMatchObject({ accessToken: 'sp-access' })
+ expect(mockFetch).toHaveBeenCalledTimes(2)
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('retries a short throttle only once and returns the second answer with its Retry-After', async () => {
+ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
+ try {
+ mockFetch.mockImplementation(async () =>
+ jsonResponse({ error: 'rate limited' }, { status: 429, headers: { 'Retry-After': '1' } })
+ )
+
+ const minted = mintSailPointServiceAccountToken(FIELDS)
+ const settled = expect(minted).rejects.toMatchObject({
+ code: 'provider_unavailable',
+ status: 429,
+ retryAfterMs: 1_000,
+ })
+ await vi.advanceTimersByTimeAsync(10_000)
+
+ await settled
+ expect(mockFetch).toHaveBeenCalledTimes(2)
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('returns a long throttle with its Retry-After instead of retrying it', async () => {
+ mockFetch.mockImplementation(async () =>
+ jsonResponse({ error: 'rate limited' }, { status: 429, headers: { 'Retry-After': '120' } })
+ )
+
+ await expect(mintSailPointServiceAccountToken(FIELDS)).rejects.toMatchObject({
+ code: 'provider_unavailable',
+ status: 429,
+ retryAfterMs: 120_000,
+ })
+ expect(mockFetch).toHaveBeenCalledOnce()
+ })
+
+ it("keeps a throttled answer's status and Retry-After when its error body cannot be read", async () => {
+ mockFetch.mockResolvedValue(
+ new Response(
+ new ReadableStream({
+ start(controller) {
+ controller.error(new TypeError('terminated'))
+ },
+ }),
+ { status: 429, headers: { 'Retry-After': '120' } }
+ )
+ )
+
+ await expect(mintSailPointServiceAccountToken(FIELDS)).rejects.toMatchObject({
+ code: 'provider_unavailable',
+ status: 429,
+ retryAfterMs: 120_000,
+ })
+ })
+
it('treats a network failure or refused redirect as a provider failure', async () => {
mockFetch.mockRejectedValue(new TypeError('fetch failed: unexpected redirect'))
diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/sailpoint.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/sailpoint.ts
index 9a8683ae38a..5691a10d1d0 100644
--- a/apps/sim/lib/credentials/client-credential-accounts/minters/sailpoint.ts
+++ b/apps/sim/lib/credentials/client-credential-accounts/minters/sailpoint.ts
@@ -18,8 +18,10 @@ import {
} from '@/lib/internal/sailpoint/client'
const STEP = 'sailpoint_token_mint'
-/** Bounds the whole exchange, including its 429 backoff retries. */
+/** Bounds the whole exchange. */
const TOKEN_EXCHANGE_TIMEOUT_MS = 30_000
+/** The longest throttle the mint waits out in place, leaving both requests well inside the exchange budget. */
+const MAX_IN_PLACE_THROTTLE_WAIT_MS = 5_000
/**
* Mints a SailPoint Identity Security Cloud access token for an API client or
@@ -32,6 +34,10 @@ const TOKEN_EXCHANGE_TIMEOUT_MS = 30_000
* from `expires_in`; the credential cache re-mints before it lapses.
*
* The token response names no identity, so the connect-time check is the mint.
+ * A 429 asking for at most {@link MAX_IN_PLACE_THROTTLE_WAIT_MS} is retried once
+ * here, so a brief throttle does not fail a cold mint or a connect-time check.
+ * A longer one, or a second 429, is returned at once with its `Retry-After`, and
+ * the credential resolver replays it for that long (within its own bounds).
*
* @see https://developer.sailpoint.com/docs/api/authentication
*/
@@ -57,7 +63,8 @@ export async function mintSailPointServiceAccountToken(
tokenUrl,
fields.clientId,
clientSecret,
- options?.signal ? AbortSignal.any([options.signal, timeout]) : timeout
+ options?.signal ? AbortSignal.any([options.signal, timeout]) : timeout,
+ { maxThrottleRetries: 1, maxThrottleWaitMs: MAX_IN_PLACE_THROTTLE_WAIT_MS }
)
} catch (error) {
options?.signal?.throwIfAborted()
@@ -68,7 +75,8 @@ export async function mintSailPointServiceAccountToken(
{
step: STEP,
tenant: host,
- }
+ },
+ { retryAfterMs: error.retryAfterMs }
)
}
if (isHostNotFound(error)) {
diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.test.ts
index d918dac7a6c..7516fe0cf35 100644
--- a/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.test.ts
+++ b/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.test.ts
@@ -130,12 +130,18 @@ describe('mintSalesforceServiceAccountToken', () => {
})
})
- it('throws provider_unavailable (not invalid_credentials) on a 429 rate limit', async () => {
- mockFetch.mockResolvedValueOnce(jsonResponse({ error: 'rate_limit_exceeded' }, 429))
+ it('throws provider_unavailable (not invalid_credentials) on a 429 rate limit, with its Retry-After', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(
+ { error: 'rate_limit_exceeded' },
+ { status: 429, headers: { 'Retry-After': '7' } }
+ )
+ )
await expect(mintSalesforceServiceAccountToken(FIELDS)).rejects.toMatchObject({
code: 'provider_unavailable',
status: 429,
+ retryAfterMs: 7_000,
})
})
diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.ts
index 495dbb48310..87cc11ad645 100644
--- a/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.ts
+++ b/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.ts
@@ -21,6 +21,7 @@ import {
providerFailureReason,
readProviderErrorSnippet,
TokenServiceAccountValidationError,
+ throttledRetryAfterMs,
} from '@/lib/credentials/token-service-accounts/errors'
/**
@@ -463,11 +464,12 @@ export async function mintSalesforceServiceAccountToken(
...(hint ? { hint } : {}),
})
}
- throw new TokenServiceAccountValidationError('provider_unavailable', res.status, {
- step: TOKEN_MINT_STEP,
- host,
- body,
- })
+ throw new TokenServiceAccountValidationError(
+ 'provider_unavailable',
+ res.status,
+ { step: TOKEN_MINT_STEP, host, body },
+ { retryAfterMs: throttledRetryAfterMs(res) }
+ )
}
const payload = await parseProviderJson(res, TOKEN_MINT_STEP)
diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/zendesk.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/zendesk.test.ts
index 00ccdccd258..a22b744cdd5 100644
--- a/apps/sim/lib/credentials/client-credential-accounts/minters/zendesk.test.ts
+++ b/apps/sim/lib/credentials/client-credential-accounts/minters/zendesk.test.ts
@@ -153,6 +153,17 @@ describe('Zendesk credential minting', () => {
await expect(mintZendeskServiceAccountToken(CLIENT)).rejects.toMatchObject({ code })
})
+ it("carries the token endpoint's Retry-After on a throttled answer", async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse({ error: 'busy' }, { status: 429, headers: { 'Retry-After': '7' } })
+ )
+
+ await expect(mintZendeskServiceAccountToken(CLIENT)).rejects.toMatchObject({
+ code: 'provider_unavailable',
+ retryAfterMs: 7_000,
+ })
+ })
+
it('refuses a token response without an access token', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ token_type: 'bearer' }))
diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/zendesk.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/zendesk.ts
index ad7bae47c3a..062ad14b05d 100644
--- a/apps/sim/lib/credentials/client-credential-accounts/minters/zendesk.ts
+++ b/apps/sim/lib/credentials/client-credential-accounts/minters/zendesk.ts
@@ -18,6 +18,7 @@ import {
providerFailureCode,
readProviderErrorSnippet,
TokenServiceAccountValidationError,
+ throttledRetryAfterMs,
} from '@/lib/credentials/token-service-accounts/errors'
import { zendeskApiTokenCredential } from '@/tools/zendesk/types'
@@ -64,7 +65,8 @@ async function throwForZendeskResponse(response: Response, step: string): Promis
throw new TokenServiceAccountValidationError(
response.status === 404 ? 'site_not_found' : providerFailureCode(response.status),
response.status,
- { step, body }
+ { step, body },
+ { retryAfterMs: throttledRetryAfterMs(response) }
)
}
diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.test.ts
index a0d87d76ee7..a5342b83433 100644
--- a/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.test.ts
+++ b/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.test.ts
@@ -187,12 +187,15 @@ describe('mintZohoDeskServiceAccountToken', () => {
})
})
- it('throws provider_unavailable (not invalid_credentials) on a 429 rate limit', async () => {
- mockFetch.mockResolvedValueOnce(jsonResponse({ error: 'too_many_requests' }, 429))
+ it('throws provider_unavailable (not invalid_credentials) on a 429 rate limit, with its Retry-After', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse({ error: 'too_many_requests' }, { status: 429, headers: { 'Retry-After': '7' } })
+ )
await expect(mintZohoDeskServiceAccountToken(FIELDS)).rejects.toMatchObject({
code: 'provider_unavailable',
status: 429,
+ retryAfterMs: 7_000,
})
})
})
diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.ts
index 4113ce36120..6960d0583f4 100644
--- a/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.ts
+++ b/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.ts
@@ -20,6 +20,7 @@ import {
readProviderErrorSnippet,
requireClientSecret,
TokenServiceAccountValidationError,
+ throttledRetryAfterMs,
} from '@/lib/credentials/token-service-accounts/errors'
import { getCanonicalScopesForProvider } from '@/lib/oauth/utils'
import { tryDeriveZohoDeskBaseFromApiDomain } from '@/tools/zoho_desk/host-allowlist'
@@ -199,12 +200,17 @@ export async function mintZohoDeskServiceAccountToken(
if (res.status >= 400 && res.status < 500 && !isTransientProviderStatus(res.status)) {
throw invalidCredentials(res.status, soid, dataCenter.id, body, zohoBodyError(body))
}
- throw new TokenServiceAccountValidationError('provider_unavailable', res.status, {
- step: STEP,
- soid,
- dataCenter: dataCenter.id,
- body,
- })
+ throw new TokenServiceAccountValidationError(
+ 'provider_unavailable',
+ res.status,
+ {
+ step: STEP,
+ soid,
+ dataCenter: dataCenter.id,
+ body,
+ },
+ { retryAfterMs: throttledRetryAfterMs(res) }
+ )
}
const payload = await parseProviderJson(res, STEP)
diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.test.ts
index 63cf63dd9a8..6421913c8d1 100644
--- a/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.test.ts
+++ b/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.test.ts
@@ -69,12 +69,18 @@ describe('mintZoomServiceAccountToken', () => {
})
})
- it('throws provider_unavailable (not invalid_credentials) on a 429 rate limit', async () => {
- mockFetch.mockResolvedValueOnce(jsonResponse({ error: 'rate_limit_exceeded' }, 429))
+ it('throws provider_unavailable (not invalid_credentials) on a 429 rate limit, with its Retry-After', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(
+ { error: 'rate_limit_exceeded' },
+ { status: 429, headers: { 'Retry-After': '7' } }
+ )
+ )
await expect(mintZoomServiceAccountToken(FIELDS)).rejects.toMatchObject({
code: 'provider_unavailable',
status: 429,
+ retryAfterMs: 7_000,
})
})
})
diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.ts
index 4d86f95da32..89daf45c2a7 100644
--- a/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.ts
+++ b/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.ts
@@ -11,6 +11,7 @@ import {
readProviderErrorSnippet,
requireClientSecret,
TokenServiceAccountValidationError,
+ throttledRetryAfterMs,
} from '@/lib/credentials/token-service-accounts/errors'
const ZOOM_TOKEN_URL = 'https://zoom.us/oauth/token'
@@ -88,10 +89,12 @@ export async function mintZoomServiceAccountToken(
...(hint ? { hint } : {}),
})
}
- throw new TokenServiceAccountValidationError('provider_unavailable', res.status, {
- step: 'zoom_token_mint',
- body,
- })
+ throw new TokenServiceAccountValidationError(
+ 'provider_unavailable',
+ res.status,
+ { step: 'zoom_token_mint', body },
+ { retryAfterMs: throttledRetryAfterMs(res) }
+ )
}
const payload = await parseProviderJson(res, 'zoom_token_mint')
diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/zoominfo.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/zoominfo.test.ts
index 446f1ec8a7e..ddd9baf0210 100644
--- a/apps/sim/lib/credentials/client-credential-accounts/minters/zoominfo.test.ts
+++ b/apps/sim/lib/credentials/client-credential-accounts/minters/zoominfo.test.ts
@@ -64,6 +64,17 @@ describe('ZoomInfo client credentials exchange', () => {
await expect(mintZoomInfoServiceAccountToken(FIELDS)).rejects.toMatchObject({ code })
})
+ it("carries the token endpoint's Retry-After on a throttled answer", async () => {
+ mockSecureFetchWithValidation.mockResolvedValue(
+ Response.json({ error: 'busy' }, { status: 429, headers: { 'Retry-After': '7' } })
+ )
+
+ await expect(mintZoomInfoServiceAccountToken(FIELDS)).rejects.toMatchObject({
+ code: 'provider_unavailable',
+ retryAfterMs: 7_000,
+ })
+ })
+
it('treats a refused redirect or network failure as a provider failure', async () => {
mockSecureFetchWithValidation.mockRejectedValue(new Error('Too many redirects (max: 0)'))
diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/zoominfo.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/zoominfo.ts
index c7bda0f1c68..9b72f30d82c 100644
--- a/apps/sim/lib/credentials/client-credential-accounts/minters/zoominfo.ts
+++ b/apps/sim/lib/credentials/client-credential-accounts/minters/zoominfo.ts
@@ -56,7 +56,8 @@ export async function mintZoomInfoServiceAccountToken(
throw new TokenServiceAccountValidationError(
providerFailureCode(error.tokenStatus),
error.tokenStatus,
- { step: STEP }
+ { step: STEP },
+ { retryAfterMs: error.retryAfterMs }
)
}
throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
diff --git a/apps/sim/lib/credentials/client-credential-accounts/vanta-token.integration.ts b/apps/sim/lib/credentials/client-credential-accounts/vanta-token.integration.ts
index 2ba5cd6f308..f015a7bf441 100644
--- a/apps/sim/lib/credentials/client-credential-accounts/vanta-token.integration.ts
+++ b/apps/sim/lib/credentials/client-credential-accounts/vanta-token.integration.ts
@@ -137,6 +137,25 @@ beforeAll(async () => {
)
return
}
+ if (parsed.client_id.endsWith('-throttled')) {
+ response.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '90' })
+ response.end(JSON.stringify({ error: 'rate_limited' }))
+ return
+ }
+ if (parsed.client_id.endsWith('-rate-limited')) {
+ response.writeHead(429, { 'Content-Type': 'application/json' })
+ response.end(JSON.stringify({ error: 'rate_limited' }))
+ return
+ }
+ if (parsed.client_id.endsWith('-outage')) {
+ response.writeHead(502).end()
+ return
+ }
+ if (parsed.client_id.endsWith('-garbled')) {
+ response.writeHead(200, { 'Content-Type': 'text/html' })
+ response.end('upstream proxy error')
+ return
+ }
await sleep(15)
const identity = `${request.headers['x-provider-origin']}:${parsed.client_id}`
const issued = (applications.get(identity)?.issued ?? 0) + 1
@@ -321,6 +340,69 @@ describe('Vanta application token lifecycle against shared storage', () => {
expect(await isActive(next.accessToken)).toBe(true)
})
+ it('waits as long as a throttled Vanta asks before any worker asks again', async () => {
+ const input = fields('throttled')
+ const keys = previousBuildKeys(input, READ)
+ const count = tokenRequests
+ const before = Date.now()
+
+ await expect(resolveVantaClientToken(input)).rejects.toMatchObject({
+ code: 'provider_unavailable',
+ status: 429,
+ })
+ await expect(resolveVantaClientToken(input)).rejects.toMatchObject({ status: 429 })
+
+ expect(tokenRequests).toBe(count + 1)
+ const stored = await readStoredRow(keys.failureKey)
+ expect(stored.expiresAt.getTime() - before).toBeGreaterThanOrEqual(90_000)
+ expect(stored.expiresAt.getTime() - Date.now()).toBeLessThanOrEqual(90_000)
+ })
+
+ it('waits at least a minute after a Vanta 429 that names no Retry-After', async () => {
+ const input = fields('rate-limited')
+ const keys = previousBuildKeys(input, READ)
+ const before = Date.now()
+
+ await expect(resolveVantaClientToken(input)).rejects.toMatchObject({ status: 429 })
+
+ const stored = await readStoredRow(keys.failureKey)
+ expect(stored.expiresAt.getTime() - before).toBeGreaterThanOrEqual(60_000)
+ })
+
+ it('asks a failing Vanta for a token at most once per 15 seconds, within its 5 tokens a minute', async () => {
+ const input = fields('outage')
+ const keys = previousBuildKeys(input, READ)
+ const count = tokenRequests
+ const before = Date.now()
+
+ await expect(resolveVantaClientToken(input)).rejects.toMatchObject({
+ code: 'provider_unavailable',
+ status: 502,
+ })
+
+ expect(tokenRequests).toBe(count + 1)
+ const stored = await readStoredRow(keys.failureKey)
+ expect(stored.expiresAt.getTime() - before).toBeGreaterThanOrEqual(15_000)
+ })
+
+ it('paces a Vanta that answers 200 with an unreadable body like any other failing exchange', async () => {
+ const input = fields('garbled')
+ const keys = previousBuildKeys(input, READ)
+ const count = tokenRequests
+ const before = Date.now()
+
+ await expect(resolveVantaClientToken(input)).rejects.toMatchObject({
+ code: 'provider_unavailable',
+ })
+ await expect(resolveVantaClientToken(input)).rejects.toMatchObject({
+ code: 'provider_unavailable',
+ })
+
+ expect(tokenRequests).toBe(count + 1)
+ const stored = await readStoredRow(keys.failureKey)
+ expect(stored.expiresAt.getTime() - before).toBeGreaterThanOrEqual(15_000)
+ })
+
it('fails closed when coordination storage fails', async () => {
const count = tokenRequests
vi.spyOn(db, 'transaction').mockRejectedValueOnce(new Error('database unavailable'))
diff --git a/apps/sim/lib/credentials/client-credential-accounts/vanta-token.ts b/apps/sim/lib/credentials/client-credential-accounts/vanta-token.ts
index 2d1bc48ec70..a3ea31abb02 100644
--- a/apps/sim/lib/credentials/client-credential-accounts/vanta-token.ts
+++ b/apps/sim/lib/credentials/client-credential-accounts/vanta-token.ts
@@ -1,5 +1,5 @@
import { isRecordLike } from '@sim/utils/object'
-import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits'
+import { PayloadSizeLimitError, readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits'
import { VANTA_PERMISSION_OPTIONS } from '@/lib/credentials/client-credential-accounts/descriptors'
import {
invalidateCoordinatedClientToken,
@@ -10,10 +10,16 @@ import {
isTransientProviderStatus,
requireClientSecret,
TokenServiceAccountValidationError,
+ throttledRetryAfterMs,
} from '@/lib/credentials/token-service-accounts/errors'
const SCOPES = VANTA_PERMISSION_OPTIONS.map((option) => option.value)
const EXPIRY_BUFFER_MS = 30_000
+/**
+ * Vanta answers 429 when an application mints more than 5 tokens a minute, and every token it
+ * issues revokes the previous one, so a failing exchange is retried at most every 15 s (4 a minute).
+ */
+const MIN_EXCHANGE_INTERVAL_MS = 15_000
const STEP = 'vanta_token_mint'
export interface VantaClientTokenFields {
@@ -91,14 +97,25 @@ async function exchangeToken(
? 'invalid_credentials'
: 'provider_unavailable',
response.status,
- { step: STEP, reason: 'provider rejected token exchange' }
+ { step: STEP, reason: 'provider rejected token exchange' },
+ { retryAfterMs: throttledRetryAfterMs(response) }
+ )
+ }
+ let payload: unknown
+ try {
+ payload = await readResponseJsonWithLimit(response, {
+ maxBytes: 64 * 1024,
+ label: 'Vanta authentication response',
+ signal,
+ })
+ } catch (error) {
+ signal?.throwIfAborted()
+ throw unavailable(
+ error instanceof SyntaxError || error instanceof PayloadSizeLimitError
+ ? 'provider returned an invalid token response'
+ : 'provider token response could not be read'
)
}
- const payload = await readResponseJsonWithLimit(response, {
- maxBytes: 64 * 1024,
- label: 'Vanta authentication response',
- signal,
- })
if (
!isRecordLike(payload) ||
typeof payload.access_token !== 'string' ||
@@ -150,6 +167,7 @@ export async function resolveVantaClientToken(
step: STEP,
signal: options.signal,
minRemainingMs: EXPIRY_BUFFER_MS,
+ minExchangeIntervalMs: MIN_EXCHANGE_INTERVAL_MS,
failureKeyQualifier: scope,
accessTokenDigestTag: apiDomain,
pinnedState: {
diff --git a/apps/sim/lib/credentials/client-credentials-grant.test.ts b/apps/sim/lib/credentials/client-credentials-grant.test.ts
index 9cbaf6432c1..ee8004a43e5 100644
--- a/apps/sim/lib/credentials/client-credentials-grant.test.ts
+++ b/apps/sim/lib/credentials/client-credentials-grant.test.ts
@@ -1,8 +1,18 @@
import { jsonResponse } from '@sim/testing/helpers/http'
+import {
+ inputValidationMock,
+ inputValidationMockFns,
+} from '@sim/testing/mocks/input-validation.mock'
import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { exchangeClientCredentialsGrant } from '@/lib/credentials/client-credentials-grant'
+import { EgressRefusedError } from '@/lib/core/network/transport.server'
+import {
+ exchangeClientCredentialsGrant,
+ postToConfiguredTokenEndpoint,
+} from '@/lib/credentials/client-credentials-grant'
import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
+vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
+
const mockFetch = vi.fn