Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 7 additions & 1 deletion apps/docs/openapi-v2-files-audit.json
Original file line number Diff line number Diff line change
Expand Up @@ -566,7 +566,7 @@
"get": {
"operationId": "downloadFile",
"summary": "Download File",
"description": "Download the current file bytes from a workspace.",
"description": "Download the current file bytes from a workspace. A generated document is served as its compiled artifact, so it returns `409` while that artifact is still compiling and `413` if it renders past the size ceiling.",
"tags": ["Files"],
"parameters": [
{
Expand Down Expand Up @@ -638,6 +638,12 @@
"404": {
"$ref": "#/components/responses/NotFound"
},
"409": {
"$ref": "#/components/responses/Conflict"
},
"413": {
"$ref": "#/components/responses/PayloadTooLarge"
},
"429": {
"$ref": "#/components/responses/RateLimited"
},
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/openapi-v2-knowledge.json
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@
}
}
},
"put": {
"patch": {
"operationId": "updateKnowledgeBase",
"summary": "Update Knowledge Base",
"description": "Update a knowledge base name, description, chunking configuration, or folder placement.",
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/openapi-v2-tables.json
Original file line number Diff line number Diff line change
Expand Up @@ -864,7 +864,7 @@
}
}
},
"put": {
"patch": {
"operationId": "updateTableRows",
"summary": "Update Rows by Filter",
"description": "Apply the same partial data patch to every row matching a non-empty predicate.",
Expand Down
6 changes: 2 additions & 4 deletions apps/sim/app/api/files/serve/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,8 @@ import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { fileServeParamsSchema, fileServeQuerySchema } from '@/lib/api/contracts/storage-transfer'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import {
DocCompileUserError,
resolveServableDocBytes,
} from '@/lib/copilot/tools/server/files/doc-compile'
import { resolveServableDocBytes } from '@/lib/copilot/tools/server/files/doc-compile'
import { DocCompileUserError } from '@/lib/copilot/tools/server/files/doc-compile-error'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { CopilotFiles, isUsingCloudStorage } from '@/lib/uploads'
import type { StorageContext } from '@/lib/uploads/config'
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/app/api/v1/files/[fileId]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ vi.mock('@sim/audit', () => ({
}))
vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() }))

import { DocCompileUserError } from '@/lib/copilot/tools/server/files/doc-compile'
import { DocCompileUserError } from '@/lib/copilot/tools/server/files/doc-compile-error'
import { GET } from '@/app/api/v1/files/[fileId]/route'

const WORKSPACE_ID = 'ws-1'
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/app/api/v1/files/[fileId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
fetchServableWorkspaceFileBuffer,
getWorkspaceFile,
} from '@/lib/uploads/contexts/workspace'
import { docNotReadyMessage, isDocNotReadyError } from '@/lib/uploads/utils/servable-file-response'
import { docNotReadyMessage, isDocNotReadyError } from '@/lib/uploads/utils/doc-not-ready'
import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration'
import {
checkRateLimit,
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/app/api/v2/files/[fileId]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ describe('v2 single-file routes', () => {
mocks.download.mockResolvedValue({
file: fileRecord(),
stream: new Blob(['id,name\n']).stream(),
contentType: 'text/csv',
contentLength: 'id,name\n'.length,
})
mocks.rename.mockResolvedValue({ file: fileRecord({ name: 'renamed.csv' }) })
mocks.deleteFile.mockResolvedValue({
Expand Down
8 changes: 5 additions & 3 deletions apps/sim/app/api/v2/files/[fileId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export const revalidate = 0
* The response carries no JSON envelope, so rate-limit state is surfaced via
* `X-RateLimit-*` headers. Errors still render the canonical v2 JSON error body.
* Lookups are workspace-scoped (IDOR-safe): a file in another workspace 404s.
*
* A generated doc whose artifact is still compiling renders `CONFLICT`; retry.
*/
export const GET = defineV2BinaryRoute({
contract: v2DownloadFileContract,
Expand All @@ -37,11 +39,11 @@ export const GET = defineV2BinaryRoute({
assertedWorkspaceId: query.workspaceId,
}),
useCase: downloadWorkspaceFileStream,
present: ({ file, stream }) => ({
present: ({ file, stream, contentType, contentLength }) => ({
body: stream,
contentType: file.type || 'application/octet-stream',
contentType,
contentDisposition: `attachment; filename="${file.name.replace(/[^\w.-]/g, '_')}"; filename*=UTF-8''${encodeURIComponent(file.name)}`,
contentLength: file.size,
contentLength,
}),
})

Expand Down
4 changes: 2 additions & 2 deletions apps/sim/app/api/v2/knowledge/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ export const GET = defineV2JsonRoute({
}),
})

/** PUT /api/v2/knowledge/[id] — Update a knowledge base. */
export const PUT = defineV2JsonRoute({
/** PATCH /api/v2/knowledge/[id] — Partially update a knowledge base. */
export const PATCH = defineV2JsonRoute({
contract: v2UpdateKnowledgeBaseContract,
auth: v2ApiKeyAuth,
operation: knowledgeOperations.update,
Expand Down
8 changes: 4 additions & 4 deletions apps/sim/app/api/v2/tables/[tableId]/rows/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ vi.mock('@/lib/table/application/rows', () => ({
deleteTableRows: { operation: { id: 'tables.rows.delete_many' }, execute: mocks.deleteRows },
}))

import { DELETE, GET, POST, PUT } from '@/app/api/v2/tables/[tableId]/rows/route'
import { DELETE, GET, PATCH, POST } from '@/app/api/v2/tables/[tableId]/rows/route'

const WORKSPACE_ID = 'workspace-1'
const PRINCIPAL = {
Expand Down Expand Up @@ -76,7 +76,7 @@ const ROW = {
}
const CONTEXT = { params: Promise.resolve({ tableId: 'table-1' }) }

function request(method: 'GET' | 'POST' | 'PUT' | 'DELETE', body?: unknown, query = '') {
function request(method: 'GET' | 'POST' | 'PATCH' | 'DELETE', body?: unknown, query = '') {
return new NextRequest(`http://localhost/api/v2/tables/table-1/rows${query}`, {
method,
headers: {
Expand Down Expand Up @@ -159,12 +159,12 @@ describe('/api/v2/tables/[tableId]/rows', () => {

it('preserves authoritative bulk update counts including a zero-match result', async () => {
mocks.updateRows.mockResolvedValue({ table: TABLE, affectedCount: 0, affectedRowIds: [] })
const req = request('PUT', {
const req = request('PATCH', {
workspaceId: WORKSPACE_ID,
filter: { all: [{ field: 'name', op: 'eq', value: 'missing' }] },
data: { name: 'Grace' },
})
const response = await PUT(req, CONTEXT)
const response = await PATCH(req, CONTEXT)

expect(response.status).toBe(200)
expect(await response.json()).toEqual({ data: { updatedCount: 0, updatedRowIds: [] } })
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/app/api/v2/tables/[tableId]/rows/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export const POST = defineV2JsonRoute({
},
})

export const PUT = defineV2JsonRoute({
export const PATCH = defineV2JsonRoute({
contract: v2UpdateRowsByFilterContract,
operation: tableOperations.updateRows,
auth: v2ApiKeyAuth,
Expand Down
196 changes: 196 additions & 0 deletions apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
/**
* @vitest-environment node
*/
import { readdirSync } from 'node:fs'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import type { z } from 'zod'

/**
* Pins which v2 lists are paged.
*
* Every v2 list returns the same `{ data, nextCursor }` envelope, but only some
* accept `limit` + `cursor` and can return a non-null `nextCursor`. That split
* is a documented part of the public contract (`v2/shared.ts`) and has already
* drifted once — `GET /api/v2/tables` gained real pagination while its contract
* docstring still claimed a single full page. Enumerating the two sets here
* makes the next such change fail a test instead of rotting a comment, and
* makes flipping a shipped list from full-set to paged a deliberate edit: a
* `limit` with a default silently truncates callers that read the full set
* today.
*/

const CONTRACTS_DIR = path.resolve(import.meta.dirname, '..', '..')

/** Lists that accept `limit` + `cursor` and can return a non-null `nextCursor`. */
const PAGED_LISTS = [
'GET /api/v2/audit-logs',
'GET /api/v2/billing/logs',
'GET /api/v2/files',
'GET /api/v2/knowledge/[id]/documents',
'GET /api/v2/logs',
'GET /api/v2/tables',
'GET /api/v2/tables/[tableId]/rows',
'POST /api/v2/tables/[tableId]/query',
'GET /api/v2/workflows',
'GET /api/v2/workflows/[id]/runs',
'GET /api/v2/workflows/[id]/versions',
'GET /api/v2/workspaces/[workspaceId]/members',
] as const

/**
* Lists that accept neither param and always return `nextCursor: null`, because
* the set is small and bounded per workspace or per table.
*/
const FULL_SET_LISTS = [
'GET /api/v2/credentials',
'GET /api/v2/custom-tools',
'GET /api/v2/files/folders',
'GET /api/v2/knowledge',
'GET /api/v2/knowledge/folders',
'GET /api/v2/mcp-servers',
'GET /api/v2/secrets',
'GET /api/v2/skills',
'GET /api/v2/tables/[tableId]/groups',
'GET /api/v2/tables/[tableId]/views',
'GET /api/v2/tables/folders',
'GET /api/v2/workflows/folders',
] as const

interface ContractLike {
method: string
path: string
query?: z.ZodType
body?: z.ZodType
response?: { mode: string; schema?: z.ZodType }
}

function isContract(value: unknown): value is ContractLike {
return (
!!value &&
typeof value === 'object' &&
typeof (value as ContractLike).method === 'string' &&
typeof (value as ContractLike).path === 'string' &&
typeof (value as ContractLike).response === 'object'
)
}

/** Unwraps `.optional()` / `.default()` / `.superRefine()` / pipe wrappers to the object underneath. */
function objectShape(schema: z.ZodType | undefined): Record<string, unknown> | null {
let current: unknown = schema
for (let depth = 0; current && depth < 8; depth++) {
const def = (current as { def?: Record<string, unknown> }).def
if (!def) return null
if (def.type === 'object') return def.shape as Record<string, unknown>
current = def.innerType ?? def.in ?? def.schema
}
return null
}

/** A `{ data: T[], nextCursor: string | null }` response. */
function isListResponse(schema: z.ZodType | undefined): boolean {
const shape = objectShape(schema)
return !!shape && 'data' in shape && 'nextCursor' in shape
}

function listContractFiles(dir: string): string[] {
const files: string[] = []
for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) =>
a.name.localeCompare(b.name)
)) {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) {
if (entry.name === '__tests__') continue
files.push(...listContractFiles(full))
} else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts')) {
files.push(full)
}
}
return files
}

interface V2ListContract {
key: string
name: string
paginationParams: string[]
}

/**
* Sweeping the contracts tree costs a few hundred dynamic imports, so it is done
* once for the whole file rather than repeated per test.
*/
let contractsPromise: Promise<V2ListContract[]> | null = null
function loadV2ListContracts(): Promise<V2ListContract[]> {
contractsPromise ??= sweepV2ListContracts()
return contractsPromise
}

async function sweepV2ListContracts(): Promise<V2ListContract[]> {
const found = new Map<string, V2ListContract>()
for (const file of listContractFiles(CONTRACTS_DIR)) {
const mod = (await import(file)) as Record<string, unknown>
for (const [name, value] of Object.entries(mod)) {
if (!isContract(value)) continue
if (!value.path.startsWith('/api/v2/')) continue
if (!isListResponse(value.response?.schema)) continue
const key = `${value.method.toUpperCase()} ${value.path}`
if (found.has(key)) continue
const inputShape = { ...objectShape(value.query), ...objectShape(value.body) }
found.set(key, {
key,
name,
paginationParams: ['limit', 'cursor'].filter((param) => param in inputShape),
})
}
}
return [...found.values()].sort((a, b) => a.key.localeCompare(b.key))
}

/**
* Only the first test pays for the sweep; the rest await the memoized promise.
* It costs ~2s standalone but has exceeded the default 10s under full-suite
* thread contention, so it gets headroom the other two do not need.
*/
const SWEEP_TIMEOUT_MS = 60_000

describe('v2 list pagination split', () => {
it(
'classifies every v2 list as paged or full-set',
async () => {
const contracts = await loadV2ListContracts()
const classified = new Set<string>([...PAGED_LISTS, ...FULL_SET_LISTS])
const unclassified = contracts.filter((c) => !classified.has(c.key)).map((c) => c.key)

expect(
unclassified,
'A new v2 list must be classified in PAGED_LISTS or FULL_SET_LISTS.'
).toEqual([])
expect(contracts.map((c) => c.key).sort()).toEqual([...classified].sort())
},
SWEEP_TIMEOUT_MS
)

it('gives every paged list both limit and cursor', async () => {
const contracts = await loadV2ListContracts()
const byKey = new Map(contracts.map((c) => [c.key, c]))

for (const key of PAGED_LISTS) {
expect(byKey.get(key)?.paginationParams, `${key} is declared paged`).toEqual([
'limit',
'cursor',
])
}
})

it('gives every full-set list neither limit nor cursor', async () => {
const contracts = await loadV2ListContracts()
const byKey = new Map(contracts.map((c) => [c.key, c]))

for (const key of FULL_SET_LISTS) {
expect(
byKey.get(key)?.paginationParams,
`${key} returns the full set; adding a defaulted limit would truncate existing callers`
).toEqual([])
}
})
})
6 changes: 5 additions & 1 deletion apps/sim/lib/api/contracts/v2/knowledge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -649,8 +649,12 @@ export const v2GetKnowledgeBaseContract = defineRouteContract({
},
})

/**
* PATCH, not PUT: every mutable field is optional and a `superRefine` requires
* at least one, so this is a partial update rather than a replacement.
*/
export const v2UpdateKnowledgeBaseContract = defineRouteContract({
method: 'PUT',
method: 'PATCH',
path: '/api/v2/knowledge/[id]',
params: v2KnowledgeBaseParamsSchema,
body: v2UpdateKnowledgeBaseBodySchema,
Expand Down
5 changes: 3 additions & 2 deletions apps/sim/lib/api/contracts/v2/openapi/files-audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,8 +324,9 @@ const routes = [
filesOperation({
operationId: 'downloadFile',
summary: 'Download File',
description: 'Download the current file bytes from a workspace.',
errors: [...WORKSPACE_ERRORS, 'NotFound'],
description:
'Download the current file bytes from a workspace. A generated document is served as its compiled artifact, so it returns `409` while that artifact is still compiling and `413` if it renders past the size ceiling.',
errors: [...WORKSPACE_ERRORS, 'NotFound', 'Conflict', 'PayloadTooLarge'],
success: {
description: 'The file bytes.',
headers: ['Content-Type', 'Content-Disposition', 'Content-Length'],
Expand Down
Loading
Loading