Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/docs/MACHINE_ACCESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Continue until `nextCursor` is `null`. Unknown cursors and invalid parameters re

Configure a remote Streamable HTTP server at `https://docs.sim.ai/api/mcp`. This endpoint is separate from Sim's workspace MCP server and `/mcp/...` documentation pages. It requires no API key.

The official stable MCP TypeScript SDK 2.3.1 serves the 2026-07-28 protocol and stateless compatibility for 2025-era clients. Tools:
The official stable MCP TypeScript SDK 2.4.0 serves the 2026-07-28 protocol and stateless compatibility for 2025-era clients. Tools:

| Tool | Input | Result |
| --- | --- | --- |
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/openapi-v2-resources.json
Original file line number Diff line number Diff line change
Expand Up @@ -18651,7 +18651,7 @@
"type": "string",
"enum": ["api:read", "api:write", "search:read"]
},
"description": "API scopes the calling credential holds. A read-only API key holds only api:read: it is refused every operation that changes state and every operation that declares no API scope, so api:read alone does not authorize those. A full-access key holds api:read and api:write; an OAuth access token holds what it was granted."
"description": "API scopes the calling credential can use. api:write implies api:read, and either implies search:read. A read-only API key holds api:read and search:read: it is refused every operation that changes state and every operation that declares no API scope, so api:read alone does not authorize those. A full-access key holds all three; an OAuth access token holds what it was granted and what that implies."
}
},
"required": ["v2Enabled", "keyType", "expiresAt", "scopes"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ describe('read-only and expiring API keys in PostgreSQL', () => {
for (const key of [readPersonalKey, readWorkspaceKey]) {
const meta = await call(v2Meta, '/api/v2/meta', { key })
expect(meta.status).toBe(200)
expect(meta.body).toMatchObject({ data: { scopes: ['api:read'] } })
expect(meta.body).toMatchObject({ data: { scopes: ['api:read', 'search:read'] } })
}
const personalMeta = await call(v2Meta, '/api/v2/meta', { key: readPersonalKey })
expect(personalMeta.body).toMatchObject({ data: { expiresAt } })
Expand Down Expand Up @@ -417,7 +417,7 @@ describe('read-only and expiring API keys in PostgreSQL', () => {
it('treats a key created before access levels as full access', async () => {
const meta = await call(v2Meta, '/api/v2/meta', { key: legacyKey })
expect(meta.body).toMatchObject({
data: { scopes: ['api:read', 'api:write'], expiresAt: null },
data: { scopes: ['api:read', 'api:write', 'search:read'], expiresAt: null },
})

const tablesBefore = await tableCount()
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/api/contracts/v2/meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export const v2MetaSchema = z
scopes: z
.array(z.enum(['api:read', 'api:write', 'search:read']))
.describe(
'API scopes the calling credential holds. A read-only API key holds only api:read: it is refused every operation that changes state and every operation that declares no API scope, so api:read alone does not authorize those. A full-access key holds api:read and api:write; an OAuth access token holds what it was granted.'
'API scopes the calling credential can use. api:write implies api:read, and either implies search:read. A read-only API key holds api:read and search:read: it is refused every operation that changes state and every operation that declares no API scope, so api:read alone does not authorize those. A full-access key holds all three; an OAuth access token holds what it was granted and what that implies.'
),
})
.meta({
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/api/mcp/route-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ describe('Sim MCP tools', () => {
v2Enabled: true,
keyType: 'personal',
expiresAt: null,
scopes: ['api:read', 'api:write'],
scopes: ['api:read', 'api:write', 'search:read'],
},
})
expect(v2RouteMocks.authenticate).toHaveBeenCalledTimes(2)
Expand Down
25 changes: 25 additions & 0 deletions apps/sim/lib/core/application/oauth-authorization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { OAuthAccessTokenPrincipal, PersonalApiKeyPrincipal } from '@sim/au
import { describe, expect, it, vi } from 'vitest'
import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application/authorized-workspace-use-case'
import {
credentialApiScopes,
InsufficientScopeError,
OAuthAccessTokenExpiredError,
ReadOnlyApiKeyError,
Expand Down Expand Up @@ -104,6 +105,30 @@ describe('semantic OAuth policy', () => {
).not.toThrow()
})

it.each([
{ case: 'a write token', credential: { ...principal, scopes: ['api:write'] } },
{ case: 'a read token', credential: principal },
{ case: 'a search-only token', credential: { ...principal, scopes: ['search:read'] } },
{
case: 'a read-only key',
credential: { kind: 'personal_api_key', userId: 'user-1', keyId: 'k-1', access: 'read' },
},
{
case: 'a full-access key',
credential: { kind: 'personal_api_key', userId: 'user-1', keyId: 'k-1', access: 'write' },
},
] as const)('reports exactly the scopes $case can reach', ({ credential }) => {
const reachable = (['api:read', 'api:write', 'search:read'] as const).filter((oauthScope) => {
try {
requireOAuthOperationScope(credential, { ...writeOperation, oauthScope })
return true
} catch {
return false
}
})
expect(credentialApiScopes(credential)).toEqual(reachable)
})

it('rejects expiry before considering scope', () => {
expect(() =>
requireOAuthOperationScope({ ...principal, expiresAt: new Date(0) }, writeOperation)
Expand Down
10 changes: 6 additions & 4 deletions apps/sim/lib/core/application/oauth-authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,15 @@ const API_SCOPES: readonly OAuthApiScope[] = [
]

/**
* The API scopes a credential holds, as `/api/v2/meta` reports them. A credential
* no grant bounds, such as a write key, holds both `api:read` and `api:write`.
* The API scopes a credential can reach, as `/api/v2/meta` reports them: each scope
* {@link requireOAuthOperationScope} would let it through, so `api:write` reports the
* `api:read` it implies and either reports `search:read`. A credential no grant bounds,
* such as a write key, reaches every scope.
*/
export function credentialApiScopes(principal: Principal): OAuthApiScope[] {
const granted = grantedApiScopes(principal)
if (!granted) return [OAUTH_API_READ_SCOPE, OAUTH_API_WRITE_SCOPE]
return API_SCOPES.filter((scope) => granted.includes(scope))
if (!granted) return [...API_SCOPES]
return API_SCOPES.filter((scope) => oauthScopeSatisfies(granted, scope))
}

function grantedApiScopes(principal: Principal): readonly string[] | undefined {
Expand Down
13 changes: 11 additions & 2 deletions apps/sim/lib/webhooks/polling/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,11 +149,19 @@ export async function recordPollSourceFailure(
* Increment the webhook's failure count, auto-disabling it once the count
* reaches MAX_CONSECUTIVE_FAILURES. One statement, so an enable that resets the
* count concurrently is never followed by a disable decided on the old count.
* The locked `prior` read reports whether this update is the one that disabled
* the webhook, so a failure on an already-disabled webhook logs nothing.
*/
export async function markWebhookFailed(webhookId: string, logger: Logger): Promise<void> {
try {
const now = new Date()
const reachesLimit = sql`COALESCE(${webhook.failedCount}, 0) + 1 >= ${MAX_CONSECUTIVE_FAILURES}`
const prior = db
.select({ wasActive: webhook.isActive })
.from(webhook)
.where(eq(webhook.id, webhookId))
.for('update')
.as('prior')
const [result] = await db
.update(webhook)
.set({
Expand All @@ -162,10 +170,11 @@ export async function markWebhookFailed(webhookId: string, logger: Logger): Prom
isActive: sql`CASE WHEN ${reachesLimit} THEN false ELSE ${webhook.isActive} END`,
updatedAt: now,
})
.from(prior)
.where(eq(webhook.id, webhookId))
.returning({ failedCount: webhook.failedCount })
.returning({ isActive: webhook.isActive, wasActive: prior.wasActive })

if ((result?.failedCount ?? 0) >= MAX_CONSECUTIVE_FAILURES) {
if (result?.wasActive && !result.isActive) {
logger.warn(
`Webhook ${webhookId} auto-disabled after ${MAX_CONSECUTIVE_FAILURES} consecutive failures`
)
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/lib/workflows/application/read-workflow-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export const readWorkflowRun = defineAuthorizedWorkflowUseCase({
viewerUserId: logProjectionSubjectUserId(principal),
})
if (!projected) throw new OrchestrationError('not_found', 'Run not found')
const { status, projection, stateExecutionId } = projected
const { status, projection, stateExecutionId, pausePoints: loadedPausePoints } = projected

/**
* A run whose `blockOutputs` the viewer's group withholds joins the same
Expand Down Expand Up @@ -171,6 +171,7 @@ export const readWorkflowRun = defineAuthorizedWorkflowUseCase({
(await readPausedRunPoints({
workflowId: context.workflowId,
executionId: stateExecutionId,
pausePoints: loadedPausePoints ?? undefined,
})) ?? []
).map((point) => ({
...point,
Expand Down
20 changes: 20 additions & 0 deletions apps/sim/lib/workflows/application/triggers.integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -742,16 +742,36 @@ check('enabling a poller clears its failures and backoff but keeps its config',

check('a poll failure auto-disables at the threshold in the same write', async () => {
const logger = createLogger('TriggerHealthIntegration')
const warn = vi.spyOn(logger, 'warn')
await markWebhookFailed(flakyWebhookId, logger)
expect(await webhookRow(flakyWebhookId)).toMatchObject({ isActive: true, failedCount: 99 })
await markWebhookFailed(flakyWebhookId, logger)
expect(await webhookRow(flakyWebhookId)).toMatchObject({ isActive: false, failedCount: 100 })
expect(warn).toHaveBeenCalledTimes(1)

await markWebhookFailed(flakyWebhookId, logger)
expect(await webhookRow(flakyWebhookId)).toMatchObject({ isActive: false, failedCount: 101 })
expect(warn).toHaveBeenCalledTimes(1)
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved

await setBlock('flaky', true)
await markWebhookFailed(flakyWebhookId, logger)
expect(await webhookRow(flakyWebhookId)).toMatchObject({ isActive: true, failedCount: 1 })
})

check('overlapping failures at the threshold count every failure and disable once', async () => {
const logger = createLogger('TriggerHealthConcurrency')
const warn = vi.spyOn(logger, 'warn')
await db
.update(webhook)
.set({ isActive: true, failedCount: 97 })
.where(eq(webhook.id, flakyWebhookId))

await Promise.all(Array.from({ length: 6 }, () => markWebhookFailed(flakyWebhookId, logger)))

expect(await webhookRow(flakyWebhookId)).toMatchObject({ isActive: false, failedCount: 103 })
expect(warn).toHaveBeenCalledTimes(1)
})

check('an unknown block is not found', async () => {
await expect(setBlock('missing', true)).rejects.toMatchObject({
code: 'not_found',
Expand Down
43 changes: 32 additions & 11 deletions apps/sim/lib/workflows/executor/execution-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,11 @@ export interface ProjectedWorkflowExecutionStatus {
* resume attempt continued. Further reads of the run's state (its pause points) use this id.
*/
stateExecutionId: string
/**
* The state execution's raw `pause_points`, already loaded while the run is paused, so
* {@link readPausedRunPoints} need not read them again; null when the run is not paused.
*/
pausePoints: unknown
}

/**
Expand Down Expand Up @@ -286,6 +291,7 @@ export async function getProjectedWorkflowExecutionStatus(
status: projectExecutionStatus(resolved.status, projection),
projection,
stateExecutionId: resolved.stateExecutionId,
pausePoints: resolved.pausePoints,
}
}

Expand Down Expand Up @@ -346,6 +352,8 @@ async function readRunDeploymentVersion(
interface ResolvedExecutionStatus {
status: WorkflowExecutionStatusResponse
stateExecutionId: string
/** The state execution's raw `pause_points`, loaded while it is paused; null otherwise. */
pausePoints: unknown
}

async function readWorkflowExecutionStatus(
Expand Down Expand Up @@ -433,6 +441,7 @@ async function readWorkflowExecutionStatus(
return {
status: projectQueueJob(job, { executionId, includeOutput, workflowId }, deploymentVersion),
stateExecutionId: executionId,
pausePoints: null,
}
}
}
Expand All @@ -455,7 +464,7 @@ async function readWorkflowExecutionStatus(
finalOutput: null,
blockOutputs: null,
}
return { status, stateExecutionId: executionId }
return { status, stateExecutionId: executionId, pausePoints: null }
}

if (!logRow) {
Expand All @@ -468,6 +477,7 @@ async function readWorkflowExecutionStatus(
? {
status: projectSettledResumeAttempt(executionId, resumeAttempt, run.status),
stateExecutionId: run.stateExecutionId,
pausePoints: run.pausePoints,
}
: null
}
Expand Down Expand Up @@ -539,6 +549,7 @@ async function readWorkflowExecutionStatus(
return {
status: { ...resource, error: null, finalOutput: null, blockOutputs: null },
stateExecutionId: executionId,
pausePoints: paused ? pausedRow?.pausePoints : null,
}
}

Expand Down Expand Up @@ -571,6 +582,7 @@ async function readWorkflowExecutionStatus(
return {
status: { ...resource, error, finalOutput, blockOutputs },
stateExecutionId: executionId,
pausePoints: paused ? pausedRow?.pausePoints : null,
}
}

Expand Down Expand Up @@ -644,22 +656,31 @@ async function readRunBlockNames(
* they were captured; an older point's stored response was never scrubbed, so both
* are null for it. Resume links and API-mode response status and headers are never
* part of a point.
*
* A caller that already loaded the paused record's `pause_points` passes them as
* `pausePoints` and the record is not read again.
*/
export async function readPausedRunPoints(input: {
workflowId: string
executionId: string
pausePoints?: unknown
}): Promise<PausedRunPoint[] | null> {
const { workflowId, executionId } = input
const [row] = await db
.select({ pausePoints: pausedExecutions.pausePoints })
.from(pausedExecutions)
.where(
and(
eq(pausedExecutions.executionId, executionId),
eq(pausedExecutions.workflowId, workflowId)
)
)
.limit(1)
const row =
input.pausePoints !== undefined
? { pausePoints: input.pausePoints }
: (
await db
.select({ pausePoints: pausedExecutions.pausePoints })
.from(pausedExecutions)
.where(
and(
eq(pausedExecutions.executionId, executionId),
eq(pausedExecutions.workflowId, workflowId)
)
)
.limit(1)
)[0]
if (!row) return null

// Positions count the whole pending queue, so it is read unpaged: it holds a few columns per
Expand Down
Loading