Skip to content

Commit b5f0d01

Browse files
fix(api): close unique-violation, revival, orphan-write, and env-rename gaps
1 parent d93dab4 commit b5f0d01

9 files changed

Lines changed: 154 additions & 55 deletions

File tree

apps/sim/app/api/v2/credentials/[id]/route.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,25 @@ describe('PATCH /api/v2/credentials/[id]', () => {
276276
expect((await res.json()).error.code).toBe('SERVICE_UNAVAILABLE')
277277
})
278278

279+
it('rejects a displayName rename on an env credential instead of dropping it', async () => {
280+
mockGetWorkspaceCredential.mockResolvedValue(
281+
buildRow({ type: 'env_workspace', envKey: 'STRIPE_API_KEY', displayName: 'STRIPE_API_KEY' })
282+
)
283+
const res = await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' })
284+
285+
expect(res.status).toBe(400)
286+
expect((await res.json()).error.message).toContain('envKey')
287+
expect(mockPerformUpdateCredential).not.toHaveBeenCalled()
288+
})
289+
290+
it('still allows a description change on an env credential', async () => {
291+
mockGetWorkspaceCredential.mockResolvedValue(buildRow({ type: 'env_workspace' }))
292+
const res = await callPatch({ workspaceId: WORKSPACE_ID, description: 'note' })
293+
294+
expect(res.status).toBe(200)
295+
expect(mockPerformUpdateCredential).toHaveBeenCalled()
296+
})
297+
279298
it('rotates a secret without echoing it back', async () => {
280299
const res = await callPatch({ workspaceId: WORKSPACE_ID, apiToken: 'brand-new-token' })
281300
const body = await res.json()

apps/sim/app/api/v2/credentials/[id]/route.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,22 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout
118118
const actor = await getCredentialActorContext(id, userId)
119119
if (!actor.member && !actor.isAdmin) return v2Error('NOT_FOUND', 'Credential not found')
120120

121+
/**
122+
* An env credential's display name IS its `envKey` — the lib only applies
123+
* `displayName` to `oauth` and `service_account`, so accepting it here would
124+
* either drop the rename silently (when sent alongside `description`) or
125+
* fail with an unrelated environment-editor message (when sent alone).
126+
*/
127+
if (
128+
changes.displayName !== undefined &&
129+
(existing.type === 'env_workspace' || existing.type === 'env_personal')
130+
) {
131+
return v2Error(
132+
'BAD_REQUEST',
133+
'displayName cannot be set on an environment credential — its name is its envKey. Delete it and create one under the new key.'
134+
)
135+
}
136+
121137
const result = await performUpdateCredential({ ...changes, credentialId: id, userId, request })
122138

123139
if (!result.success) {

apps/sim/app/api/v2/custom-tools/[id]/route.test.ts

Lines changed: 25 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,14 @@ const {
1414
mockGetWorkspaceCustomTool,
1515
mockGetWorkspaceCustomToolByTitle,
1616
mockDeleteWorkspaceCustomTool,
17-
mockUpsertCustomTools,
17+
mockUpdateWorkspaceCustomTool,
1818
} = vi.hoisted(() => ({
1919
mockCheckRateLimit: vi.fn(),
2020
mockResolveWorkspaceAccess: vi.fn(),
2121
mockGetWorkspaceCustomTool: vi.fn(),
2222
mockGetWorkspaceCustomToolByTitle: vi.fn(),
2323
mockDeleteWorkspaceCustomTool: vi.fn(),
24-
mockUpsertCustomTools: vi.fn(),
24+
mockUpdateWorkspaceCustomTool: vi.fn(),
2525
}))
2626

2727
vi.mock('@/app/api/v1/middleware', () => ({
@@ -33,7 +33,7 @@ vi.mock('@/lib/workflows/custom-tools/operations', () => ({
3333
getWorkspaceCustomTool: mockGetWorkspaceCustomTool,
3434
getWorkspaceCustomToolByTitle: mockGetWorkspaceCustomToolByTitle,
3535
deleteWorkspaceCustomTool: mockDeleteWorkspaceCustomTool,
36-
upsertCustomTools: mockUpsertCustomTools,
36+
updateWorkspaceCustomTool: mockUpdateWorkspaceCustomTool,
3737
}))
3838

3939
vi.mock('@/app/api/v2/lib/gate', () => ({
@@ -175,7 +175,7 @@ describe('PATCH /api/v2/custom-tools/[id]', () => {
175175
mockResolveWorkspaceAccess.mockResolvedValue(null)
176176
mockGetWorkspaceCustomTool.mockResolvedValue(buildTool())
177177
mockGetWorkspaceCustomToolByTitle.mockResolvedValue(null)
178-
mockUpsertCustomTools.mockResolvedValue([buildTool()])
178+
mockUpdateWorkspaceCustomTool.mockResolvedValue(buildTool())
179179
})
180180

181181
it('returns 404 when the v2 API surface flag is off', async () => {
@@ -186,20 +186,20 @@ describe('PATCH /api/v2/custom-tools/[id]', () => {
186186
const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 1' })
187187

188188
expect(res.status).toBe(404)
189-
expect(mockUpsertCustomTools).not.toHaveBeenCalled()
189+
expect(mockUpdateWorkspaceCustomTool).not.toHaveBeenCalled()
190190
})
191191

192192
it('400s when no field to change is supplied', async () => {
193193
const res = await callPatch({ workspaceId: 'workspace-1' })
194194
expect(res.status).toBe(400)
195-
expect(mockUpsertCustomTools).not.toHaveBeenCalled()
195+
expect(mockUpdateWorkspaceCustomTool).not.toHaveBeenCalled()
196196
})
197197

198198
it('surfaces an access-denied failure in the v2 error envelope', async () => {
199199
mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED)
200200
const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 1' })
201201
expect(res.status).toBe(403)
202-
expect(mockUpsertCustomTools).not.toHaveBeenCalled()
202+
expect(mockUpdateWorkspaceCustomTool).not.toHaveBeenCalled()
203203
})
204204

205205
it('returns the rate-limit response when denied', async () => {
@@ -213,7 +213,7 @@ describe('PATCH /api/v2/custom-tools/[id]', () => {
213213
mockGetWorkspaceCustomTool.mockResolvedValue(null)
214214
const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 1' })
215215
expect(res.status).toBe(404)
216-
expect(mockUpsertCustomTools).not.toHaveBeenCalled()
216+
expect(mockUpdateWorkspaceCustomTool).not.toHaveBeenCalled()
217217
})
218218

219219
it('409s when renaming onto an existing title', async () => {
@@ -223,27 +223,29 @@ describe('PATCH /api/v2/custom-tools/[id]', () => {
223223

224224
expect(res.status).toBe(409)
225225
expect((await res.json()).error.code).toBe('CONFLICT')
226-
expect(mockUpsertCustomTools).not.toHaveBeenCalled()
226+
expect(mockUpdateWorkspaceCustomTool).not.toHaveBeenCalled()
227227
})
228228

229229
it('merges the partial body against the stored tool', async () => {
230230
const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 2' })
231231

232232
expect(res.status).toBe(200)
233-
expect(mockUpsertCustomTools).toHaveBeenCalledWith(
234-
expect.objectContaining({
235-
workspaceId: 'workspace-1',
236-
userId: 'user-1',
237-
tools: [
238-
{
239-
id: 'tool_abc123',
240-
title: 'lookup_order',
241-
schema: TOOL_SCHEMA,
242-
code: 'return 2',
243-
},
244-
],
245-
})
246-
)
233+
expect(mockUpdateWorkspaceCustomTool).toHaveBeenCalledWith({
234+
workspaceId: 'workspace-1',
235+
toolId: 'tool_abc123',
236+
title: 'lookup_order',
237+
schema: TOOL_SCHEMA,
238+
code: 'return 2',
239+
})
240+
})
241+
242+
it('404s rather than orphaning a tool deleted between the read and the write', async () => {
243+
mockUpdateWorkspaceCustomTool.mockResolvedValue(null)
244+
245+
const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 2' })
246+
247+
expect(res.status).toBe(404)
248+
expect((await res.json()).error.code).toBe('NOT_FOUND')
247249
})
248250
})
249251

apps/sim/app/api/v2/custom-tools/[id]/route.ts

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
deleteWorkspaceCustomTool,
1515
getWorkspaceCustomTool,
1616
getWorkspaceCustomToolByTitle,
17-
upsertCustomTools,
17+
updateWorkspaceCustomTool,
1818
} from '@/lib/workflows/custom-tools/operations'
1919
import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
2020
import { toV2CustomTool, v2CustomToolWriteError } from '@/app/api/v2/custom-tools/utils'
@@ -114,21 +114,13 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout
114114
}
115115
}
116116

117-
await upsertCustomTools({
118-
tools: [
119-
{
120-
id,
121-
title: title ?? current.title,
122-
schema: schema ?? current.schema,
123-
code: code ?? current.code,
124-
},
125-
],
117+
const updated = await updateWorkspaceCustomTool({
126118
workspaceId,
127-
userId,
128-
requestId,
119+
toolId: id,
120+
title: title ?? current.title,
121+
schema: schema ?? current.schema,
122+
code: code ?? current.code,
129123
})
130-
131-
const updated = await getWorkspaceCustomTool({ workspaceId, toolId: id })
132124
if (!updated) return v2Error('NOT_FOUND', 'Custom tool not found')
133125

134126
recordAudit({

apps/sim/app/api/v2/mcp-servers/route.test.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,14 @@ const {
1414
mockResolveWorkspaceAccess,
1515
mockListWorkspaceMcpServers,
1616
mockGetWorkspaceMcpServer,
17-
mockMcpServerIdExists,
17+
mockGetMcpServerIdState,
1818
mockPerformCreateMcpServer,
1919
} = vi.hoisted(() => ({
2020
mockCheckRateLimit: vi.fn(),
2121
mockResolveWorkspaceAccess: vi.fn(),
2222
mockListWorkspaceMcpServers: vi.fn(),
2323
mockGetWorkspaceMcpServer: vi.fn(),
24-
mockMcpServerIdExists: vi.fn(),
24+
mockGetMcpServerIdState: vi.fn(),
2525
mockPerformCreateMcpServer: vi.fn(),
2626
}))
2727

@@ -33,7 +33,7 @@ vi.mock('@/app/api/v1/middleware', () => ({
3333
vi.mock('@/lib/mcp/queries', () => ({
3434
listWorkspaceMcpServers: mockListWorkspaceMcpServers,
3535
getWorkspaceMcpServer: mockGetWorkspaceMcpServer,
36-
mcpServerIdExists: mockMcpServerIdExists,
36+
getMcpServerIdState: mockGetMcpServerIdState,
3737
}))
3838

3939
vi.mock('@/lib/mcp/orchestration', () => ({
@@ -204,7 +204,7 @@ describe('POST /api/v2/mcp-servers', () => {
204204
vi.clearAllMocks()
205205
mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
206206
mockResolveWorkspaceAccess.mockResolvedValue(null)
207-
mockMcpServerIdExists.mockResolvedValue(false)
207+
mockGetMcpServerIdState.mockResolvedValue(null)
208208
mockPerformCreateMcpServer.mockResolvedValue({
209209
success: true,
210210
serverId: 'mcp-abc12345',
@@ -269,7 +269,7 @@ describe('POST /api/v2/mcp-servers', () => {
269269
})
270270

271271
it('409s on a duplicate URL without letting the lib upsert', async () => {
272-
mockMcpServerIdExists.mockResolvedValue(true)
272+
mockGetMcpServerIdState.mockResolvedValue({ deleted: false })
273273

274274
const res = await callCreate(VALID_BODY)
275275

@@ -291,6 +291,20 @@ describe('POST /api/v2/mcp-servers', () => {
291291
expect((await res.json()).error.code).toBe('CONFLICT')
292292
})
293293

294+
it('revives a soft-deleted URL instead of stranding it behind a 409', async () => {
295+
mockGetMcpServerIdState.mockResolvedValue({ deleted: true })
296+
mockPerformCreateMcpServer.mockResolvedValue({
297+
success: true,
298+
serverId: 'mcp-abc12345',
299+
updated: true,
300+
})
301+
302+
const res = await callCreate(VALID_BODY)
303+
304+
expect(res.status).toBe(201)
305+
expect(mockPerformCreateMcpServer).toHaveBeenCalled()
306+
})
307+
294308
it('creates the server and returns 201 with the public shape', async () => {
295309
const res = await callCreate({ ...VALID_BODY, headers: { Authorization: 'Bearer tok' } })
296310
const body = await res.json()

apps/sim/app/api/v2/mcp-servers/route.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ import { generateRequestId } from '@/lib/core/utils/request'
1010
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1111
import { performCreateMcpServer } from '@/lib/mcp/orchestration'
1212
import {
13+
getMcpServerIdState,
1314
getWorkspaceMcpServer,
1415
listWorkspaceMcpServers,
15-
mcpServerIdExists,
1616
} from '@/lib/mcp/queries'
1717
import { generateMcpServerId } from '@/lib/mcp/utils'
1818
import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
@@ -106,14 +106,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
106106
* same URL silently overwrites the first. The internal surface and the
107107
* copilot rely on that; a public create must not, so the collision is
108108
* detected here, before the lib is given a chance to clobber the row.
109+
*
110+
* Only a *live* row is a conflict. A soft-deleted one is revived by the lib
111+
* rather than inserted alongside, and reporting it as a duplicate would
112+
* strand that URL for good: the detail routes resolve live rows only, so it
113+
* could be neither fetched, patched, nor re-created.
109114
*/
110115
const serverId = generateMcpServerId(workspaceId, body.url)
111-
if (await mcpServerIdExists({ workspaceId, serverId })) {
116+
const idState = await getMcpServerIdState({ workspaceId, serverId })
117+
if (idState && !idState.deleted) {
112118
return v2Error(
113119
'CONFLICT',
114120
'An MCP server with this URL already exists in this workspace. Update it with PATCH /api/v2/mcp-servers/{id}.'
115121
)
116122
}
123+
const revivingSoftDeleted = idState?.deleted === true
117124

118125
const result = await performCreateMcpServer({
119126
workspaceId,
@@ -138,8 +145,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
138145
return v2McpOrchestrationError(result.errorCode, result.error ?? 'Failed to register server')
139146
}
140147

141-
// A concurrent create won the id race and the lib upserted onto it.
142-
if (result.updated) {
148+
/**
149+
* `updated` means the lib wrote onto an existing row. Reviving the
150+
* soft-deleted row we already saw is the intended outcome; otherwise a
151+
* concurrent create won the id race between the check above and the write.
152+
*/
153+
if (result.updated && !revivingSoftDeleted) {
143154
return v2Error('CONFLICT', 'An MCP server with this URL already exists in this workspace.')
144155
}
145156

apps/sim/lib/mcp/queries.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,19 +41,23 @@ export async function getWorkspaceMcpServer(params: {
4141
}
4242

4343
/**
44-
* Whether a row already occupies the deterministic id derived from a workspace
45-
* and URL — soft-deleted rows included, because the create path revives rather
46-
* than inserts alongside them. Lets a caller reject a duplicate registration
47-
* before the upsert in `performCreateMcpServer` overwrites the existing row.
44+
* The state of the row occupying the deterministic id derived from a workspace
45+
* and URL, or null when the id is free.
46+
*
47+
* The soft-deleted case has to be distinguished rather than merged into "taken":
48+
* `performCreateMcpServer` revives such a row instead of inserting alongside it,
49+
* so reporting it as a duplicate would make a soft-deleted URL permanently
50+
* unusable — it cannot be fetched or patched either, since those resolve live
51+
* rows only.
4852
*/
49-
export async function mcpServerIdExists(params: {
53+
export async function getMcpServerIdState(params: {
5054
workspaceId: string
5155
serverId: string
52-
}): Promise<boolean> {
56+
}): Promise<{ deleted: boolean } | null> {
5357
const [row] = await db
54-
.select({ id: mcpServers.id })
58+
.select({ deletedAt: mcpServers.deletedAt })
5559
.from(mcpServers)
5660
.where(and(eq(mcpServers.id, params.serverId), eq(mcpServers.workspaceId, params.workspaceId)))
5761
.limit(1)
58-
return Boolean(row)
62+
return row ? { deleted: row.deletedAt !== null } : null
5963
}

apps/sim/lib/skills/orchestration/skill-lifecycle.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
22
import type { skill } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
4-
import { getErrorMessage } from '@sim/utils/errors'
4+
import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors'
55
import type { NextRequest } from 'next/server'
66
import type { z } from 'zod'
77
import {
@@ -151,9 +151,21 @@ async function resolveEditableSkill(params: {
151151
/**
152152
* `upsertSkills` reports name collisions and vanished ids as thrown Errors.
153153
* Classify them rather than letting every caller re-match the message.
154+
*
155+
* The `23505` arm covers the race its in-transaction name `SELECT` cannot: two
156+
* concurrent creates (or renames) both pass that check, and the loser is rejected
157+
* by `skill_workspace_name_unique` as a raw Postgres error whose message matches
158+
* nothing here — which would otherwise surface as a 500 for what is a conflict.
154159
*/
155160
function classifyUpsertError(error: unknown): PerformSkillResult {
156161
const message = getErrorMessage(error, 'Failed to save skill')
162+
if (getPostgresErrorCode(error) === '23505') {
163+
return {
164+
success: false,
165+
error: 'That skill name is unavailable in this workspace',
166+
errorCode: 'conflict',
167+
}
168+
}
157169
if (message.includes('is unavailable')) {
158170
return { success: false, error: message, errorCode: 'conflict' }
159171
}

0 commit comments

Comments
 (0)