Skip to content

Commit 1561c09

Browse files
committed
fix(release): small release-audit cleanups
- Read a paused run's pause points once per status read instead of querying paused_executions twice. - Log a polling webhook's auto-disable only when this failure disables it, in the same race-free statement. - /api/v2/meta reports the scopes a credential can actually reach, using the same check enforcement uses. Enforcement is unchanged. - MACHINE_ACCESS.md names MCP TypeScript SDK 2.4.0.
1 parent 03bf275 commit 1561c09

10 files changed

Lines changed: 87 additions & 23 deletions

File tree

‎apps/docs/MACHINE_ACCESS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ Continue until `nextCursor` is `null`. Unknown cursors and invalid parameters re
3333

3434
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.
3535

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

3838
| Tool | Input | Result |
3939
| --- | --- | --- |

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18651,7 +18651,7 @@
1865118651
"type": "string",
1865218652
"enum": ["api:read", "api:write", "search:read"]
1865318653
},
18654-
"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."
18654+
"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."
1865518655
}
1865618656
},
1865718657
"required": ["v2Enabled", "keyType", "expiresAt", "scopes"],

‎apps/sim/lib/api-key/__integration__/read-only-access.integration.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,7 @@ describe('read-only and expiring API keys in PostgreSQL', () => {
275275
for (const key of [readPersonalKey, readWorkspaceKey]) {
276276
const meta = await call(v2Meta, '/api/v2/meta', { key })
277277
expect(meta.status).toBe(200)
278-
expect(meta.body).toMatchObject({ data: { scopes: ['api:read'] } })
278+
expect(meta.body).toMatchObject({ data: { scopes: ['api:read', 'search:read'] } })
279279
}
280280
const personalMeta = await call(v2Meta, '/api/v2/meta', { key: readPersonalKey })
281281
expect(personalMeta.body).toMatchObject({ data: { expiresAt } })
@@ -417,7 +417,7 @@ describe('read-only and expiring API keys in PostgreSQL', () => {
417417
it('treats a key created before access levels as full access', async () => {
418418
const meta = await call(v2Meta, '/api/v2/meta', { key: legacyKey })
419419
expect(meta.body).toMatchObject({
420-
data: { scopes: ['api:read', 'api:write'], expiresAt: null },
420+
data: { scopes: ['api:read', 'api:write', 'search:read'], expiresAt: null },
421421
})
422422

423423
const tablesBefore = await tableCount()

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export const v2MetaSchema = z
2525
scopes: z
2626
.array(z.enum(['api:read', 'api:write', 'search:read']))
2727
.describe(
28-
'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.'
28+
'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.'
2929
),
3030
})
3131
.meta({

‎apps/sim/lib/core/application/oauth-authorization.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { OAuthAccessTokenPrincipal, PersonalApiKeyPrincipal } from '@sim/au
22
import { describe, expect, it, vi } from 'vitest'
33
import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application/authorized-workspace-use-case'
44
import {
5+
credentialApiScopes,
56
InsufficientScopeError,
67
OAuthAccessTokenExpiredError,
78
ReadOnlyApiKeyError,
@@ -104,6 +105,30 @@ describe('semantic OAuth policy', () => {
104105
).not.toThrow()
105106
})
106107

108+
it.each([
109+
{ case: 'a write token', credential: { ...principal, scopes: ['api:write'] } },
110+
{ case: 'a read token', credential: principal },
111+
{ case: 'a search-only token', credential: { ...principal, scopes: ['search:read'] } },
112+
{
113+
case: 'a read-only key',
114+
credential: { kind: 'personal_api_key', userId: 'user-1', keyId: 'k-1', access: 'read' },
115+
},
116+
{
117+
case: 'a full-access key',
118+
credential: { kind: 'personal_api_key', userId: 'user-1', keyId: 'k-1', access: 'write' },
119+
},
120+
] as const)('reports exactly the scopes $case can reach', ({ credential }) => {
121+
const reachable = (['api:read', 'api:write', 'search:read'] as const).filter((oauthScope) => {
122+
try {
123+
requireOAuthOperationScope(credential, { ...writeOperation, oauthScope })
124+
return true
125+
} catch {
126+
return false
127+
}
128+
})
129+
expect(credentialApiScopes(credential)).toEqual(reachable)
130+
})
131+
107132
it('rejects expiry before considering scope', () => {
108133
expect(() =>
109134
requireOAuthOperationScope({ ...principal, expiresAt: new Date(0) }, writeOperation)

‎apps/sim/lib/core/application/oauth-authorization.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,13 +87,15 @@ const API_SCOPES: readonly OAuthApiScope[] = [
8787
]
8888

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

99101
function grantedApiScopes(principal: Principal): readonly string[] | undefined {

‎apps/sim/lib/webhooks/polling/utils.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,11 +149,19 @@ export async function recordPollSourceFailure(
149149
* Increment the webhook's failure count, auto-disabling it once the count
150150
* reaches MAX_CONSECUTIVE_FAILURES. One statement, so an enable that resets the
151151
* count concurrently is never followed by a disable decided on the old count.
152+
* The locked `prior` read reports whether this update is the one that disabled
153+
* the webhook, so a failure on an already-disabled webhook logs nothing.
152154
*/
153155
export async function markWebhookFailed(webhookId: string, logger: Logger): Promise<void> {
154156
try {
155157
const now = new Date()
156158
const reachesLimit = sql`COALESCE(${webhook.failedCount}, 0) + 1 >= ${MAX_CONSECUTIVE_FAILURES}`
159+
const prior = db
160+
.select({ wasActive: webhook.isActive })
161+
.from(webhook)
162+
.where(eq(webhook.id, webhookId))
163+
.for('update')
164+
.as('prior')
157165
const [result] = await db
158166
.update(webhook)
159167
.set({
@@ -162,10 +170,11 @@ export async function markWebhookFailed(webhookId: string, logger: Logger): Prom
162170
isActive: sql`CASE WHEN ${reachesLimit} THEN false ELSE ${webhook.isActive} END`,
163171
updatedAt: now,
164172
})
173+
.from(prior)
165174
.where(eq(webhook.id, webhookId))
166-
.returning({ failedCount: webhook.failedCount })
175+
.returning({ isActive: webhook.isActive, wasActive: prior.wasActive })
167176

168-
if ((result?.failedCount ?? 0) >= MAX_CONSECUTIVE_FAILURES) {
177+
if (result?.wasActive && !result.isActive) {
169178
logger.warn(
170179
`Webhook ${webhookId} auto-disabled after ${MAX_CONSECUTIVE_FAILURES} consecutive failures`
171180
)

‎apps/sim/lib/workflows/application/read-workflow-run.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ export const readWorkflowRun = defineAuthorizedWorkflowUseCase({
8585
viewerUserId: logProjectionSubjectUserId(principal),
8686
})
8787
if (!projected) throw new OrchestrationError('not_found', 'Run not found')
88-
const { status, projection, stateExecutionId } = projected
88+
const { status, projection, stateExecutionId, pausePoints: loadedPausePoints } = projected
8989

9090
/**
9191
* A run whose `blockOutputs` the viewer's group withholds joins the same
@@ -171,6 +171,7 @@ export const readWorkflowRun = defineAuthorizedWorkflowUseCase({
171171
(await readPausedRunPoints({
172172
workflowId: context.workflowId,
173173
executionId: stateExecutionId,
174+
pausePoints: loadedPausePoints ?? undefined,
174175
})) ?? []
175176
).map((point) => ({
176177
...point,

‎apps/sim/lib/workflows/application/triggers.integration.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -742,10 +742,16 @@ check('enabling a poller clears its failures and backoff but keeps its config',
742742

743743
check('a poll failure auto-disables at the threshold in the same write', async () => {
744744
const logger = createLogger('TriggerHealthIntegration')
745+
const warn = vi.spyOn(logger, 'warn')
745746
await markWebhookFailed(flakyWebhookId, logger)
746747
expect(await webhookRow(flakyWebhookId)).toMatchObject({ isActive: true, failedCount: 99 })
747748
await markWebhookFailed(flakyWebhookId, logger)
748749
expect(await webhookRow(flakyWebhookId)).toMatchObject({ isActive: false, failedCount: 100 })
750+
expect(warn).toHaveBeenCalledTimes(1)
751+
752+
await markWebhookFailed(flakyWebhookId, logger)
753+
expect(await webhookRow(flakyWebhookId)).toMatchObject({ isActive: false, failedCount: 101 })
754+
expect(warn).toHaveBeenCalledTimes(1)
749755

750756
await setBlock('flaky', true)
751757
await markWebhookFailed(flakyWebhookId, logger)

‎apps/sim/lib/workflows/executor/execution-status.ts‎

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,11 @@ export interface ProjectedWorkflowExecutionStatus {
256256
* resume attempt continued. Further reads of the run's state (its pause points) use this id.
257257
*/
258258
stateExecutionId: string
259+
/**
260+
* The state execution's raw `pause_points`, already loaded while the run is paused, so
261+
* {@link readPausedRunPoints} need not read them again; null when the run is not paused.
262+
*/
263+
pausePoints: unknown
259264
}
260265

261266
/**
@@ -286,6 +291,7 @@ export async function getProjectedWorkflowExecutionStatus(
286291
status: projectExecutionStatus(resolved.status, projection),
287292
projection,
288293
stateExecutionId: resolved.stateExecutionId,
294+
pausePoints: resolved.pausePoints,
289295
}
290296
}
291297

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

351359
async function readWorkflowExecutionStatus(
@@ -433,6 +441,7 @@ async function readWorkflowExecutionStatus(
433441
return {
434442
status: projectQueueJob(job, { executionId, includeOutput, workflowId }, deploymentVersion),
435443
stateExecutionId: executionId,
444+
pausePoints: null,
436445
}
437446
}
438447
}
@@ -455,7 +464,7 @@ async function readWorkflowExecutionStatus(
455464
finalOutput: null,
456465
blockOutputs: null,
457466
}
458-
return { status, stateExecutionId: executionId }
467+
return { status, stateExecutionId: executionId, pausePoints: null }
459468
}
460469

461470
if (!logRow) {
@@ -468,6 +477,7 @@ async function readWorkflowExecutionStatus(
468477
? {
469478
status: projectSettledResumeAttempt(executionId, resumeAttempt, run.status),
470479
stateExecutionId: run.stateExecutionId,
480+
pausePoints: run.pausePoints,
471481
}
472482
: null
473483
}
@@ -539,6 +549,7 @@ async function readWorkflowExecutionStatus(
539549
return {
540550
status: { ...resource, error: null, finalOutput: null, blockOutputs: null },
541551
stateExecutionId: executionId,
552+
pausePoints: paused ? pausedRow?.pausePoints : null,
542553
}
543554
}
544555

@@ -571,6 +582,7 @@ async function readWorkflowExecutionStatus(
571582
return {
572583
status: { ...resource, error, finalOutput, blockOutputs },
573584
stateExecutionId: executionId,
585+
pausePoints: paused ? pausedRow?.pausePoints : null,
574586
}
575587
}
576588

@@ -644,22 +656,31 @@ async function readRunBlockNames(
644656
* they were captured; an older point's stored response was never scrubbed, so both
645657
* are null for it. Resume links and API-mode response status and headers are never
646658
* part of a point.
659+
*
660+
* A caller that already loaded the paused record's `pause_points` passes them as
661+
* `pausePoints` and the record is not read again.
647662
*/
648663
export async function readPausedRunPoints(input: {
649664
workflowId: string
650665
executionId: string
666+
pausePoints?: unknown
651667
}): Promise<PausedRunPoint[] | null> {
652668
const { workflowId, executionId } = input
653-
const [row] = await db
654-
.select({ pausePoints: pausedExecutions.pausePoints })
655-
.from(pausedExecutions)
656-
.where(
657-
and(
658-
eq(pausedExecutions.executionId, executionId),
659-
eq(pausedExecutions.workflowId, workflowId)
660-
)
661-
)
662-
.limit(1)
669+
const row =
670+
input.pausePoints !== undefined
671+
? { pausePoints: input.pausePoints }
672+
: (
673+
await db
674+
.select({ pausePoints: pausedExecutions.pausePoints })
675+
.from(pausedExecutions)
676+
.where(
677+
and(
678+
eq(pausedExecutions.executionId, executionId),
679+
eq(pausedExecutions.workflowId, workflowId)
680+
)
681+
)
682+
.limit(1)
683+
)[0]
663684
if (!row) return null
664685

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

0 commit comments

Comments
 (0)