Skip to content

Commit 4266ef6

Browse files
authored
fix(release): small release-audit cleanups (#9863)
* 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. * test(webhooks): overlapping poll failures count every failure and disable once * test(mcp): expect the search:read scope a full-access key reaches in v2 meta
1 parent 0b8f632 commit 4266ef6

11 files changed

Lines changed: 102 additions & 24 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/api/mcp/route-handler.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ describe('Sim MCP tools', () => {
102102
v2Enabled: true,
103103
keyType: 'personal',
104104
expiresAt: null,
105-
scopes: ['api:read', 'api:write'],
105+
scopes: ['api:read', 'api:write', 'search:read'],
106106
},
107107
})
108108
expect(v2RouteMocks.authenticate).toHaveBeenCalledTimes(2)

‎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: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -742,16 +742,36 @@ 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)
752758
expect(await webhookRow(flakyWebhookId)).toMatchObject({ isActive: true, failedCount: 1 })
753759
})
754760

761+
check('overlapping failures at the threshold count every failure and disable once', async () => {
762+
const logger = createLogger('TriggerHealthConcurrency')
763+
const warn = vi.spyOn(logger, 'warn')
764+
await db
765+
.update(webhook)
766+
.set({ isActive: true, failedCount: 97 })
767+
.where(eq(webhook.id, flakyWebhookId))
768+
769+
await Promise.all(Array.from({ length: 6 }, () => markWebhookFailed(flakyWebhookId, logger)))
770+
771+
expect(await webhookRow(flakyWebhookId)).toMatchObject({ isActive: false, failedCount: 103 })
772+
expect(warn).toHaveBeenCalledTimes(1)
773+
})
774+
755775
check('an unknown block is not found', async () => {
756776
await expect(setBlock('missing', true)).rejects.toMatchObject({
757777
code: 'not_found',

0 commit comments

Comments
 (0)