diff --git a/apps/docs/MACHINE_ACCESS.md b/apps/docs/MACHINE_ACCESS.md index 96b62913da9..2e2ea1f2b7f 100644 --- a/apps/docs/MACHINE_ACCESS.md +++ b/apps/docs/MACHINE_ACCESS.md @@ -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 | | --- | --- | --- | diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 1a858de5f42..74bd837975f 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -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"], diff --git a/apps/sim/lib/api-key/__integration__/read-only-access.integration.ts b/apps/sim/lib/api-key/__integration__/read-only-access.integration.ts index fbb61a2e513..7bbc0ad1c34 100644 --- a/apps/sim/lib/api-key/__integration__/read-only-access.integration.ts +++ b/apps/sim/lib/api-key/__integration__/read-only-access.integration.ts @@ -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 } }) @@ -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() diff --git a/apps/sim/lib/api/contracts/v2/meta.ts b/apps/sim/lib/api/contracts/v2/meta.ts index 7787b01243d..08bb5a58fe9 100644 --- a/apps/sim/lib/api/contracts/v2/meta.ts +++ b/apps/sim/lib/api/contracts/v2/meta.ts @@ -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({ diff --git a/apps/sim/lib/api/mcp/route-handler.test.ts b/apps/sim/lib/api/mcp/route-handler.test.ts index 061a166518d..dfe2c9d92e1 100644 --- a/apps/sim/lib/api/mcp/route-handler.test.ts +++ b/apps/sim/lib/api/mcp/route-handler.test.ts @@ -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) diff --git a/apps/sim/lib/core/application/oauth-authorization.test.ts b/apps/sim/lib/core/application/oauth-authorization.test.ts index 6a202dcc196..1c51cef8bb5 100644 --- a/apps/sim/lib/core/application/oauth-authorization.test.ts +++ b/apps/sim/lib/core/application/oauth-authorization.test.ts @@ -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, @@ -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) diff --git a/apps/sim/lib/core/application/oauth-authorization.ts b/apps/sim/lib/core/application/oauth-authorization.ts index 5c2cff2eed9..6d75224fac8 100644 --- a/apps/sim/lib/core/application/oauth-authorization.ts +++ b/apps/sim/lib/core/application/oauth-authorization.ts @@ -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 { diff --git a/apps/sim/lib/webhooks/polling/utils.ts b/apps/sim/lib/webhooks/polling/utils.ts index 3228a3a1bfb..519af916bea 100644 --- a/apps/sim/lib/webhooks/polling/utils.ts +++ b/apps/sim/lib/webhooks/polling/utils.ts @@ -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 { 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({ @@ -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` ) diff --git a/apps/sim/lib/workflows/application/read-workflow-run.ts b/apps/sim/lib/workflows/application/read-workflow-run.ts index 62c79d8020d..c23d1f4a91b 100644 --- a/apps/sim/lib/workflows/application/read-workflow-run.ts +++ b/apps/sim/lib/workflows/application/read-workflow-run.ts @@ -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 @@ -171,6 +171,7 @@ export const readWorkflowRun = defineAuthorizedWorkflowUseCase({ (await readPausedRunPoints({ workflowId: context.workflowId, executionId: stateExecutionId, + pausePoints: loadedPausePoints ?? undefined, })) ?? [] ).map((point) => ({ ...point, diff --git a/apps/sim/lib/workflows/application/triggers.integration.ts b/apps/sim/lib/workflows/application/triggers.integration.ts index 320095009b3..7152476d0b4 100644 --- a/apps/sim/lib/workflows/application/triggers.integration.ts +++ b/apps/sim/lib/workflows/application/triggers.integration.ts @@ -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) 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', diff --git a/apps/sim/lib/workflows/executor/execution-status.ts b/apps/sim/lib/workflows/executor/execution-status.ts index e3462c5d879..40a1bda292b 100644 --- a/apps/sim/lib/workflows/executor/execution-status.ts +++ b/apps/sim/lib/workflows/executor/execution-status.ts @@ -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 } /** @@ -286,6 +291,7 @@ export async function getProjectedWorkflowExecutionStatus( status: projectExecutionStatus(resolved.status, projection), projection, stateExecutionId: resolved.stateExecutionId, + pausePoints: resolved.pausePoints, } } @@ -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( @@ -433,6 +441,7 @@ async function readWorkflowExecutionStatus( return { status: projectQueueJob(job, { executionId, includeOutput, workflowId }, deploymentVersion), stateExecutionId: executionId, + pausePoints: null, } } } @@ -455,7 +464,7 @@ async function readWorkflowExecutionStatus( finalOutput: null, blockOutputs: null, } - return { status, stateExecutionId: executionId } + return { status, stateExecutionId: executionId, pausePoints: null } } if (!logRow) { @@ -468,6 +477,7 @@ async function readWorkflowExecutionStatus( ? { status: projectSettledResumeAttempt(executionId, resumeAttempt, run.status), stateExecutionId: run.stateExecutionId, + pausePoints: run.pausePoints, } : null } @@ -539,6 +549,7 @@ async function readWorkflowExecutionStatus( return { status: { ...resource, error: null, finalOutput: null, blockOutputs: null }, stateExecutionId: executionId, + pausePoints: paused ? pausedRow?.pausePoints : null, } } @@ -571,6 +582,7 @@ async function readWorkflowExecutionStatus( return { status: { ...resource, error, finalOutput, blockOutputs }, stateExecutionId: executionId, + pausePoints: paused ? pausedRow?.pausePoints : null, } } @@ -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 { 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