Skip to content

Commit 1d65986

Browse files
committed
improvement(credentials): put the credential picker under the operation picker and share the provider-failure split
Credential-only blocks now show their saved-credential picker right under the operation picker. Validators reuse the tools' header builders, one providerFailureCode decides caller vs provider errors (a Databricks token reply without a token is the provider misbehaving), and new providers sit in alphabetical order.
1 parent c94b4e7 commit 1d65986

10 files changed

Lines changed: 373 additions & 302 deletions

File tree

‎apps/sim/blocks/blocks/telegram.ts‎

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,20 @@ const AUDIO_FIELD = ['audioFile', 'audioRef', 'audio'] as const
4444
const ANIMATION_FIELD = ['animationFile', 'animationRef', 'animation'] as const
4545
const DOCUMENT_FIELD = ['attachmentFiles', 'files'] as const
4646

47+
/** Coerce string/number input to a finite number, throwing with a labeled message. */
48+
function requireNumber(value: unknown, label: string): number {
49+
const num = Number(value)
50+
if (value === undefined || value === null || value === '' || Number.isNaN(num)) {
51+
throw new Error(`${label} is required and must be a number.`)
52+
}
53+
return num
54+
}
55+
56+
/** Coerce agent-supplied "true"/"false" strings (or booleans) to a real boolean. */
57+
function toBoolean(value: unknown): boolean {
58+
return value === true || String(value).toLowerCase() === 'true'
59+
}
60+
4761
/** Builds Telegram tool params; a saved credential supplies the bot token, so only the legacy block requires it inline. */
4862
function telegramToolParams(params: BlockToolParams, requireBotToken: boolean) {
4963
if (requireBotToken && !params.botToken) {
@@ -60,19 +74,6 @@ function telegramToolParams(params: BlockToolParams, requireBotToken: boolean) {
6074
chatId,
6175
}
6276

63-
/** Coerce string/number input to a finite number, throwing with a labeled message. */
64-
const requireNumber = (value: unknown, label: string): number => {
65-
const num = Number(value)
66-
if (value === undefined || value === null || value === '' || Number.isNaN(num)) {
67-
throw new Error(`${label} is required and must be a number.`)
68-
}
69-
return num
70-
}
71-
72-
/** Coerce agent-supplied "true"/"false" strings (or booleans) to a real boolean. */
73-
const toBoolean = (value: unknown): boolean =>
74-
value === true || String(value).toLowerCase() === 'true'
75-
7677
switch (params.operation) {
7778
case 'telegram_message':
7879
if (!params.text) {

‎apps/sim/blocks/utils.test.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,50 @@ describe('withSavedCredential', () => {
292292
outputs: {},
293293
}
294294

295+
it('places the picker right under the operation picker, ahead of the other fields', () => {
296+
const { subBlocks } = withSavedCredential(
297+
{
298+
...legacy,
299+
subBlocks: [
300+
{ id: 'operation', title: 'Operation', type: 'dropdown', options: [] },
301+
{ id: 'bucket', title: 'Bucket', type: 'short-input' },
302+
{ id: 'accessKeyId', title: 'Access Key ID', type: 'short-input', required: true },
303+
{ id: 'secretAccessKey', title: 'Secret', type: 'short-input', required: true },
304+
],
305+
},
306+
picker
307+
)
308+
309+
expect(subBlocks.map((subBlock) => subBlock.id)).toEqual([
310+
'operation',
311+
'credential',
312+
'manualCredential',
313+
'bucket',
314+
])
315+
})
316+
317+
it('keeps the picker where the secret was when the block has no operation picker', () => {
318+
const { subBlocks } = withSavedCredential(
319+
{
320+
...legacy,
321+
subBlocks: [
322+
{ id: 'region', title: 'Region', type: 'short-input' },
323+
{ id: 'accessKeyId', title: 'Access Key ID', type: 'short-input', required: true },
324+
{ id: 'secretAccessKey', title: 'Secret', type: 'short-input', required: true },
325+
{ id: 'bucket', title: 'Bucket', type: 'short-input' },
326+
],
327+
},
328+
picker
329+
)
330+
331+
expect(subBlocks.map((subBlock) => subBlock.id)).toEqual([
332+
'region',
333+
'credential',
334+
'manualCredential',
335+
'bucket',
336+
])
337+
})
338+
295339
it('keeps trigger fields that share a replaced secret id, untouched', () => {
296340
const { subBlocks } = withSavedCredential(legacy, picker)
297341
const triggerFields = legacy.subBlocks.filter((subBlock) => subBlock.mode === 'trigger')

‎apps/sim/blocks/utils.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,8 @@ interface SavedCredentialOptions {
8383
* The fields of a credential-only successor to a block that takes inline
8484
* secrets. The legacy block keeps its own definition and keeps running; the
8585
* successor spreads it, then these fields swap its secret fields for a
86-
* saved-credential picker in the same place. Fields that depended on
86+
* saved-credential picker right under the operation picker (or where the
87+
* secret was, when the block has none). Fields that depended on
8788
* a replaced secret depend on the credential instead.
8889
*/
8990
export function withSavedCredential(
@@ -142,8 +143,13 @@ export function withSavedCredential(
142143
`Block "${legacy.type}": unused selector twins ${unusedSelectorKeys.join(', ')}`
143144
)
144145
}
146+
// The picker sits right under the operation picker, the first thing a user
147+
// sets; a block without one keeps it where its secret was.
148+
const operationAt = subBlocks.findIndex(
149+
(subBlock) => subBlock.id === 'operation' && !isTriggerModeSubBlock(subBlock)
150+
)
145151
subBlocks.splice(
146-
at,
152+
operationAt === -1 ? at : operationAt + 1,
147153
0,
148154
{
149155
...picker,

‎apps/sim/lib/credentials/databricks-service-account.server.test.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,4 +100,12 @@ describe('getting a Databricks token for a run', () => {
100100
code: 'provider_unavailable',
101101
})
102102
})
103+
104+
it('reports a token reply without a token as the provider misbehaving, not a bad credential', async () => {
105+
fetch.mockResolvedValue(jsonResponse({}, 200))
106+
107+
await expect(resolveDatabricksCredentialToken('cred-1')).rejects.toMatchObject({
108+
code: 'provider_unavailable',
109+
})
110+
})
103111
})

‎apps/sim/lib/credentials/databricks-service-account.server.ts‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
77
import { validateDatabricksWorkspaceHost } from '@/lib/core/security/input-validation'
88
import { parseServiceAccountConfig } from '@/lib/credentials/service-account-config'
99
import {
10-
isTransientProviderStatus,
10+
providerFailureCode,
1111
TokenServiceAccountValidationError,
1212
} from '@/lib/credentials/token-service-accounts/errors'
1313
import { WORKLOAD_IDENTITY_AUDIENCES } from '@/lib/credentials/workload-identity/audiences'
@@ -318,10 +318,8 @@ export async function resolveDatabricksCredentialToken(
318318
} catch (error) {
319319
if (!(error instanceof DatabricksCredentialError)) throw error
320320
// Report in the shared service-account codes: an outage is not a bad credential.
321-
const unavailable =
322-
error.status !== undefined && (error.status >= 500 || isTransientProviderStatus(error.status))
323321
throw new TokenServiceAccountValidationError(
324-
unavailable ? 'provider_unavailable' : 'invalid_credentials',
322+
error.status === undefined ? 'invalid_credentials' : providerFailureCode(error.status),
325323
error.status ?? 400,
326324
{ step: 'token', reason: error.message }
327325
)

‎apps/sim/lib/credentials/token-service-accounts/errors.ts‎

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,19 @@ export function isTransientProviderStatus(status: number): boolean {
7474
return status === 408 || status === 429
7575
}
7676

77+
/**
78+
* The shared split for a failed provider call: a non-transient 4xx is the
79+
* caller's input being wrong; anything else (5xx, 408/429, or a reply that
80+
* wasn't an error status but still failed) is the provider.
81+
*/
82+
export function providerFailureCode(
83+
status: number
84+
): Extract<TokenServiceAccountValidationCode, 'invalid_credentials' | 'provider_unavailable'> {
85+
return status >= 400 && status < 500 && !isTransientProviderStatus(status)
86+
? 'invalid_credentials'
87+
: 'provider_unavailable'
88+
}
89+
7790
export interface FetchProviderOptions {
7891
/**
7992
* Validation code thrown when the host does not resolve (`ENOTFOUND` only —
@@ -182,11 +195,9 @@ export async function throwForProviderResponse(
182195
): Promise<void> {
183196
if (res.ok) return
184197
const body = await readProviderErrorSnippet(res)
185-
const callerError =
186-
res.status >= 400 && res.status < 500 && !isTransientProviderStatus(res.status)
187-
throw new TokenServiceAccountValidationError(
188-
callerError ? 'invalid_credentials' : 'provider_unavailable',
189-
res.status,
190-
{ step, body, ...context }
191-
)
198+
throw new TokenServiceAccountValidationError(providerFailureCode(res.status), res.status, {
199+
step,
200+
body,
201+
...context,
202+
})
192203
}

‎apps/sim/lib/credentials/token-service-accounts/server.ts‎

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -324,22 +324,22 @@ const TOKEN_SERVICE_ACCOUNT_VALIDATORS: Record<
324324
[AGENTMAIL_SERVICE_ACCOUNT_PROVIDER_ID]: validateAgentMailServiceAccount,
325325
[AFFINITY_SERVICE_ACCOUNT_PROVIDER_ID]: validateAffinityServiceAccount,
326326
[TAVILY_SERVICE_ACCOUNT_PROVIDER_ID]: validateTavilyServiceAccount,
327-
[WHATSAPP_SERVICE_ACCOUNT_PROVIDER_ID]: validateWhatsAppServiceAccount,
328-
[VERCEL_SERVICE_ACCOUNT_PROVIDER_ID]: validateVercelServiceAccount,
329-
[TYPEFORM_SERVICE_ACCOUNT_PROVIDER_ID]: validateTypeformServiceAccount,
330-
[TELEGRAM_SERVICE_ACCOUNT_PROVIDER_ID]: validateTelegramServiceAccount,
331-
[STRIPE_SERVICE_ACCOUNT_PROVIDER_ID]: validateStripeServiceAccount,
332-
[SENTRY_SERVICE_ACCOUNT_PROVIDER_ID]: validateSentryServiceAccount,
333-
[ROOTLY_SERVICE_ACCOUNT_PROVIDER_ID]: validateRootlyServiceAccount,
334-
[RESEND_SERVICE_ACCOUNT_PROVIDER_ID]: validateResendServiceAccount,
335-
[PAGERDUTY_SERVICE_ACCOUNT_PROVIDER_ID]: validatePagerDutyServiceAccount,
336-
[LEMLIST_SERVICE_ACCOUNT_PROVIDER_ID]: validateLemlistServiceAccount,
337-
[GRANOLA_SERVICE_ACCOUNT_PROVIDER_ID]: validateGranolaServiceAccount,
338-
[GRAIN_SERVICE_ACCOUNT_PROVIDER_ID]: validateGrainServiceAccount,
339-
[FATHOM_SERVICE_ACCOUNT_PROVIDER_ID]: validateFathomServiceAccount,
340-
[CHECKR_SERVICE_ACCOUNT_PROVIDER_ID]: validateCheckrServiceAccount,
341-
[CALENDLY_SERVICE_ACCOUNT_PROVIDER_ID]: validateCalendlyServiceAccount,
342327
[ASHBY_SERVICE_ACCOUNT_PROVIDER_ID]: validateAshbyServiceAccount,
328+
[CALENDLY_SERVICE_ACCOUNT_PROVIDER_ID]: validateCalendlyServiceAccount,
329+
[CHECKR_SERVICE_ACCOUNT_PROVIDER_ID]: validateCheckrServiceAccount,
330+
[FATHOM_SERVICE_ACCOUNT_PROVIDER_ID]: validateFathomServiceAccount,
331+
[GRAIN_SERVICE_ACCOUNT_PROVIDER_ID]: validateGrainServiceAccount,
332+
[GRANOLA_SERVICE_ACCOUNT_PROVIDER_ID]: validateGranolaServiceAccount,
333+
[LEMLIST_SERVICE_ACCOUNT_PROVIDER_ID]: validateLemlistServiceAccount,
334+
[PAGERDUTY_SERVICE_ACCOUNT_PROVIDER_ID]: validatePagerDutyServiceAccount,
335+
[RESEND_SERVICE_ACCOUNT_PROVIDER_ID]: validateResendServiceAccount,
336+
[ROOTLY_SERVICE_ACCOUNT_PROVIDER_ID]: validateRootlyServiceAccount,
337+
[SENTRY_SERVICE_ACCOUNT_PROVIDER_ID]: validateSentryServiceAccount,
338+
[STRIPE_SERVICE_ACCOUNT_PROVIDER_ID]: validateStripeServiceAccount,
339+
[TELEGRAM_SERVICE_ACCOUNT_PROVIDER_ID]: validateTelegramServiceAccount,
340+
[TYPEFORM_SERVICE_ACCOUNT_PROVIDER_ID]: validateTypeformServiceAccount,
341+
[VERCEL_SERVICE_ACCOUNT_PROVIDER_ID]: validateVercelServiceAccount,
342+
[WHATSAPP_SERVICE_ACCOUNT_PROVIDER_ID]: validateWhatsAppServiceAccount,
343343
}
344344

345345
export function getTokenServiceAccountValidator(

‎apps/sim/lib/credentials/token-service-accounts/validators/http.ts‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { truncate } from '@sim/utils/string'
22
import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits'
33
import {
44
fetchProvider,
5-
isTransientProviderStatus,
5+
providerFailureCode,
66
TokenServiceAccountValidationError,
77
throwForProviderResponse,
88
} from '@/lib/credentials/token-service-accounts/errors'
@@ -92,10 +92,14 @@ export function tokenCheckValidator(check: TokenCheck): TokenServiceAccountValid
9292
if ((check.rejectsKey || check.provesKey) && !response.ok) {
9393
const unreadable = Symbol('unreadable')
9494
const errorBody = await readJsonBody(response, check.serviceLabel).catch(() => unreadable)
95-
if (errorBody !== unreadable && check.rejectsKey?.(errorBody)) {
96-
throw new TokenServiceAccountValidationError('invalid_credentials', 400, { step: 'verify' })
95+
if (errorBody !== unreadable) {
96+
if (check.rejectsKey?.(errorBody)) {
97+
throw new TokenServiceAccountValidationError('invalid_credentials', 400, {
98+
step: 'verify',
99+
})
100+
}
101+
if (check.provesKey?.(errorBody)) return accepted
97102
}
98-
if (errorBody !== unreadable && check.provesKey?.(errorBody)) return accepted
99103
if (scopedForbidden) {
100104
// Only a body that was read and doesn't name a bad key proves the token.
101105
if (errorBody === unreadable) {
@@ -106,12 +110,8 @@ export function tokenCheckValidator(check: TokenCheck): TokenServiceAccountValid
106110
}
107111
return accepted
108112
}
109-
const callerError =
110-
response.status >= 400 &&
111-
response.status < 500 &&
112-
!isTransientProviderStatus(response.status)
113113
throw new TokenServiceAccountValidationError(
114-
callerError ? 'invalid_credentials' : 'provider_unavailable',
114+
providerFailureCode(response.status),
115115
response.status,
116116
{
117117
step: 'verify',

0 commit comments

Comments
 (0)