Skip to content

Commit a4ea29c

Browse files
committed
fix(integrations): preserve field values and tighten credential lifecycle
1 parent 0b27d1e commit a4ea29c

22 files changed

Lines changed: 297 additions & 58 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,7 @@ Set one field on a Flow entity using a key from the data model and a JSON value
283283
| `entityId` | string | Yes | Entity ID returned by Search Entities or List Entities |
284284
| `branchId` | string | No | Branch ID; omit or use master for Base |
285285
| `fieldKey` | string | Yes | Stored field key returned by Get Data Model |
286-
| `value` | json | Yes | Field value as JSON, matching the data model: string, number, boolean, array, object, or null |
286+
| `value` | string | Yes | JSON-encoded field value matching the data model. Use "text" for a string, 123 for a number, true or false for a boolean, an array or object, or null to clear the field. Use "" for an empty string. |
287287

288288
#### Output
289289

‎apps/sim/blocks/blocks/flow.ts‎

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -888,13 +888,6 @@ export const FlowBlock: BlockConfig = {
888888
tool: (params) => `flow_${params.operation || 'search_entities'}`,
889889
params: (params) => {
890890
const result: Record<string, unknown> = {}
891-
if (params.operation === 'set_value' && typeof params.value === 'string') {
892-
try {
893-
result.value = JSON.parse(params.value)
894-
} catch {
895-
throw new Error('Value must be valid JSON; wrap text values in double quotes')
896-
}
897-
}
898891
if (params.limit != null && params.limit !== '') result.limit = Number(params.limit)
899892
for (const key of ['includeResolved', 'resolved', 'isVariant']) {
900893
if (params[key] != null && params[key] !== '')
@@ -976,7 +969,7 @@ export const FlowBlock: BlockConfig = {
976969
description: 'Stored field key returned by Get Data Model',
977970
},
978971
value: {
979-
type: 'json',
972+
type: 'string',
980973
description:
981974
'Field value as JSON, matching the data model: string, number, boolean, array, object, or null',
982975
},

‎apps/sim/blocks/blocks/jama.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -708,7 +708,7 @@ export const JamaBlock: BlockConfig = {
708708
items: {
709709
type: 'json',
710710
description:
711-
'List Baseline Items result (id, documentKey, globalId, itemType, project, createdDate, modifiedDate, lastActivityDate, createdBy, modifiedBy, fields, version)',
711+
'List Items, Search Items, or List Baseline Items results (id, documentKey, globalId, itemType, project, createdDate, modifiedDate, lastActivityDate, createdBy, modifiedBy, fields, version)',
712712
},
713713
item: {
714714
type: 'json',

‎apps/sim/executor/handlers/generic/handler.test.ts‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import '@sim/testing/mocks/executor'
33
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
44
import { executeBufferTool } from '@/lib/internal/buffer/execute-tool'
55
import { BufferBlock } from '@/blocks/blocks/buffer'
6+
import { FlowBlock } from '@/blocks/blocks/flow'
67
import { HarmonicBlock } from '@/blocks/blocks/harmonic'
78
import { McpBlock } from '@/blocks/blocks/mcp'
89
import { getBlock } from '@/blocks/index'
@@ -15,6 +16,8 @@ import bufferPostFixture from '@/tools/buffer/__fixtures__/post.json'
1516
import { bufferCreatePostTool } from '@/tools/buffer/create_post'
1617
import { bufferEditPostTool } from '@/tools/buffer/edit_post'
1718
import type { BufferCreatePostParams, BufferEditPostParams } from '@/tools/buffer/types'
19+
import { flowSetValueTool } from '@/tools/flow/set-value'
20+
import type { FlowSetValueParams } from '@/tools/flow/types'
1821
import { mcpRunOperationTool } from '@/tools/mcp/run-operation'
1922
import type { ToolConfig } from '@/tools/types'
2023
import { getTool } from '@/tools/utils'
@@ -82,6 +85,70 @@ describe('GenericBlockHandler', () => {
8285
mockExecuteTool.mockResolvedValue({ success: true, output: { customResult: 'OK' } })
8386
})
8487

88+
describe('Flow field values', () => {
89+
const writes: unknown[] = []
90+
91+
beforeEach(async () => {
92+
writes.length = 0
93+
mockGetBlock.mockReturnValue(FlowBlock)
94+
mockGetTool.mockReturnValue(flowSetValueTool)
95+
const { validateRequiredParametersAfterMerge } =
96+
await vi.importActual<typeof import('@/tools/utils')>('@/tools/utils')
97+
mockExecuteTool.mockImplementation(async (toolId: string, params: FlowSetValueParams) => {
98+
validateRequiredParametersAfterMerge(toolId, flowSetValueTool, params)
99+
writes.push(await Response.json(flowSetValueTool.request.body?.(params)).json())
100+
return { success: true, output: {} }
101+
})
102+
})
103+
104+
const values = ['123', 'true', '{"key":1}', '', null, 123, false, [1, '2'], { key: 1 }]
105+
it.each(values.map((value) => ({ value, encoded: JSON.stringify(value) })))(
106+
'preserves the JSON value $encoded on the provider wire',
107+
async ({ value, encoded }) => {
108+
await handler.execute(
109+
mockContext,
110+
{
111+
...mockBlock,
112+
metadata: { id: 'flow', name: 'Flow' },
113+
config: { tool: 'flow_set_value', params: {} },
114+
},
115+
{
116+
operation: 'set_value',
117+
projectId: 'project',
118+
entityId: 'entity',
119+
fieldKey: 'name',
120+
value: encoded,
121+
}
122+
)
123+
expect(writes).toEqual([{ key: 'name', value }])
124+
}
125+
)
126+
127+
it.each([undefined, '', '{"broken":', 'unquoted text'])(
128+
'rejects omitted or malformed JSON before sending a field write (%s)',
129+
async (value) => {
130+
await expect(
131+
handler.execute(
132+
mockContext,
133+
{
134+
...mockBlock,
135+
metadata: { id: 'flow', name: 'Flow' },
136+
config: { tool: 'flow_set_value', params: {} },
137+
},
138+
{
139+
operation: 'set_value',
140+
projectId: 'project',
141+
entityId: 'entity',
142+
fieldKey: 'name',
143+
value,
144+
}
145+
)
146+
).rejects.toThrow()
147+
expect(writes).toEqual([])
148+
}
149+
)
150+
})
151+
85152
it.each([
86153
{
87154
selection: 'keep-current scheduling',

‎apps/sim/hooks/queries/organization-accounts.test.tsx‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,24 @@ vi.mock('next/navigation', () => nextNavigationMock)
1616

1717
import { ApiClientError } from '@/lib/api/client/errors'
1818
import { listOrganizationAccountPeopleContract } from '@/lib/api/contracts/organization-accounts'
19+
import { resourceScopeKey } from '@/lib/core/resource-scope'
1920
import { useOrganizationAccountPeople } from '@/hooks/queries/organization-accounts'
21+
import { useRemovePersonalSearchCredential } from '@/hooks/queries/personal-search-integrations'
22+
import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys'
2023

2124
const mockRequestJson = apiClientRequestMockFns.mockRequestJson
2225

23-
describe('organization people search pagination', () => {
26+
describe('organization accounts', () => {
2427
let root: Root
2528
let container: HTMLDivElement
2629
let client: QueryClient
2730
let result: ReturnType<typeof useOrganizationAccountPeople>
31+
let removal: ReturnType<typeof useRemovePersonalSearchCredential>
32+
33+
function RemovalProbe() {
34+
removal = useRemovePersonalSearchCredential()
35+
return null
36+
}
2837

2938
function Probe({
3039
search,
@@ -130,6 +139,37 @@ describe('organization people search pagination', () => {
130139
expect(result.data?.pages).toHaveLength(1)
131140
})
132141

142+
it('clears disconnected Search content while preserving another organization’s results', async () => {
143+
const search = knowledgeKeys.search(
144+
resourceScopeKey({ kind: 'organization', organizationId: 'org-1' }),
145+
'private content'
146+
)
147+
const otherSearch = knowledgeKeys.search(
148+
resourceScopeKey({ kind: 'organization', organizationId: 'org-2' }),
149+
'private content'
150+
)
151+
const document = knowledgeKeys.document('knowledge-1', 'document-1')
152+
client.setQueryData(search, [{ content: 'disconnected source content' }])
153+
client.setQueryData(otherSearch, [{ content: 'other organization content' }])
154+
client.setQueryData(document, { content: 'disconnected document content' })
155+
mockRequestJson.mockResolvedValueOnce({ success: true })
156+
157+
await act(async () =>
158+
root.render(
159+
<QueryClientProvider client={client}>
160+
<RemovalProbe />
161+
</QueryClientProvider>
162+
)
163+
)
164+
await act(async () => {
165+
await removal.mutateAsync({ organizationId: 'org-1', credentialId: 'credential-1' })
166+
})
167+
168+
expect(client.getQueryData(search)).toBeUndefined()
169+
expect(client.getQueryData(document)).toBeUndefined()
170+
expect(client.getQueryData(otherSearch)).toEqual([{ content: 'other organization content' }])
171+
})
172+
133173
it.each([400, 401, 403, 404, 409, 422])(
134174
'does not retry a non-retryable %s response',
135175
async (status) => {

‎apps/sim/hooks/queries/personal-search-integrations.ts‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
import { isDesktopApp } from '@/lib/desktop'
1818
import { connectDesktopSource } from '@/lib/desktop/source-connect'
1919
import { organizationAccountsKeys } from '@/hooks/queries/utils/organization-accounts-keys'
20+
import { resetOrganizationSearchAccess } from '@/hooks/queries/utils/reset-organization-search-access'
2021
import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys'
2122

2223
export const PERSONAL_SEARCH_INTEGRATIONS_STALE_TIME = 15_000
@@ -102,12 +103,7 @@ export function useRemovePersonalSearchCredential() {
102103
queryKey: organizationAccountsKeys.detail(body.organizationId),
103104
}),
104105
client.invalidateQueries({ queryKey: personalSearchIntegrationKeys.lists() }),
105-
client.invalidateQueries({
106-
queryKey: searchSourceKeys.list({
107-
kind: 'organization',
108-
organizationId: body.organizationId,
109-
}),
110-
}),
106+
resetOrganizationSearchAccess(client, body.organizationId),
111107
]),
112108
})
113109
}

‎apps/sim/lib/credential-groups/member-integrations.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,13 @@ export function memberIntegrationEntries(
122122
permissionAccountIds: [],
123123
reconnectAccountIds: ready ? savedAccounts.map((account) => account.credentialId) : [],
124124
connectionTarget: ready ? { savedProviderId } : undefined,
125-
description: ready ? 'Search with your own account' : 'Disabled by your organization',
125+
description: !policies
126+
? undefined
127+
: approval?.available === false
128+
? 'Currently unavailable'
129+
: ready
130+
? 'Search with your own account'
131+
: 'Disabled by your organization',
126132
})
127133
continue
128134
}

‎apps/sim/lib/credentials/application/crud.test.ts‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ const hoisted = vi.hoisted(() => ({
3232
createRecord: vi.fn(),
3333
personalAccounts: vi.fn(),
3434
createPersonalToken: vi.fn(),
35+
updatePersonalToken: vi.fn(),
3536
}))
3637

3738
const resolveGroupConfigMock = permissionGroupScopeMockFns.mockResolvePermissionGroupConfig
@@ -54,7 +55,7 @@ vi.mock('@/lib/credentials/application/workspace-personal-accounts', () => ({
5455
}))
5556
vi.mock('@/lib/credentials/personal-tokens', () => ({
5657
createPersonalTokenCredential: hoisted.createPersonalToken,
57-
updatePersonalTokenCredential: vi.fn(),
58+
updatePersonalTokenCredential: hoisted.updatePersonalToken,
5859
}))
5960
vi.mock('@/lib/core/config/block-visibility', () => blockVisibilityMock)
6061
vi.mock('@/lib/integrations/principal-scope.server', () => ({ allowedIntegrationTypes: vi.fn() }))
@@ -237,6 +238,40 @@ describe('updateWorkspaceCredentialUseCase', () => {
237238
).rejects.toMatchObject({ detailCode: 'CREDENTIAL_ADMIN_ACCESS_REQUIRED' })
238239
expect(mocks.updateRecord).not.toHaveBeenCalled()
239240
})
241+
242+
it('rejects provider workspace changes that a personal token cannot persist', async () => {
243+
const personalToken = {
244+
...credential,
245+
type: 'personal_token' as const,
246+
providerId: 'gitlab',
247+
workspaceId: null,
248+
organizationId: 'organization-1',
249+
}
250+
mocks.loadWorkspace.mockResolvedValue({
251+
...workspace,
252+
workspaceOrganizationId: personalToken.organizationId,
253+
})
254+
mocks.getWorkspaceCredential.mockResolvedValue(personalToken)
255+
mocks.getActor.mockResolvedValue({
256+
credential: personalToken,
257+
member: { role: 'admin' },
258+
hasWorkspaceAccess: true,
259+
isAdmin: true,
260+
})
261+
mocks.updatePersonalToken.mockResolvedValue({ success: true, updatedFields: [] })
262+
263+
await expect(
264+
updateWorkspaceCredentialUseCase.execute({
265+
principal: sessionPrincipal,
266+
input: {
267+
credentialId: personalToken.id,
268+
assertedWorkspaceId: WORKSPACE_ID,
269+
providerWorkspaceId: 'another-provider-workspace',
270+
},
271+
})
272+
).rejects.toMatchObject({ code: 'validation' })
273+
expect(mocks.updatePersonalToken).not.toHaveBeenCalled()
274+
})
240275
})
241276

242277
describe('personal-credential capability', () => {

‎apps/sim/lib/credentials/application/crud.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,6 @@ export const updateWorkspaceCredentialUseCase = defineAuthorizedCredentialUseCas
329329
'description',
330330
'apiToken',
331331
'domain',
332-
'providerWorkspaceId',
333332
])
334333
if (
335334
Object.entries(fields).some(
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import {
2+
inputValidationMock,
3+
inputValidationMockFns,
4+
} from '@sim/testing/mocks/input-validation.mock'
5+
import { describe, expect, it, vi } from 'vitest'
6+
import { mintJamaServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/jama'
7+
8+
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
9+
10+
describe('Jama credential validation', () => {
11+
it.each([
12+
['ENOTFOUND', 'site_not_found', 400],
13+
['EAI_AGAIN', 'provider_unavailable', 502],
14+
['ETIMEDOUT', 'provider_unavailable', 502],
15+
])(
16+
'classifies %s without treating transient failures as a wrong instance',
17+
async (code, expectedCode, status) => {
18+
inputValidationMockFns.mockSecureFetchWithValidation.mockRejectedValueOnce(
19+
new Error('url hostname could not be resolved', {
20+
cause: Object.assign(new Error('DNS lookup failed'), { code }),
21+
})
22+
)
23+
24+
await expect(
25+
mintJamaServiceAccountToken({
26+
orgId: 'https://example.jamacloud.com',
27+
clientId: 'client-id',
28+
clientSecret: 'client-secret',
29+
})
30+
).rejects.toMatchObject({ code: expectedCode, status })
31+
}
32+
)
33+
})

0 commit comments

Comments
 (0)