Skip to content

Commit 4fbcb9f

Browse files
committed
Merge remote-tracking branch 'origin/staging' into codex/ashby-integration-expansion
# Conflicts: # apps/sim/tools/generated/tool-metadata.ts # apps/sim/tools/generated/tool-outputs.ts
2 parents 71da9fe + 27734f5 commit 4fbcb9f

48 files changed

Lines changed: 1647 additions & 276 deletions

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/webflow.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ Integrates Webflow CMS into the workflow. Can create, get, list, update, or dele
3636

3737
### Webflow List Items
3838

39-
List all items from a Webflow CMS collection
39+
List items from a Webflow CMS collection
4040

4141
#### Input
4242

@@ -64,6 +64,7 @@ List all items from a Webflow CMS collection
6464
|`itemCount` | number | Number of items returned |
6565
|`offset` | number | Pagination offset |
6666
|`limit` | number | Maximum items per page |
67+
|`total` | number | Total number of matching items |
6768

6869
### Webflow Get Item
6970

apps/docs/content/docs/platform/credentials.mdx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,11 +177,13 @@ When a workflow runs, secrets resolve in this order:
177177
| Run started by | Personal secrets come from |
178178
| --- | --- |
179179
| Clicking Run, or a personal API key | The person running it |
180-
| A workspace API key, schedule, or webhook | The workflow owner |
180+
| A workspace API key, schedule, webhook, or deployed chat | The workflow owner |
181181
| A public API URL with no authentication | Nobody — personal secrets do not resolve |
182182

183183
The workflow owner is the fallback only where nobody can be identified but somebody in the workspace set the trigger up, since those workflows are usually built against the owner's own keys. A public URL can be called by anyone, so it never borrows a person's keys at all — put every secret such a workflow needs in **Workspace**.
184184

185+
If the workflow owner later leaves the workspace, the run keeps working: it resolves workspace secrets as normal and simply resolves no personal ones, so any block that needed a personal secret fails on its own with the missing key named. Move that secret to **Workspace** to fix it for good.
186+
185187
## Best Practices
186188

187189
- **Use workspace secrets for production** so workflows work regardless of who triggers them
@@ -193,7 +195,8 @@ The workflow owner is the fallback only where nobody can be identified but someb
193195
{ question: "Are my secrets encrypted at rest?", answer: "Yes. Values saved under Secrets are encrypted before being stored in the database." },
194196
{ question: "Can a saved secret still appear in a workflow result?", answer: "Yes. Functional workflow data is not rewritten, so the raw value can still reach downstream blocks and tools and can appear in workflow execution responses, streams, or callbacks if your workflow deliberately returns or prints it. Log-facing views and read APIs receive a protected copy after a successful {{KEY}} resolution. Before content is sent to a model, exact values from the run's authorized secret catalog are replaced with placeholders, but encoded or otherwise transformed values remain outside that protection." },
195197
{ question: "What happens if both a workspace secret and a personal secret have the same key name?", answer: "Among secrets available to the execution actor, the workspace secret takes precedence and the personal secret is the fallback. An inaccessible workspace secret does not shadow an authorized personal value." },
196-
{ question: "Who determines which personal secret is used for automated runs?", answer: "Whoever is running it, when that can be identified. Clicking Run or calling with a personal API key uses that person's personal secrets. A workspace API key, schedule, or webhook has no identifiable caller, so it falls back to the workflow owner's — those triggers are set up inside the workspace and the workflow is usually built against the owner's own keys. A public API URL with no authentication can be called by anyone, so no personal secrets resolve at all — those workflows run on workspace secrets only." },
198+
{ question: "Who determines which personal secret is used for automated runs?", answer: "Whoever is running it, when that can be identified. Clicking Run or calling with a personal API key uses that person's personal secrets. A workspace API key, schedule, webhook, or deployed chat has no identifiable caller, so it falls back to the workflow owner's — those triggers are set up inside the workspace and the workflow is usually built against the owner's own keys. A public API URL with no authentication can be called by anyone, so no personal secrets resolve at all — those workflows run on workspace secrets only." },
199+
{ question: "What happens to automated runs if the workflow owner leaves the workspace?", answer: "They keep running. Workspace secrets resolve as normal, because they are checked against the workspace's billing account rather than the owner. Personal secrets stop resolving, so any block that referenced one fails with that key named — move it to Workspace to fix it permanently." },
197200
{ question: "Can I import secrets from a .env file?", answer: "Yes. Paste .env-style content (KEY=VALUE format) into any key or value field and the secrets will be auto-populated. The parser supports export KEY=VALUE, quoted values, and inline comments." },
198201
{ question: "What happens if I delete a secret that is used in a workflow?", answer: "The workflow will fail at any block that references the deleted secret during execution because the value cannot be resolved. Update any references before deleting a secret." },
199202
]} />

apps/docs/openapi-v2-logs.json

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1344,6 +1344,19 @@
13441344
],
13451345
"description": "Files the run produced, or null when none are recorded. Only the run's own output files appear; input attachments a caller supplied are addressed through the files API instead."
13461346
},
1347+
"executedByEmail": {
1348+
"anyOf": [
1349+
{
1350+
"type": "string",
1351+
"format": "email",
1352+
"pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"
1353+
},
1354+
{
1355+
"type": "null"
1356+
}
1357+
],
1358+
"description": "Email of the identity the run executed as: the caller for an interactive or personal-API-key run, and the workspace billing account for a schedule, webhook, deployed chat, or public API call. Null when the run failed before an identity was resolved."
1359+
},
13471360
"workflow": {
13481361
"type": "object",
13491362
"properties": {
@@ -1398,7 +1411,8 @@
13981411
"type": "null"
13991412
}
14001413
],
1401-
"description": "Workflow owner email, or null when unavailable."
1414+
"description": "Deprecated — use the run-level `executedByEmail` instead. Email of the workflow's current owner, or null when unavailable. This is a property of the workflow as it stands today, not of the run: it changes when workflow ownership is reassigned, and the owner is not the identity a background run executes as.",
1415+
"deprecated": true
14021416
},
14031417
"workspaceId": {
14041418
"anyOf": [
@@ -1577,6 +1591,7 @@
15771591
"endedAt",
15781592
"totalDurationMs",
15791593
"files",
1594+
"executedByEmail",
15801595
"workflow",
15811596
"workflowState",
15821597
"traceSpans",
@@ -1614,6 +1629,7 @@
16141629
"endedAt": "2026-01-15T10:30:01.250Z",
16151630
"totalDurationMs": 1250,
16161631
"files": null,
1632+
"executedByEmail": "billing@example.com",
16171633
"workflow": {
16181634
"id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36",
16191635
"name": "Customer Support Agent",

apps/sim/app/api/chat/[identifier]/route.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,8 @@ export const POST = withRouteHandler(
180180
const preprocessResult = await preprocessExecution({
181181
workflowId: deployment.workflowId,
182182
userId: deployment.userId,
183+
// Whoever deployed this chat, not whoever is talking to it.
184+
userIdIsStoredReference: true,
183185
triggerType: 'chat',
184186
executionId,
185187
requestId,
@@ -273,7 +275,16 @@ export const POST = withRouteHandler(
273275

274276
const workflowForExecution = {
275277
id: deployment.workflowId,
276-
userId: deployment.userId,
278+
/**
279+
* The workflow owner, not the chat's creator: `executeWorkflow` reads this
280+
* one field to set `workflowUserId`, the personal-environment fallback for
281+
* runs with no identifiable caller. `chat.userId` records who deployed the
282+
* chat and is never maintained as an execution identity — member removal
283+
* reassigns `workflow.userId` to keep it an active workspace identity and
284+
* has no equivalent for the chat row — so reading it here made deployed
285+
* chat resolve a pointer that every other trigger had already repaired.
286+
*/
287+
userId: workflowRecord.userId,
277288
workspaceId,
278289
isDeployed: workflowRecord?.isDeployed ?? false,
279290
variables: (workflowRecord?.variables as Record<string, unknown>) ?? undefined,

apps/sim/app/api/v1/logs/[id]/route.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,6 @@ export const GET = withRouteHandler(
5151
name: log.workflowName || 'Deleted Workflow',
5252
description: log.workflowDescription,
5353
folderId: log.workflowFolderId,
54-
userId: log.workflowUserId,
5554
workspaceId: log.workflowWorkspaceId,
5655
createdAt: log.workflowCreatedAt,
5756
updatedAt: log.workflowUpdatedAt,

apps/sim/app/api/v2/logs/[runId]/route.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ const log = {
4949
files: null,
5050
workflowName: 'Support Agent',
5151
workflowDescription: null,
52+
executedByEmail: 'actor@example.com',
5253
workflowOwnerEmail: 'owner@example.com',
5354
workflowWorkspaceId: 'workspace-1',
5455
workflowCreatedAt: new Date('2026-01-01T00:00:00Z'),
@@ -86,8 +87,16 @@ describe('GET /api/v2/logs/[runId]', () => {
8687
const body = await response.json()
8788

8889
expect(response.status).toBe(200)
90+
/**
91+
* The two identities are deliberately different people here. `ownerEmail` is
92+
* deprecated but still a required field of the published schema, so it must
93+
* keep resolving from the workflow owner rather than quietly aliasing to the
94+
* executing identity — dropping its own join would make both read the same
95+
* and break every client still on it.
96+
*/
8997
expect(body.data).toMatchObject({
9098
runId: 'run-1',
99+
executedByEmail: 'actor@example.com',
91100
workflow: { folderPath: '/agents', ownerEmail: 'owner@example.com' },
92101
finalOutput: { ok: true },
93102
})

apps/sim/app/api/v2/logs/[runId]/route.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,13 @@ export const GET = defineV2JsonRoute({
4949
endedAt: log.endedAt ? log.endedAt.toISOString() : null,
5050
totalDurationMs: log.totalDurationMs,
5151
files: projectLogFiles(log),
52+
executedByEmail: log.executedByEmail,
5253
workflow: {
5354
id: log.workflowId,
5455
name: log.workflowName || 'Deleted Workflow',
5556
description: log.workflowDescription,
5657
folderPath: workflowFolderPath,
58+
/** Deprecated in favour of the run-level `executedByEmail`. */
5759
ownerEmail: log.workflowOwnerEmail,
5860
workspaceId: log.workflowWorkspaceId,
5961
createdAt: log.workflowCreatedAt ? log.workflowCreatedAt.toISOString() : null,

apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,22 @@ function callPublicExecute(body: Record<string, unknown>, headers: Record<string
218218
return POST(req, { params: Promise.resolve({ workflowId: 'workflow-1' }) })
219219
}
220220

221+
/**
222+
* Queues the two reads the anonymous public path makes, in order: the workflow's
223+
* public-API eligibility, then the workspace billing account it runs as. Keeping
224+
* them together stops a caller queueing only the first and getting an
225+
* indistinguishable 401 from the missing second.
226+
*/
227+
function queuePublicWorkflowReads(
228+
overrides: { isPublicApi?: boolean; isDeployed?: boolean; billedAccountUserId?: string } = {}
229+
) {
230+
const { isPublicApi = true, isDeployed = true, billedAccountUserId = 'billing-1' } = overrides
231+
dbChainMockFns.limit.mockResolvedValueOnce([
232+
{ isPublicApi, isDeployed, workspaceId: 'workspace-1' },
233+
])
234+
dbChainMockFns.limit.mockResolvedValueOnce([{ billedAccountUserId }])
235+
}
236+
221237
function authenticatePersonalKey() {
222238
mockAuthenticateV2ApiKey.mockResolvedValue({
223239
principal: { kind: 'personal_api_key', userId: 'actor-1', keyId: 'key-1' },
@@ -759,9 +775,7 @@ describe('POST /api/v2/workflows/[workflowId]/execute', () => {
759775

760776
it('runs the anonymous public path sync but refuses async', async () => {
761777
dbChainMockFns.limit.mockReset()
762-
dbChainMockFns.limit.mockResolvedValueOnce([
763-
{ isPublicApi: true, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' },
764-
])
778+
queuePublicWorkflowReads()
765779

766780
const okRes = await callPublicExecute({ input: {} })
767781
expect(okRes.status).toBe(200)
@@ -774,18 +788,14 @@ describe('POST /api/v2/workflows/[workflowId]/execute', () => {
774788
expect.objectContaining({ rateLimitCounter: 'sync' })
775789
)
776790

777-
dbChainMockFns.limit.mockResolvedValueOnce([
778-
{ isPublicApi: true, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' },
779-
])
791+
queuePublicWorkflowReads()
780792
const asyncRes = await callPublicExecute({ input: {}, async: true })
781793
expect(asyncRes.status).toBe(400)
782794
})
783795

784796
it('never permits manual execution on the anonymous public path', async () => {
785797
dbChainMockFns.limit.mockReset()
786-
dbChainMockFns.limit.mockResolvedValueOnce([
787-
{ isPublicApi: true, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' },
788-
])
798+
queuePublicWorkflowReads()
789799

790800
const response = await callPublicExecute({ run: { source: 'manual' } })
791801

@@ -798,9 +808,7 @@ describe('POST /api/v2/workflows/[workflowId]/execute', () => {
798808

799809
it('returns not found when a public workflow disappears before authorization', async () => {
800810
dbChainMockFns.limit.mockReset()
801-
dbChainMockFns.limit.mockResolvedValueOnce([
802-
{ isPublicApi: true, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' },
803-
])
811+
queuePublicWorkflowReads()
804812
mockAuthorize.mockResolvedValueOnce({
805813
allowed: false,
806814
status: 404,
@@ -837,7 +845,7 @@ describe('POST /api/v2/workflows/[workflowId]/execute', () => {
837845
it('401s non-public workflows without a key', async () => {
838846
dbChainMockFns.limit.mockReset()
839847
dbChainMockFns.limit.mockResolvedValueOnce([
840-
{ isPublicApi: false, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' },
848+
{ isPublicApi: false, isDeployed: true, workspaceId: 'workspace-1' },
841849
])
842850

843851
const res = await callPublicExecute({ input: {} })
@@ -870,9 +878,7 @@ describe('POST /api/v2/workflows/[workflowId]/execute', () => {
870878
expect(keyedBody.error.message).toContain('Maximum workflow call chain depth (25) exceeded')
871879

872880
dbChainMockFns.limit.mockReset()
873-
dbChainMockFns.limit.mockResolvedValueOnce([
874-
{ isPublicApi: true, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' },
875-
])
881+
queuePublicWorkflowReads()
876882
const anonymous = await callPublicExecute({ input: {} }, { 'X-Sim-Via': maxChain })
877883
expect(anonymous.status).toBe(409)
878884
expect((await anonymous.json()).error.code).toBe('CONFLICT')
@@ -891,9 +897,7 @@ describe('POST /api/v2/workflows/[workflowId]/execute', () => {
891897
])
892898

893899
dbChainMockFns.limit.mockReset()
894-
dbChainMockFns.limit.mockResolvedValueOnce([
895-
{ isPublicApi: true, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' },
896-
])
900+
queuePublicWorkflowReads()
897901
const anonymous = await callPublicExecute({ input: {} }, { 'X-Sim-Via': 'wf-a, wf-b' })
898902
expect(anonymous.status).toBe(200)
899903
expect(mockExecuteWorkflowCore.mock.calls[1][0].snapshot.metadata.callChain).toEqual([

apps/sim/app/api/v2/workflows/[workflowId]/execute/route.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
v2RateLimits,
2222
} from '@/lib/api/server/routes'
2323
import type { V2ApiKeyPrincipal } from '@/lib/api/server/routes/v2-api-key-auth'
24+
import { getWorkspaceBilledAccountUserId } from '@/lib/billing/core/billing-attribution'
2425
import { tryAdmit } from '@/lib/core/admission/gate'
2526
import { ADMISSION_ERROR_DESCRIPTOR } from '@/lib/core/admission/transient-failure'
2627
import type { ForbiddenDetailCode } from '@/lib/core/application'
@@ -173,7 +174,6 @@ export const POST = withRouteHandler(
173174
.select({
174175
isPublicApi: workflowTable.isPublicApi,
175176
isDeployed: workflowTable.isDeployed,
176-
userId: workflowTable.userId,
177177
workspaceId: workflowTable.workspaceId,
178178
})
179179
.from(workflowTable)
@@ -183,15 +183,27 @@ export const POST = withRouteHandler(
183183
if (!wf?.isPublicApi || !wf.isDeployed || !wf.workspaceId) {
184184
return v2Error('UNAUTHORIZED', 'Unauthorized')
185185
}
186+
/**
187+
* An anonymous public-API call has no caller, so it acts as the workspace
188+
* billing account — the identity preprocessing elects for exactly this
189+
* case. The workflow owner is only the personal-variable fallback, and a
190+
* public run resolves no personal variables at all, so gating on the
191+
* owner's governance config and workspace read would fail a public
192+
* endpoint the moment that stored pointer's access lapsed.
193+
*/
194+
const billedAccountUserId = await getWorkspaceBilledAccountUserId(wf.workspaceId)
195+
if (!billedAccountUserId) {
196+
return v2Error('UNAUTHORIZED', 'Unauthorized')
197+
}
186198
try {
187-
await validatePublicApiAllowed(wf.userId, wf.workspaceId)
199+
await validatePublicApiAllowed(billedAccountUserId, wf.workspaceId)
188200
} catch (err) {
189201
if (err instanceof PublicApiNotAllowedError) {
190202
return v2Error('UNAUTHORIZED', 'Unauthorized')
191203
}
192204
throw err
193205
}
194-
publicApiUserId = wf.userId
206+
publicApiUserId = billedAccountUserId
195207
isPublicApiAccess = true
196208
}
197209

@@ -343,7 +355,7 @@ export const POST = withRouteHandler(
343355
}
344356
} else {
345357
if (!publicApiUserId) {
346-
throw new Error('Public workflow execution is missing its owner')
358+
throw new Error('Public workflow execution is missing its workspace billing account')
347359
}
348360
const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({
349361
workflowId,

apps/sim/app/api/workflows/[id]/execute/route.async.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838

3939
const {
4040
mockAssertBillingAttributionSnapshot,
41+
mockGetWorkspaceBilledAccountUserId,
4142
mockClaimExecutionId,
4243
mockClaimWorkflowToolExecution,
4344
mockCheckNeedsRedeployment,
@@ -89,12 +90,14 @@ const {
8990
mockReleaseExecutionSlot: vi.fn(),
9091
mockReleaseWorkflowToolExecutionClaim: vi.fn(),
9192
mockRequireBillingAttributionHeader: vi.fn(),
93+
mockGetWorkspaceBilledAccountUserId: vi.fn().mockResolvedValue('billing-1'),
9294
mockShouldExecuteInline: vi.fn().mockReturnValue(false),
9395
mockValidatePublicApiAllowed: vi.fn(),
9496
}))
9597

9698
vi.mock('@/lib/billing/core/billing-attribution', () => ({
9799
assertBillingAttributionSnapshot: mockAssertBillingAttributionSnapshot,
100+
getWorkspaceBilledAccountUserId: mockGetWorkspaceBilledAccountUserId,
98101
requireBillingAttributionHeader: mockRequireBillingAttributionHeader,
99102
}))
100103

@@ -345,7 +348,6 @@ function configureExecutionCaller(caller: ExecutionCallerCase, requestCount = 1)
345348
{
346349
isPublicApi: true,
347350
isDeployed: true,
348-
userId: 'owner-1',
349351
workspaceId: 'workspace-1',
350352
},
351353
])

0 commit comments

Comments
 (0)