Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
2b88944
fix(v2-api): close two secret disclosures and align docs with signatures
waleedlatif1 Aug 11, 2026
32fca05
refactor(v2-api)!: flatten the single-resource response envelope
waleedlatif1 Aug 11, 2026
a2abd09
fix(v2-api): close a third secret disclosure and make concealment coh…
waleedlatif1 Aug 11, 2026
f633e79
docs(v2-api): correct eleven false or misleading spec claims
waleedlatif1 Aug 11, 2026
5d6c47d
test(v2-api): align upload concealment test with cross-tenant-only se…
waleedlatif1 Aug 11, 2026
6abc1b4
fix(v2-api): accept the redacting log status and envelope the knowled…
waleedlatif1 Aug 11, 2026
94fa43b
fix(uploads): restore archive extraction folder parity
waleedlatif1 Aug 11, 2026
0077285
chore(files): tidy archive extraction cleanup
waleedlatif1 Aug 11, 2026
427b260
fix(uploads): roll back folders archive extraction created
waleedlatif1 Aug 11, 2026
9ef805a
fix(billing): withhold the payer credit pool from v2 status readers
waleedlatif1 Aug 11, 2026
aa8f09a
chore(api): remove the unused public API route builder and dead endpo…
waleedlatif1 Aug 11, 2026
8ff822e
fix(billing): deny the payer pool to actor-less workspace API keys
waleedlatif1 Aug 11, 2026
46f9c2f
fix(folders): bound the workflow folderId-branch path index reads
waleedlatif1 Aug 11, 2026
79a3486
chore(billing): tidy payer-pool concealment cleanup
waleedlatif1 Aug 11, 2026
23b7792
fix(api): reject an undecodable offset cursor on v2 table rows
waleedlatif1 Aug 11, 2026
515096b
fix(api): restore v1 table error-response parity and stop internal me…
waleedlatif1 Aug 11, 2026
209e2c8
fix(skills): only reject a built-in name collision on an actual rename
waleedlatif1 Aug 11, 2026
5519d45
chore(tables): tidy v1 error projection cleanup
waleedlatif1 Aug 11, 2026
c8007ce
chore(skills): tidy collision guard cleanup
waleedlatif1 Aug 11, 2026
7af315f
Merge pull request #6565 from simstudioai/fix/archive-extraction-fold…
waleedlatif1 Aug 11, 2026
1667d3c
Merge pull request #6567 from simstudioai/fix/v2-billing-status-authz
waleedlatif1 Aug 11, 2026
3cab8ef
Merge pull request #6568 from simstudioai/chore/v2-dead-code-and-bounds
waleedlatif1 Aug 11, 2026
262ce32
Merge pull request #6569 from simstudioai/fix/v1-response-parity
waleedlatif1 Aug 11, 2026
fe7fd80
Merge pull request #6570 from simstudioai/fix/skills-builtin-name-col…
waleedlatif1 Aug 11, 2026
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
Prev Previous commit
Next Next commit
fix(api): restore v1 table error-response parity and stop internal me…
…ssage leak

The v1 table routes were rewritten to consume `lib/table/orchestration`
results, and two response behaviors drifted from what the live API returned.

Information disclosure: an unclassified failure's `outcome.error` carries
whatever text the fault happened to have. Drizzle wraps a throw raised inside
a transaction in an error whose own message is the failed statement and its
bound parameters, so `DELETE /api/v1/tables/{tableId}` and
`DELETE /api/v1/tables/{tableId}/rows/{rowId}` returned that verbatim in the
500 body to any API-key holder. Previously these returned a fixed generic
string.

Lost `lock` field: the 423 body used to be `{ error, lock }`. The delete,
row-delete, and column-update routes (v1 and internal) dropped the lock kind
the orchestration result already computes, leaving clients unable to tell
which lock to clear.

Both are fixed at one altitude: `orchestrationOutcomeErrorResponse` in
`app/api/table/utils.ts` is now the only way a table route projects an
orchestration failure onto the wire. It renders the route's fallback for an
unclassified failure and the real message for a classified one (validation,
not-found, conflict, locked keep their specific text), and carries `lock` on a
423. A future route cannot reintroduce either bug by hand-spelling the body.

Duplicate table names on `POST /api/v1/tables` keep answering 409 rather than
reverting to the previous 400. 409 is the correct semantic, and every other v1
duplicate-name surface (knowledge, files, workflow import) already answers 409;
the tables 400 was the outlier. v1 tables appears in no published OpenAPI
document and no in-repo client branches on the status, so the compatibility
cost is limited to a caller matching 400 specifically for a name collision.
  • Loading branch information
waleedlatif1 committed Aug 11, 2026
commit 515096b97ef6eeec5ff9d1ee5b0be0f602b43043
17 changes: 15 additions & 2 deletions apps/sim/app/api/table/[tableId]/columns/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
*/
import { hybridAuthMockFns } from '@sim/testing'
import { getErrorMessage } from '@sim/utils/errors'
import { NextRequest } from 'next/server'
import { NextRequest, NextResponse } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const {
Expand Down Expand Up @@ -53,11 +53,24 @@ vi.mock('@/app/api/table/utils', () => ({
accessError: () => new Response('denied', { status: 403 }),
checkAccess: mockCheckAccess,
normalizeColumn: (c: unknown) => c,
orchestrationOutcomeErrorResponse: (
outcome: { error?: string; errorCode?: OrchestrationErrorCode },
fallback: string
) =>
NextResponse.json(
{ error: messageForOrchestrationError(outcome, fallback) },
{ status: statusForOrchestrationError(outcome.errorCode) }
),
rootErrorMessage: (e: unknown) => getErrorMessage(e),
tableLockErrorResponse: () => null,
}))

import { OrchestrationError } from '@/lib/core/orchestration/types'
import {
messageForOrchestrationError,
OrchestrationError,
type OrchestrationErrorCode,
statusForOrchestrationError,
} from '@/lib/core/orchestration/types'
import { PATCH } from '@/app/api/table/[tableId]/columns/route'

const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'
Expand Down
7 changes: 2 additions & 5 deletions apps/sim/app/api/table/[tableId]/columns/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
import { parseRequest } from '@/lib/api/server'
import { isZodError, validationErrorResponse } from '@/lib/api/server/validation'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { statusForOrchestrationError } from '@/lib/core/orchestration/types'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { addTableColumn, deleteColumn } from '@/lib/table'
Expand All @@ -18,6 +17,7 @@ import {
accessError,
checkAccess,
normalizeColumn,
orchestrationOutcomeErrorResponse,
rootErrorMessage,
tableLockErrorResponse,
} from '@/app/api/table/utils'
Expand Down Expand Up @@ -122,10 +122,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
request,
})
if (!outcome.success || !outcome.table) {
return NextResponse.json(
{ error: outcome.error ?? 'Failed to update column' },
{ status: statusForOrchestrationError(outcome.errorCode) }
)
return orchestrationOutcomeErrorResponse(outcome, 'Failed to update column')
}

// Live-collab: tell open viewers the change landed so they refetch.
Expand Down
27 changes: 9 additions & 18 deletions apps/sim/app/api/table/[tableId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { getTableQuerySchema, updateTableContract } from '@/lib/api/contracts/ta
import { isZodError, parseRequest, validationErrorResponse } from '@/lib/api/server/validation'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
import { statusForOrchestrationError } from '@/lib/core/orchestration/types'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { findActiveFolder } from '@/lib/folders/queries'
Expand All @@ -24,6 +23,7 @@ import {
accessError,
checkAccess,
normalizeColumn,
orchestrationOutcomeErrorResponse,
tableLockErrorResponse,
} from '@/app/api/table/utils'

Expand Down Expand Up @@ -187,10 +187,7 @@ export const PATCH = withRouteHandler(
request,
})
if (!lockOutcome.success) {
return NextResponse.json(
{ error: lockOutcome.error ?? 'Failed to update table locks' },
{ status: statusForOrchestrationError(lockOutcome.errorCode) }
)
return orchestrationOutcomeErrorResponse(lockOutcome, 'Failed to update table locks')
}
}

Expand All @@ -203,10 +200,7 @@ export const PATCH = withRouteHandler(
request,
})
if (!renameOutcome.success) {
return NextResponse.json(
{ error: renameOutcome.error ?? 'Failed to rename table' },
{ status: statusForOrchestrationError(renameOutcome.errorCode) }
)
return orchestrationOutcomeErrorResponse(renameOutcome, 'Failed to rename table')
}
}

Expand All @@ -229,11 +223,11 @@ export const PATCH = withRouteHandler(
request,
})
if (!moveOutcome.success) {
return NextResponse.json(
{
error: moveOutcome.errorCode === 'not_found' ? 'Table not found' : moveOutcome.error,
},
{ status: statusForOrchestrationError(moveOutcome.errorCode) }
return orchestrationOutcomeErrorResponse(
moveOutcome.errorCode === 'not_found'
? { ...moveOutcome, error: 'Table not found' }
: moveOutcome,
'Failed to move table'
)
}
}
Expand Down Expand Up @@ -302,10 +296,7 @@ export const DELETE = withRouteHandler(
request,
})
if (!outcome.success) {
return NextResponse.json(
{ error: outcome.error ?? 'Failed to delete table' },
{ status: statusForOrchestrationError(outcome.errorCode) }
)
return orchestrationOutcomeErrorResponse(outcome, 'Failed to delete table')
}

return NextResponse.json({
Expand Down
7 changes: 2 additions & 5 deletions apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
} from '@/lib/api/contracts/tables'
import { isZodError, parseRequest, validationErrorResponse } from '@/lib/api/server/validation'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { statusForOrchestrationError } from '@/lib/core/orchestration/types'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import type { RowData, TableSchema } from '@/lib/table'
Expand All @@ -27,6 +26,7 @@ import {
accessError,
checkAccess,
orchestrationErrorResponse,
orchestrationOutcomeErrorResponse,
rowWriteErrorResponse,
tableLockErrorResponse,
} from '@/app/api/table/utils'
Expand Down Expand Up @@ -248,10 +248,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row

const outcome = await performDeleteTableRow({ table, rowId, requestId })
if (!outcome.success) {
return NextResponse.json(
{ error: outcome.error ?? 'Failed to delete row' },
{ status: statusForOrchestrationError(outcome.errorCode) }
)
return orchestrationOutcomeErrorResponse(outcome, 'Failed to delete row')
}

// Live-collab: tell open viewers the change landed so they refetch.
Expand Down
64 changes: 63 additions & 1 deletion apps/sim/app/api/table/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ import { describe, expect, it } from 'vitest'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { TableRowLimitError } from '@/lib/table/billing'
import type { ColumnDefinition } from '@/lib/table/types'
import { rootErrorMessage, rowWriteErrorResponse, tableFilterError } from '@/app/api/table/utils'
import {
orchestrationOutcomeErrorResponse,
rootErrorMessage,
rowWriteErrorResponse,
tableFilterError,
} from '@/app/api/table/utils'

/** Mimics drizzle's DrizzleQueryError: message is the failed SQL, real error on `cause`. */
function wrapLikeDrizzle(cause: Error): Error {
Expand Down Expand Up @@ -117,3 +122,60 @@ describe('tableFilterError', () => {
expect(tableFilterError({ col_status: { $regex: 'x' } } as never, columns)?.status).toBe(400)
})
})

describe('orchestrationOutcomeErrorResponse', () => {
/**
* Shaped like a driver fault surfacing verbatim — a statement plus its bound
* parameters — so the assertion proves none of it reaches the response body.
*/
const leakyMessage =
'Failed query: delete from "user_table" where "user_table"."id" = $1 params: tbl-1'

it('replaces an unclassified failure message with the fallback', async () => {
const response = orchestrationOutcomeErrorResponse(
{ success: false, error: leakyMessage, errorCode: 'internal' },
'Failed to delete table'
)

expect(response.status).toBe(500)
const body = await response.json()
expect(body).toEqual({ error: 'Failed to delete table' })
expect(JSON.stringify(body)).not.toContain('Failed query')
expect(JSON.stringify(body)).not.toContain('params:')
})

it('replaces an unclassified failure with no error code too', async () => {
const response = orchestrationOutcomeErrorResponse(
{ error: leakyMessage },
'Failed to delete table'
)

expect(response.status).toBe(500)
expect(await response.json()).toEqual({ error: 'Failed to delete table' })
})

it('keeps the message of a classified failure', async () => {
const response = orchestrationOutcomeErrorResponse(
{ error: 'A table named "Orders" already exists in this workspace', errorCode: 'conflict' },
'Failed to rename table'
)

expect(response.status).toBe(409)
expect(await response.json()).toEqual({
error: 'A table named "Orders" already exists in this workspace',
})
})

it('carries the rejecting lock kind on a 423', async () => {
const response = orchestrationOutcomeErrorResponse(
{ error: 'Table is locked against deletion', errorCode: 'locked', lock: 'delete' },
'Failed to delete table'
)

expect(response.status).toBe(423)
expect(await response.json()).toEqual({
error: 'Table is locked against deletion',
lock: 'delete',
})
})
})
47 changes: 46 additions & 1 deletion apps/sim/app/api/table/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ import {
updateTableColumnBodySchema,
} from '@/lib/api/contracts/tables'
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types'
import {
asOrchestrationError,
messageForOrchestrationError,
type OrchestrationErrorCode,
statusForOrchestrationError,
} from '@/lib/core/orchestration/types'
import type { MultipartError } from '@/lib/core/utils/multipart'
import type { ColumnDefinition, Filter, TableDefinition, TablePredicate } from '@/lib/table'
import { buildFilterClause, getTableById, TableQueryValidationError } from '@/lib/table'
Expand All @@ -17,6 +22,7 @@ import { USER_TABLE_ROWS_SQL_NAME } from '@/lib/table/constants'
import { TableLockedError } from '@/lib/table/mutation-locks'
import { isTablePredicate } from '@/lib/table/query-builder/converters'
import { validateStoragePredicate } from '@/lib/table/query-builder/validate'
import type { TableLockKind } from '@/lib/table/types'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { getWorkspaceOrganizationId } from '@/lib/workspaces/utils'

Expand Down Expand Up @@ -132,6 +138,45 @@ export function orchestrationErrorResponse(error: unknown): NextResponse | null
)
}

/**
* The failure half of a `lib/table/orchestration` result. Every `perform*`
* function returns this shape, so one projection serves all of them.
*/
export interface TableOrchestrationFailure {
error?: string
errorCode?: OrchestrationErrorCode
/** Which lock rejected the write. Set only when `errorCode` is `'locked'`. */
lock?: TableLockKind
}

/**
* Projects an orchestration failure RESULT onto its HTTP response, the
* counterpart of {@link orchestrationErrorResponse} for the functions that
* return a failure instead of throwing one.
*
* Every table route must go through this rather than reading `outcome.error`
* itself, for two reasons the per-route spellings kept getting wrong:
*
* - An unclassified failure carries whatever text the fault happened to have —
* a driver's failed SQL and its bound parameters — so it renders `fallback`
* instead. Only a classified, caller-fixable failure keeps its own message.
* - A `'locked'` failure answers 423 with `{ error, lock }`. The lock kind is
* the only thing that tells a client which lock to clear, and it is computed
* by every `perform*` function already.
*/
export function orchestrationOutcomeErrorResponse(
outcome: TableOrchestrationFailure,
fallback: string
): NextResponse {
return NextResponse.json(
{
error: messageForOrchestrationError(outcome, fallback),
...(outcome.lock ? { lock: outcome.lock } : {}),
},
{ status: statusForOrchestrationError(outcome.errorCode) }
)
}

/**
* {@link orchestrationErrorResponse} under the name the row-write routes call
* it by. Row writes have no classification rules of their own any more.
Expand Down
7 changes: 2 additions & 5 deletions apps/sim/app/api/v1/tables/[tableId]/columns/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
v1UpdateTableColumnContract,
} from '@/lib/api/contracts/v1/tables'
import { parseRequest } from '@/lib/api/server'
import { statusForOrchestrationError } from '@/lib/core/orchestration/types'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { addTableColumn, deleteColumn } from '@/lib/table'
Expand All @@ -18,6 +17,7 @@ import {
checkAccess,
normalizeColumn,
orchestrationErrorResponse,
orchestrationOutcomeErrorResponse,
tableLockErrorResponse,
} from '@/app/api/table/utils'
import {
Expand Down Expand Up @@ -143,10 +143,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
request,
})
if (!outcome.success || !outcome.table) {
return NextResponse.json(
{ error: outcome.error ?? 'Failed to update column' },
{ status: statusForOrchestrationError(outcome.errorCode) }
)
return orchestrationOutcomeErrorResponse(outcome, 'Failed to update column')
}

// Live-collab: tell open viewers the change landed so they refetch.
Expand Down
Loading