Skip to content

Commit d5e98fa

Browse files
authored
feat(credentials): regional and instance hosts for saved LaunchDarkly, LangSmith, New Relic, Square, SendGrid, Hex, and Sentry credentials (#9926)
* feat(credentials): add defaulted region fields to token credentials and LaunchDarkly EU and federal instances A token credential's connection field can now declare a fixed option set (rendered as a dropdown) and a default value. The default is what a create that omits the field gets, and what a credential saved before the field existed resolves to, so existing credentials and API callers keep the host they always used. A reconnect that leaves the field blank keeps the stored value instead of falling back to the default. LaunchDarkly is the first provider to use it: the access token is checked at, and every LaunchDarkly tool sends it to, the commercial, EU (app.eu.launchdarkly.com), or federal (app.launchdarkly.us) instance it was created in. Runs without a saved credential keep the commercial host. * feat(langsmith): verify and run saved LangSmith keys in their region A LangSmith API key only authenticates in the region its organization lives in. The saved credential now carries that region (GCP US by default, or GCP EU, GCP APAC, AWS US, or Azure US), and the key check and every LangSmith tool use its documented API host. Credentials saved before, and runs without a saved credential, stay on api.smith.langchain.com. * feat(new-relic): add the JP data center to saved keys, tools, and the legacy block New Relic now documents a Japan region whose NerdGraph endpoint is api.jp.newrelic.com. Saved User API keys can be verified and run there, the tools route jp to it, and the legacy block's region dropdown offers it. The saved key's region field is now a dropdown. Unknown region values still fall back to the US endpoint, as workflows saved before regions expect. * feat(square): verify and run saved Square tokens in the sandbox Square credentials and resources of one environment cannot be used in the other. The saved credential now carries its environment (production by default, or sandbox), and the token check, every Square tool, and the catalog image upload operation call connect.squareup.com or connect.squareupsandbox.com accordingly. Credentials saved before, and runs without a saved credential, stay on production. * feat(sendgrid): verify and run saved keys of EU regional subusers on the EU host SendGrid serves regional subusers in the EU from api.eu.sendgrid.com. The saved credential now carries its region (global by default, or eu), and the key check, every SendGrid tool, and the mail send operation use that host. Credentials saved before, and runs without a saved credential, stay on api.sendgrid.com. The connect modal notes that Marketing Campaigns actions are not available to EU subusers. * feat(hex): verify and run saved Hex tokens on the workspace's hex.tech host Hex serves EU multi-tenant, HIPAA multi-tenant, and single-tenant workspaces from their own hex.tech host instead of app.hex.tech. The saved credential now carries that host (app.hex.tech by default), accepting only a single label under hex.tech so a token is only ever sent to Hex, and the token check and every Hex tool use it. Credentials saved before, and runs without a saved credential, stay on app.hex.tech. * test(credentials): type the regional host tests and keep region option types module-private * feat(sentry): verify and run saved Sentry tokens in their data storage region Sentry serves organizations whose data is stored in the EU from de.sentry.io, and its organization list is per region. The saved credential now carries its region (us by default, or de), and the token check and every Sentry tool call sentry.io or de.sentry.io accordingly. Credentials saved before, and runs without a saved credential, stay on sentry.io. * feat(credentials): report a defaulted region field's default in the provider catalog The v2 provider catalog marked a defaulted region, instance, or host field optional without saying what it defaults to. Each field now carries an additive optional defaultValue, and the OpenAPI document and CLI client are regenerated. * docs(square): say a saved credential can hold a sandbox access token * fix(credentials): keep default-region token credentials as they were and share region definitions - A credential on its provider's default region or host keeps the bare service display name, and a vendor region host that fails to resolve is reported as the vendor being unavailable, not a typed host that was not found. - defaultValue is honored on the domain field only; providerWorkspaceId keeps its required, non-defaulted handling everywhere, so a blank reconnect can never reset it. - One defineCredentialRegions factory now holds each vendor's region ids, hosts, labels, default, API base, and hidden tool param (LaunchDarkly, LangSmith, New Relic, SendGrid, Sentry, Square); the internal SendGrid and Square schemas validate against it, and the New Relic descriptor and block take their options from it. - One isTokenServiceAccountFieldRequired predicate replaces the inline defaulted-is-optional checks. - Hex refuses its known non-workspace hex.tech labels (status, learn, docs, www, help, community, blog), and the pattern's comment no longer claims more than it guarantees. - Drops a mock-only and two pass-through tests; adds Sentry to the default-region tables. * test(credentials): guard a named reconnect region against the stored one Fold the named-region reconnect case into the blank-reconnect table as a contrast row on a credential stored in eu, so it fails if a reconnect always keeps the stored region. * test(credentials): drop the repeated mock reset in the legacy region tests
1 parent 3e5200f commit d5e98fa

149 files changed

Lines changed: 1551 additions & 259 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/docs/content/docs/integrations/square.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { ReferenceDetails } from "@/components/ui/reference-details"
1212
{/* MANUAL-CONTENT-START:intro */}
1313
Use [Square](https://squareup.com/) to take, retrieve, list, cancel, complete, or refund payments; manage customer profiles; create, retrieve, search, or pay for orders; and create, publish, cancel, or delete invoices.
1414

15-
The actions also manage catalog objects and images, read inventory counts, and look up locations. Connect with a saved Square credential holding a production access token. Payment and refund actions change live commerce records; use the action's idempotency fields before running them.
15+
The actions also manage catalog objects and images, read inventory counts, and look up locations. Connect with a saved Square credential holding a production or sandbox access token, and choose the environment that issued it; a sandbox credential runs every action against the Square sandbox. Payment and refund actions on a production credential change live commerce records; use the action's idempotency fields before running them.
1616
{/* MANUAL-CONTENT-END */}
1717

1818
## Use the block [#usage-instructions]

‎apps/docs/openapi-v2-resources.json‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17950,6 +17950,12 @@
1795017950
"additionalProperties": false
1795117951
}
1795217952
},
17953+
"defaultValue": {
17954+
"description": "Value an optional field takes when it is omitted.",
17955+
"type": "string",
17956+
"minLength": 1,
17957+
"maxLength": 255
17958+
},
1795317959
"hint": {
1795417960
"description": "Provider-specific setup guidance.",
1795517961
"type": "string",
@@ -18106,6 +18112,12 @@
1810618112
"additionalProperties": false
1810718113
}
1810818114
},
18115+
"defaultValue": {
18116+
"description": "Value an optional field takes when it is omitted.",
18117+
"type": "string",
18118+
"minLength": 1,
18119+
"maxLength": 255
18120+
},
1810918121
"hint": {
1811018122
"description": "Provider-specific setup guidance.",
1811118123
"type": "string",

‎apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/token-service-account-modal.tsx‎

Lines changed: 68 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import type { ServiceAccountSecretFields } from '@/lib/credentials/service-accou
2222
import {
2323
CLIENT_CREDENTIALS_AUTH_METHOD,
2424
getTokenServiceAccountErrorMessage,
25+
isTokenServiceAccountFieldRequired,
2526
type TokenServiceAccountDescriptor,
2627
type TokenServiceAccountField,
2728
} from '@/lib/credentials/token-service-accounts/descriptors'
@@ -115,10 +116,20 @@ function TokenServiceAccountModalForm({
115116
? CLIENT_CREDENTIALS_AUTH_METHOD
116117
: 'token')
117118
const usesClientCredentials = authMethod === CLIENT_CREDENTIALS_AUTH_METHOD
119+
const tokenField = descriptor.fields.find((field) => field.id === 'apiToken')
120+
const domainField = descriptor.fields.find((field) => field.id === 'domain')
121+
const workspaceField = descriptor.fields.find((field) => field.id === 'providerWorkspaceId')
122+
/**
123+
* A new connection starts on the domain's default (the region or host it had before the field
124+
* existed). A reconnect starts blank, which keeps the stored value: the server carries it
125+
* forward, since the stored one is never returned to the client.
126+
*/
127+
const initialDomain = credentialId ? '' : (domainField?.defaultValue ?? '')
128+
118129
const [apiToken, setApiToken] = useState('')
119130
const [clientId, setClientId] = useState('')
120131
const [clientSecret, setClientSecret] = useState('')
121-
const [domain, setDomain] = useState('')
132+
const [domain, setDomain] = useState(initialDomain)
122133
const [providerWorkspaceId, setProviderWorkspaceId] = useState('')
123134
const [displayName, setDisplayName] = useState(initialDisplayName ?? '')
124135
const [description, setDescription] = useState(initialDescription ?? '')
@@ -127,26 +138,65 @@ function TokenServiceAccountModalForm({
127138
const createCredential = useCreateScopedCredential()
128139
const updateCredential = useUpdateScopedCredential()
129140

130-
const tokenField = descriptor.fields.find((field) => field.id === 'apiToken')
131-
const domainField = descriptor.fields.find((field) => field.id === 'domain')
132-
const workspaceField = descriptor.fields.find((field) => field.id === 'providerWorkspaceId')
133-
134141
const trimmedToken = apiToken.trim()
135142
const normalizedDomain = domainField?.url ? domain.trim() : normalizeDomainInput(domain)
143+
const trimmedWorkspaceId = providerWorkspaceId.trim()
136144
const isPending =
137145
createCredential.isPending || updateCredential.isPending || submitPending === true
138146
const isDisabled =
139147
!authMethod ||
140148
(usesClientCredentials ? !clientId.trim() || !clientSecret.trim() : !trimmedToken) ||
141-
(Boolean(domainField) && !normalizedDomain) ||
142-
(Boolean(workspaceField) && !providerWorkspaceId.trim()) ||
149+
Boolean(domainField && isTokenServiceAccountFieldRequired(domainField) && !normalizedDomain) ||
150+
(Boolean(workspaceField) && !trimmedWorkspaceId) ||
143151
isPending
144152

145153
const hintFor = (field: TokenServiceAccountField, value: string): string | undefined => {
146154
if (!field.hintPattern || !field.hintMessage || value.length === 0) return undefined
147155
return field.hintPattern.test(value) ? undefined : field.hintMessage
148156
}
149157

158+
const renderConnectionField = (
159+
field: TokenServiceAccountField,
160+
value: string,
161+
setValue: (value: string) => void,
162+
formatError?: string
163+
) => {
164+
const onChange = (next: string) => {
165+
setValue(next)
166+
if (error) setError(null)
167+
}
168+
const placeholder =
169+
credentialId && !isTokenServiceAccountFieldRequired(field)
170+
? `Keep the current ${field.label.toLowerCase()}`
171+
: field.placeholder
172+
if (field.options) {
173+
return (
174+
<ChipModalField
175+
type='dropdown'
176+
title={field.label}
177+
value={value || undefined}
178+
onChange={onChange}
179+
options={field.options}
180+
placeholder={placeholder}
181+
align='start'
182+
required={isTokenServiceAccountFieldRequired(field)}
183+
/>
184+
)
185+
}
186+
return (
187+
<ChipModalField
188+
type='input'
189+
title={field.label}
190+
value={value}
191+
onChange={onChange}
192+
placeholder={placeholder}
193+
autoComplete='off'
194+
required={isTokenServiceAccountFieldRequired(field)}
195+
error={formatError}
196+
/>
197+
)
198+
}
199+
150200
const handleSubmit = async () => {
151201
setError(null)
152202
if (isDisabled) return
@@ -159,8 +209,8 @@ function TokenServiceAccountModalForm({
159209
clientSecret: clientSecret.trim(),
160210
}
161211
: { apiToken: tokenField?.preserveWhitespace ? apiToken : trimmedToken }),
162-
...(workspaceField ? { providerWorkspaceId: providerWorkspaceId.trim() } : {}),
163-
...(domainField ? { domain: normalizedDomain } : {}),
212+
...(workspaceField ? { providerWorkspaceId: trimmedWorkspaceId } : {}),
213+
...(domainField && normalizedDomain ? { domain: normalizedDomain } : {}),
164214
}
165215
if (onSubmitCredentials) {
166216
const savedId = await onSubmitCredentials({
@@ -275,32 +325,15 @@ function TokenServiceAccountModalForm({
275325
</ChipModalField>
276326
)}
277327

278-
{workspaceField && (
279-
<ChipModalField
280-
type='input'
281-
title={workspaceField.label}
282-
value={providerWorkspaceId}
283-
onChange={setProviderWorkspaceId}
284-
placeholder={workspaceField.placeholder}
285-
required
286-
autoComplete='off'
287-
/>
288-
)}
289-
{domainField && (
290-
<ChipModalField
291-
type='input'
292-
title={domainField.label}
293-
value={domain}
294-
onChange={(value) => {
295-
setDomain(value)
296-
if (error) setError(null)
297-
}}
298-
placeholder={domainField.placeholder}
299-
autoComplete='off'
300-
required
301-
error={hintFor(domainField, normalizedDomain)}
302-
/>
303-
)}
328+
{workspaceField &&
329+
renderConnectionField(workspaceField, providerWorkspaceId, setProviderWorkspaceId)}
330+
{domainField &&
331+
renderConnectionField(
332+
domainField,
333+
domain,
334+
setDomain,
335+
hintFor(domainField, normalizedDomain)
336+
)}
304337

305338
<ChipModalField
306339
type='input'

‎apps/sim/blocks/blocks/new_relic.ts‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { BlockConfig, BlockMeta } from '@/blocks/types'
44
import { AuthMode, IntegrationType } from '@/blocks/types'
55
import { withSavedCredential } from '@/blocks/utils'
66
import type { NewRelicCustomAttributes } from '@/tools/new_relic/types'
7+
import { NEW_RELIC_REGIONS } from '@/tools/new_relic/utils'
78

89
function parseCustomAttributes(value: unknown): NewRelicCustomAttributes | undefined {
910
if (!value) return undefined
@@ -77,10 +78,7 @@ export const NewRelicBlock: BlockConfig = {
7778
id: 'region',
7879
title: 'Region',
7980
type: 'dropdown',
80-
options: [
81-
{ label: 'US', id: 'us' },
82-
{ label: 'EU', id: 'eu' },
83-
],
81+
options: NEW_RELIC_REGIONS.options.map(({ value, label }) => ({ id: value, label })),
8482
value: () => 'us',
8583
},
8684
{

‎apps/sim/lib/api/contracts/v2/credentials.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,12 @@ export const v2CredentialProviderFieldSchema = z
107107
.max(20)
108108
.optional()
109109
.describe('Fixed values accepted by a selector field.'),
110+
defaultValue: z
111+
.string()
112+
.min(1)
113+
.max(255)
114+
.optional()
115+
.describe('Value an optional field takes when it is omitted.'),
110116
hint: z.string().min(1).max(2000).optional().describe('Provider-specific setup guidance.'),
111117
})
112118
.strict()

‎apps/sim/lib/credentials/application/provider-catalog-contract.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,24 @@ describe('v2 credential provider catalog contract', () => {
7979
).toBe(false)
8080
})
8181

82+
it('tells API callers the region an optional region field takes when omitted', async () => {
83+
const providers = await listProviders()
84+
const sentry = providers.find(
85+
(provider) =>
86+
provider.type === 'service_account' && provider.providerId === 'sentry-service-account'
87+
)
88+
89+
expect(sentry?.fields.find((field) => field.id === 'domain')).toMatchObject({
90+
required: false,
91+
defaultValue: 'us',
92+
options: [
93+
{ value: 'us', label: 'US (sentry.io)' },
94+
{ value: 'de', label: 'EU (de.sentry.io)' },
95+
],
96+
})
97+
expect(responseSchema.safeParse({ data: providers, nextCursor: null }).success).toBe(true)
98+
})
99+
82100
it('keeps the field-less GitHub installation in the internal catalog for existing credentials', async () => {
83101
const catalog = await listCredentialProviderCatalog(createSessionPrincipal(), context)
84102
const installation = catalog.find(

‎apps/sim/lib/credentials/application/provider-catalog.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ export interface CredentialProviderField {
5454
multiline: boolean
5555
requiredForAuthMethods?: string[]
5656
options?: CredentialProviderFieldOption[]
57+
/** The value an optional field takes when it is omitted (a region's default). */
58+
defaultValue?: string
5759
hint?: string
5860
}
5961

@@ -119,13 +121,18 @@ function providerField(
119121
id: field.id,
120122
label: field.label,
121123
placeholder: field.placeholder,
122-
required: !('optional' in field && field.optional),
124+
required:
125+
!('optional' in field && field.optional) &&
126+
!('defaultValue' in field && field.defaultValue !== undefined),
123127
secret: field.secret,
124128
multiline: 'multiline' in field && field.multiline === true,
125129
...('requiredForAuthMethods' in field && field.requiredForAuthMethods
126130
? { requiredForAuthMethods: [...field.requiredForAuthMethods] }
127131
: {}),
128132
...('options' in field && field.options ? { options: [...field.options] } : {}),
133+
...('defaultValue' in field && field.defaultValue !== undefined
134+
? { defaultValue: field.defaultValue }
135+
: {}),
129136
...('hint' in field && field.hint
130137
? { hint: field.hint }
131138
: 'hintMessage' in field && field.hintMessage

‎apps/sim/lib/credentials/orchestration/index.test.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,41 @@ describe('performUpdateCredential — service-account secret rotation', () => {
399399
)
400400
})
401401

402+
it.each([
403+
{ domain: undefined, expected: 'eu' },
404+
{ domain: ' ', expected: 'eu' },
405+
{ domain: 'federal', expected: 'federal' },
406+
])(
407+
'sends region $expected when a token credential stored on eu is reconnected with $domain',
408+
async ({ domain, expected }) => {
409+
mockCredential({ providerId: 'launchdarkly-service-account', displayName: 'Flags (eu)' })
410+
mockStoredBlob({
411+
type: 'token_service_account',
412+
providerId: 'launchdarkly-service-account',
413+
apiToken: 'old-token',
414+
domain: 'eu',
415+
})
416+
mockVerifyAndBuildServiceAccountSecret.mockResolvedValue({
417+
providerId: 'launchdarkly-service-account',
418+
encryptedServiceAccountKey: 'new-cipher',
419+
displayName: `LaunchDarkly (${expected})`,
420+
auditMetadata: {},
421+
})
422+
423+
await performUpdateCredential({
424+
credentialId: 'cred-1',
425+
userId: 'user-1',
426+
apiToken: 'new-token',
427+
...(domain === undefined ? {} : { domain }),
428+
})
429+
430+
expect(mockVerifyAndBuildServiceAccountSecret).toHaveBeenCalledWith(
431+
'launchdarkly-service-account',
432+
expect.objectContaining({ apiToken: 'new-token', domain: expected })
433+
)
434+
}
435+
)
436+
402437
it('fails the reconnect when the stored secret cannot be decrypted', async () => {
403438
mockCredential()
404439
queueTableRows(schemaMock.credential, [{ key: 'stored-cipher' }])

‎apps/sim/lib/credentials/orchestration/index.ts‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,10 @@ import {
4949
ServiceAccountSecretError,
5050
verifyAndBuildServiceAccountSecret,
5151
} from '@/lib/credentials/service-account-secret'
52-
import { SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/credentials/token-service-accounts/descriptors'
52+
import {
53+
getTokenServiceAccountDomainDefault,
54+
SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID,
55+
} from '@/lib/credentials/token-service-accounts/descriptors'
5356
import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
5457
import {
5558
assertReconnectedSubjectUnclaimed,
@@ -159,7 +162,7 @@ async function readStoredSecretBlob(credentialId: string): Promise<Record<string
159162
*/
160163
function readStoredField(
161164
blob: Record<string, unknown> | null,
162-
field: 'dataCenter' | 'scope' | 'authMethod' | 'username' | 'atlassianProduct'
165+
field: 'dataCenter' | 'scope' | 'authMethod' | 'username' | 'atlassianProduct' | 'domain'
163166
): string | undefined {
164167
const value = blob?.[field]
165168
return typeof value === 'string' && value ? value : undefined
@@ -364,6 +367,10 @@ export async function updateCredentialRecord(
364367
providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID &&
365368
params.atlassianProduct === undefined
366369
const needsStoredUsername = params.username === undefined && isMultiGrantProvider
370+
// A token provider's defaulted region or host is left blank on a reconnect that keeps it;
371+
// filling the default instead would move an EU or sandbox credential to the default host.
372+
const needsStoredDomain =
373+
!params.domain?.trim() && getTokenServiceAccountDomainDefault(providerId) !== undefined
367374
// A keyless AWS role, Databricks principal, Microsoft app, Google service account, or
368375
// Snowflake service user keeps its trusted subject across reconnects.
369376
const needsStoredBoundSubject =
@@ -389,6 +396,7 @@ export async function updateCredentialRecord(
389396
needsStoredScope ||
390397
needsStoredAuthMethod ||
391398
needsStoredUsername ||
399+
needsStoredDomain ||
392400
needsStoredIdentity ||
393401
needsStoredAtlassianProduct ||
394402
needsStoredBoundSubject
@@ -445,7 +453,7 @@ export async function updateCredentialRecord(
445453
signingSecret: params.signingSecret,
446454
botToken: params.botToken,
447455
apiToken: params.apiToken,
448-
domain: params.domain,
456+
domain: needsStoredDomain ? readStoredField(storedBlob, 'domain') : params.domain,
449457
providerWorkspaceId: params.providerWorkspaceId,
450458
atlassianProduct: needsStoredAtlassianProduct
451459
? readStoredField(storedBlob, 'atlassianProduct') === 'confluence'

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,21 @@ describe('required fields for a service-account create', () => {
1414
)
1515
})
1616

17+
it.each([
18+
'launchdarkly-service-account',
19+
'langsmith-service-account',
20+
'square-service-account',
21+
'sendgrid-service-account',
22+
'hex-service-account',
23+
'sentry-service-account',
24+
])(
25+
'lets a %s create omit the region or host it defaults, as it could before one existed',
26+
(providerId) => {
27+
expect(getMissingServiceAccountFields(providerId, { apiToken: 'token' })).toEqual([])
28+
expect(getMissingServiceAccountFields(providerId, {})).toEqual(['apiToken'])
29+
}
30+
)
31+
1732
it('does not let a JSON configuration stand in for another token provider’s token', () => {
1833
expect(
1934
getMissingServiceAccountFields('hubspot-service-account', { serviceAccountJson: '{}' })

0 commit comments

Comments
 (0)