From cd3efefab995c81d07a8cb2803268e32e2df30a8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 15:55:02 -0700 Subject: [PATCH 01/56] fix(v2): stop a third-party tool description from 500ing MCP discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `v2McpToolInputSchema` declared `description: z.string().optional()` inside a `.catchall(z.unknown())` object, and a declared key beats the catchall. The MCP SDK's own `ToolSchema.inputSchema` does not declare `description` at all, so any value — including the JSON `null` a Python server emits for an absent one — passes its validation and reaches Sim unchecked. The builder's outbound `.parse()` then threw, and the discovery error policy correctly declines to classify a Sim-side schema defect, so the endpoint that completes MCP onboarding answered a bare 500. The key is dropped and left to the catchall; `type`, `properties`, and `required` stay pinned because the SDK enforces those at least as tightly. Also in the v2 resources family: - The single-resource query schemas for MCP servers, skills, custom tools, and secrets are now `.strict()`, matching every list in the same family. A mistyped flag was silently ignored behind a 200. - `openapi/resources.ts` re-derived `RESOURCE_ERRORS` and `RESOURCE_CONFLICT_ERRORS` inline in 21 of 22 operations. They now import the shared constants; the generated spec is unchanged, which is the point. - The internal MCP refresh route stamped `updatedAt` alongside `lastToolsRefresh`. `updatedAt` means "configuration last changed" and is a public keyset sort, so a refresh moved rows out from under an in-flight page. `updateServerStatus` already held that invariant; the route now matches it. - The discovery cooldown is a typed `McpServerCooldownError` rather than a substring search for `cooldown`. `McpConnectionError` interpolates the server's display name into its message, so a server named after the word was reported as a transient cooldown when its connection had genuinely failed. --- apps/docs/openapi-v2-resources.json | 4 -- .../mcp/servers/[id]/refresh/route.test.ts | 29 +++++++++ .../app/api/mcp/servers/[id]/refresh/route.ts | 11 +++- apps/sim/app/api/v2/credentials/route.test.ts | 26 ++++++++ .../api/v2/custom-tools/[id]/route.test.ts | 19 ++++++ .../app/api/v2/mcp-servers/[id]/route.test.ts | 19 ++++++ .../v2/mcp-servers/[id]/tools/route.test.ts | 63 ++++++++++++++++++- apps/sim/app/api/v2/mcp-servers/utils.ts | 13 +++- .../app/api/v2/secrets/[name]/route.test.ts | 19 ++++++ apps/sim/app/api/v2/skills/[id]/route.test.ts | 19 ++++++ apps/sim/app/api/v2/skills/route.test.ts | 44 +++++++++++++ apps/sim/lib/api/contracts/v2/custom-tools.ts | 8 ++- apps/sim/lib/api/contracts/v2/mcp-servers.ts | 22 +++++-- .../lib/api/contracts/v2/openapi/resources.ts | 44 ++++++------- apps/sim/lib/api/contracts/v2/secrets.ts | 10 +-- apps/sim/lib/api/contracts/v2/skills.ts | 8 ++- .../sim/lib/mcp/application/use-cases.test.ts | 51 +++++++++++++++ apps/sim/lib/mcp/service.ts | 7 +-- apps/sim/lib/mcp/types.ts | 18 ++++++ apps/sim/lib/mcp/utils.test.ts | 22 ++++++- apps/sim/lib/mcp/utils.ts | 7 ++- 21 files changed, 407 insertions(+), 56 deletions(-) diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index a1186dc1054..15b1bf55db9 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -3019,10 +3019,6 @@ "type": "string", "description": "Name of a required argument." } - }, - "description": { - "description": "Description of the argument object.", - "type": "string" } }, "required": ["type"], diff --git a/apps/sim/app/api/mcp/servers/[id]/refresh/route.test.ts b/apps/sim/app/api/mcp/servers/[id]/refresh/route.test.ts index e31560a561d..55eb87f4ce7 100644 --- a/apps/sim/app/api/mcp/servers/[id]/refresh/route.test.ts +++ b/apps/sim/app/api/mcp/servers/[id]/refresh/route.test.ts @@ -98,6 +98,35 @@ describe('MCP server refresh route', () => { ) }) + /** + * `updatedAt` means "when the server's configuration last changed" and is one + * of the public list's keyset sorts, so a refresh must not stamp it. The + * service's discovery status write already holds that invariant; this route + * writes the same row from the UI's refresh button, and stamping it here moves + * the row to the head of `sortBy=updatedAt` under an in-flight v2 page, which + * duplicates some servers across pages and skips others. Liveness is published + * through `lastToolsRefresh`, `lastConnected`, and `lastError`. + */ + it('records the refresh without stamping updatedAt', async () => { + mockDiscoverServerTools.mockResolvedValueOnce([]) + + const request = new Request('http://localhost/api/mcp/servers/server-1/refresh', { + method: 'POST', + }) as NextRequest + await POST(request, { params: Promise.resolve({ id: 'server-1' }) }) + + const refreshWrites = dbChainMockFns.set.mock.calls.filter( + ([values]) => (values as Record)?.lastToolsRefresh !== undefined + ) + expect(refreshWrites.length).toBeGreaterThan(0) + for (const [values] of refreshWrites) { + expect( + (values as Record).updatedAt, + 'the refresh route stamped updatedAt, corrupting the updatedAt keyset page' + ).toBeUndefined() + } + }) + it('reports the discovery failure when status persistence leaves a stale connected row', async () => { const reflectedSecret = 'Bearer reflected-static-token' mockDiscoverServerTools.mockRejectedValueOnce( diff --git a/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts b/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts index 90a91aeae7d..550aa2f77d3 100644 --- a/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts +++ b/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts @@ -229,11 +229,20 @@ export const POST = withRouteHandler( const now = new Date() + /** + * Deliberately leaves `updatedAt` alone, matching the invariant + * `McpService.updateServerStatus` holds: `updatedAt` means "when the + * server's configuration last changed", and it is one of the public + * list's keyset sorts. A refresh stamping it moves the row to the head + * of `sortBy=updatedAt` under an in-flight page, so a caller walking the + * list while anyone presses this button sees servers duplicated across + * pages and others skipped. Refresh liveness is already published + * through `lastToolsRefresh`, `lastConnected`, and `lastError`. + */ const [refreshedServer] = await db .update(mcpServers) .set({ lastToolsRefresh: now, - updatedAt: now, }) .where( and( diff --git a/apps/sim/app/api/v2/credentials/route.test.ts b/apps/sim/app/api/v2/credentials/route.test.ts index a84ba1f6d68..3f2819ad83a 100644 --- a/apps/sim/app/api/v2/credentials/route.test.ts +++ b/apps/sim/app/api/v2/credentials/route.test.ts @@ -134,6 +134,32 @@ describe('GET /api/v2/credentials', () => { expect(JSON.stringify(body)).not.toContain('createdBy') }) + /** + * The projection is an explicit field-by-field copy, which is what makes a + * column added to the credential table later inert here: a field nobody wrote + * into `toV2Credential` is simply never read. The outbound response `.parse()` + * strips whatever survives, so a leak needs two independent mistakes. This + * pins the pairing against a row carrying a column the projection has never + * heard of. + */ + it('withholds a credential column the projection was never taught to publish', async () => { + mocks.execute.mockResolvedValueOnce({ + credentials: [{ ...credential, encryptedFutureSecret: 'MUST_NOT_LEAK_EITHER' }], + nextCursorKeys: null, + sortBy: 'createdAt', + sortOrder: 'desc', + }) + + const response = await GET( + new NextRequest(`http://localhost:3000/api/v2/credentials?workspaceId=${WORKSPACE_ID}`) + ) + const body = await response.json() + + expect(response.status).toBe(200) + expect(JSON.stringify(body)).not.toContain('encryptedFutureSecret') + expect(JSON.stringify(body)).not.toContain('MUST_NOT_LEAK_EITHER') + }) + it('hides repository errors that may contain secret details', async () => { mocks.execute.mockRejectedValueOnce(new Error('encryptedServiceAccountKey failed')) diff --git a/apps/sim/app/api/v2/custom-tools/[id]/route.test.ts b/apps/sim/app/api/v2/custom-tools/[id]/route.test.ts index b55abafc968..749a9086b9b 100644 --- a/apps/sim/app/api/v2/custom-tools/[id]/route.test.ts +++ b/apps/sim/app/api/v2/custom-tools/[id]/route.test.ts @@ -130,6 +130,25 @@ describe('/api/v2/custom-tools/[id]', () => { }) }) + /** + * Every list in this family rejects a query param it does not implement, so + * the single-resource reads must too. A caller who mistypes a flag otherwise + * gets a 200 that silently ignored it, which reads as confirmation the flag + * exists and does nothing. + */ + it('rejects a query param it does not implement', async () => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/custom-tools/${tool.id}?workspaceId=${WORKSPACE_ID}&includeCodes=true`, + { method: 'GET', headers: { 'x-api-key': 'key' } } + ), + context + ) + + expect(response.status).toBe(400) + expect(mocks.get).not.toHaveBeenCalled() + }) + it('updates a custom tool through its semantic update operation', async () => { const response = await PATCH( request('PATCH', { workspaceId: WORKSPACE_ID, code: 'return 2' }), diff --git a/apps/sim/app/api/v2/mcp-servers/[id]/route.test.ts b/apps/sim/app/api/v2/mcp-servers/[id]/route.test.ts index d1c0836afdb..167c927d32f 100644 --- a/apps/sim/app/api/v2/mcp-servers/[id]/route.test.ts +++ b/apps/sim/app/api/v2/mcp-servers/[id]/route.test.ts @@ -121,6 +121,25 @@ describe('/api/v2/mcp-servers/[id]', () => { }) }) + /** + * Every list in this family rejects a query param it does not implement, so + * the single-resource reads must too. A caller who mistypes a flag otherwise + * gets a 200 that silently ignored it, which reads as confirmation the flag + * exists and does nothing. + */ + it('rejects a query param it does not implement', async () => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/mcp-servers/${server.id}?workspaceId=${WORKSPACE_ID}&includeTools=true`, + { method: 'GET', headers: { 'x-api-key': 'key' } } + ), + context + ) + + expect(response.status).toBe(400) + expect(mocks.get).not.toHaveBeenCalled() + }) + it('updates an MCP server through the strict semantic update operation', async () => { const response = await PATCH( request('PATCH', { workspaceId: WORKSPACE_ID, name: 'New docs' }), diff --git a/apps/sim/app/api/v2/mcp-servers/[id]/tools/route.test.ts b/apps/sim/app/api/v2/mcp-servers/[id]/tools/route.test.ts index cf39d34b6a8..9a028fb0a89 100644 --- a/apps/sim/app/api/v2/mcp-servers/[id]/tools/route.test.ts +++ b/apps/sim/app/api/v2/mcp-servers/[id]/tools/route.test.ts @@ -36,7 +36,11 @@ vi.mock('@/lib/mcp/application/use-cases', () => ({ })) import { WorkspaceApiKeyAuthorizationError } from '@/lib/core/application' -import { McpConnectionError, McpOauthAuthorizationRequiredError } from '@/lib/mcp/types' +import { + McpConnectionError, + McpOauthAuthorizationRequiredError, + McpServerCooldownError, +} from '@/lib/mcp/types' import { GET } from '@/app/api/v2/mcp-servers/[id]/tools/route' const WORKSPACE_ID = 'workspace-1' @@ -173,6 +177,63 @@ describe('/api/v2/mcp-servers/[id]/tools', () => { expect(body.error.code).toBe('INTERNAL_ERROR') }) + /** + * `inputSchema` below the `object` wrapper is authored by the third-party + * server, and the MCP SDK's own `ToolSchema` does not declare `description` + * there — its `.catchall(z.unknown())` lets any value through, so a server + * serializing an absent description as JSON `null` (what a Python `None` + * produces) reaches Sim unvalidated. Declaring the key more tightly than the + * upstream schema does made the builder's outbound `.parse()` throw, and + * discovery answered a bare 500 for a payload the protocol permits. + */ + it('publishes a tool whose server reported a non-string inputSchema description', async () => { + mocks.discover.mockResolvedValueOnce({ + tools: [ + { + ...TOOL, + inputSchema: { type: 'object' as const, description: null, properties: {} }, + }, + ], + }) + + const response = await GET(request(`workspaceId=${WORKSPACE_ID}`), { ...context }) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data[0].inputSchema).toEqual({ + type: 'object', + description: null, + properties: {}, + }) + }) + + /** + * The 503 wording used to be selected by searching the error message for + * `cooldown`. `McpConnectionError` interpolates the server's display name into + * that message, so a server the caller happened to name after the word + * borrowed the negative-cache wording and told them to wait out a cooldown + * that was never entered. + */ + it('does not read cooldown wording out of a server display name', async () => { + mocks.discover.mockRejectedValueOnce(new McpConnectionError('ECONNREFUSED', 'Cooldown Docs')) + + const response = await GET(request(`workspaceId=${WORKSPACE_ID}`), { ...context }) + const body = await response.json() + + expect(response.status).toBe(503) + expect(body.error.message).toBe('The MCP server could not be reached') + }) + + it('reports a server inside the discovery cooldown with its own wording', async () => { + mocks.discover.mockRejectedValueOnce(new McpServerCooldownError(SERVER_ID)) + + const response = await GET(request(`workspaceId=${WORKSPACE_ID}`), { ...context }) + const body = await response.json() + + expect(response.status).toBe(503) + expect(body.error.message).toBe('The MCP server recently failed and is in cooldown') + }) + it('rejects a workspace API key, which cannot supply the caller`s OAuth grant', async () => { mocks.discover.mockRejectedValueOnce(new WorkspaceApiKeyAuthorizationError()) diff --git a/apps/sim/app/api/v2/mcp-servers/utils.ts b/apps/sim/app/api/v2/mcp-servers/utils.ts index a46f344ea57..e30b8cb99c3 100644 --- a/apps/sim/app/api/v2/mcp-servers/utils.ts +++ b/apps/sim/app/api/v2/mcp-servers/utils.ts @@ -6,7 +6,11 @@ import { createV2ResourceConcealmentPolicy, type V2ErrorPolicy } from '@/lib/api import { isTimeoutError } from '@/lib/core/execution-limits' import { projectMcpHeaders } from '@/lib/mcp/projection' import type { McpServerRow } from '@/lib/mcp/queries' -import { McpConnectionError, McpOauthAuthorizationRequiredError } from '@/lib/mcp/types' +import { + McpConnectionError, + McpOauthAuthorizationRequiredError, + McpServerCooldownError, +} from '@/lib/mcp/types' import { v2Error } from '@/app/api/v2/lib/response' /** @@ -49,10 +53,15 @@ export const MCP_SERVER_REAUTHORIZATION_REQUIRED = 'MCP_SERVER_REAUTHORIZATION_R * * Every branch returns a constant, so an upstream message — which may quote a * hostname, a token endpoint, or a stack — never reaches the caller. + * + * Selection is typed for the same reason classification is. The cooldown branch + * used to search the message for `cooldown`, but `McpConnectionError` + * interpolates the server's display name into its message, so a server a caller + * named after the word was told to wait out a cooldown it was never in. */ function unreachableServerMessage(error: unknown): string { if (isTimeoutError(error)) return 'The MCP server took too long to respond' - if (error instanceof McpConnectionError && error.message.toLowerCase().includes('cooldown')) { + if (error instanceof McpServerCooldownError) { return 'The MCP server recently failed and is in cooldown' } return 'The MCP server could not be reached' diff --git a/apps/sim/app/api/v2/secrets/[name]/route.test.ts b/apps/sim/app/api/v2/secrets/[name]/route.test.ts index d77c511e782..189328f2217 100644 --- a/apps/sim/app/api/v2/secrets/[name]/route.test.ts +++ b/apps/sim/app/api/v2/secrets/[name]/route.test.ts @@ -157,6 +157,25 @@ describe('/api/v2/secrets/[name]', () => { }) }) + /** + * The secrets list rejects a query param it does not implement, so the delete + * must too. A caller who mistypes `scope` otherwise gets a 400 for the missing + * required param — but a caller who adds a param that does not exist at all + * would have had it silently ignored. + */ + it('rejects a query param it does not implement', async () => { + const response = await DELETE( + new NextRequest( + `http://localhost:3000/api/v2/secrets/${SECRET_NAME}?workspaceId=${WORKSPACE_ID}&scope=workspace&scopes=personal`, + { method: 'DELETE', headers: { 'x-api-key': 'key' } } + ), + context + ) + + expect(response.status).toBe(400) + expect(mocks.remove).not.toHaveBeenCalled() + }) + it('renders typed application errors without leaking raw errors', async () => { mocks.remove.mockRejectedValueOnce(new OrchestrationError('not_found', 'stored detail')) diff --git a/apps/sim/app/api/v2/skills/[id]/route.test.ts b/apps/sim/app/api/v2/skills/[id]/route.test.ts index 814159913b0..d98298af838 100644 --- a/apps/sim/app/api/v2/skills/[id]/route.test.ts +++ b/apps/sim/app/api/v2/skills/[id]/route.test.ts @@ -124,6 +124,25 @@ describe('/api/v2/skills/[id]', () => { }) }) + /** + * Every list in this family rejects a query param it does not implement, so + * the single-resource reads must too. A caller who mistypes a flag otherwise + * gets a 200 that silently ignored it, which reads as confirmation the flag + * exists and does nothing. + */ + it('rejects a query param it does not implement', async () => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/skills/${skill.id}?workspaceId=${WORKSPACE_ID}&includeContents=true`, + { method: 'GET', headers: { 'x-api-key': 'key' } } + ), + context + ) + + expect(response.status).toBe(400) + expect(mocks.get).not.toHaveBeenCalled() + }) + it('updates a skill and emits only surface analytics', async () => { const response = await PATCH( request('PATCH', { workspaceId: WORKSPACE_ID, content: '# Updated' }), diff --git a/apps/sim/app/api/v2/skills/route.test.ts b/apps/sim/app/api/v2/skills/route.test.ts index 6324aee87dc..c4b30a766f8 100644 --- a/apps/sim/app/api/v2/skills/route.test.ts +++ b/apps/sim/app/api/v2/skills/route.test.ts @@ -179,6 +179,50 @@ describe('/api/v2/skills', () => { expect(mocks.list).not.toHaveBeenCalled() }) + /** + * `search` and `sortOrder` change the sequence the offset counts positions in + * just as `sortBy` does, so both are stamped into the scope and both must + * invalidate a replayed cursor. + */ + it.each([ + ['search', 'search=other'], + ['sortOrder', 'sortOrder=asc'], + ])('rejects a cursor replayed under a different %s', async (_field, param) => { + const cursor = Buffer.from(JSON.stringify({ scope: SCOPE(), offset: 2 })).toString('base64') + + const response = await GET( + request( + 'GET', + `/api/v2/skills?workspaceId=${WORKSPACE_ID}&${param}&cursor=${encodeURIComponent(cursor)}` + ) + ) + + expect(response.status).toBe(400) + expect(mocks.list).not.toHaveBeenCalled() + }) + + /** + * `limit` is deliberately absent from the scope: it selects how much of the + * sequence to return, not what the sequence is. Stamping it would strand every + * cursor the moment a caller changed page size, for no correctness gain. + */ + it('resumes a cursor minted under a different page size', async () => { + mocks.list.mockResolvedValueOnce({ skills: [skill], hasMore: false, offset: 2, limit: 5 }) + const cursor = Buffer.from(JSON.stringify({ scope: SCOPE(), offset: 2 })).toString('base64') + + const response = await GET( + request( + 'GET', + `/api/v2/skills?workspaceId=${WORKSPACE_ID}&limit=5&cursor=${encodeURIComponent(cursor)}` + ) + ) + + expect(response.status).toBe(200) + expect(mocks.list).toHaveBeenCalledWith( + expect.objectContaining({ input: expect.objectContaining({ limit: 5, offset: 2 }) }) + ) + }) + /** * The guard itself is unit-tested in `definition.test.ts`; this proves the * pairing end-to-end, on a real v2 read that used to reply 500 to a plain HEAD. diff --git a/apps/sim/lib/api/contracts/v2/custom-tools.ts b/apps/sim/lib/api/contracts/v2/custom-tools.ts index ae764afba70..81bfcbaee5e 100644 --- a/apps/sim/lib/api/contracts/v2/custom-tools.ts +++ b/apps/sim/lib/api/contracts/v2/custom-tools.ts @@ -110,9 +110,11 @@ export const v2CustomToolParamsSchema = z.object({ }) export type V2CustomToolParams = z.output -export const v2CustomToolWorkspaceQuerySchema = z.object({ - workspaceId: workspaceIdSchema.describe('Workspace that owns the custom tool.'), -}) +export const v2CustomToolWorkspaceQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the custom tool.'), + }) + .strict() export type V2CustomToolWorkspaceQuery = z.output /** A custom tool's natural name field is `title`, so that is what `search` matches. */ diff --git a/apps/sim/lib/api/contracts/v2/mcp-servers.ts b/apps/sim/lib/api/contracts/v2/mcp-servers.ts index 46fdd9b03b5..52015f9750b 100644 --- a/apps/sim/lib/api/contracts/v2/mcp-servers.ts +++ b/apps/sim/lib/api/contracts/v2/mcp-servers.ts @@ -172,9 +172,11 @@ export const v2McpServerParamsSchema = z.object({ }) export type V2McpServerParams = z.output -export const v2McpServerWorkspaceQuerySchema = z.object({ - workspaceId: workspaceIdSchema.describe('Workspace that owns the MCP server.'), -}) +export const v2McpServerWorkspaceQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the MCP server.'), + }) + .strict() export type V2McpServerWorkspaceQuery = z.output export const v2McpServerSortFields = ['name', 'createdAt', 'updatedAt'] as const @@ -288,7 +290,18 @@ export type V2UpdateMcpServerBody = z.input * declaration gives an OpenAI function's `parameters`. `type` can be pinned to * the literal because the MCP SDK's own `ListToolsResult` schema already rejects * a tool whose `inputSchema.type` is anything else, so a server cannot make this - * response fail its own validation. + * response fail its own validation. `properties` and `required` are pinned on + * the same ground, and the SDK is the stricter of the two on `properties`. + * + * The rule that keeps this safe is that a key may only be declared here when the + * SDK declares it at least as tightly. `description` may not: the SDK's + * `ToolSchema.inputSchema` does not declare it at all, so its own + * `.catchall(z.unknown())` admits any value — including the JSON `null` a Python + * server emits for an absent description. Declaring it `z.string().optional()` + * made the builder's outbound `.parse()` throw on a payload the protocol + * permits, and discovery answered a bare 500. It is left to the `catchall` + * below, which publishes as `additionalProperties` and passes the value through + * untouched. */ const v2McpToolInputSchema = z .object({ @@ -303,7 +316,6 @@ const v2McpToolInputSchema = z .array(z.string().describe('Name of a required argument.')) .optional() .describe('Names of the arguments the tool requires.'), - description: z.string().optional().describe('Description of the argument object.'), }) .catchall(z.unknown().describe('Additional JSON Schema keyword reported by the server.')) .describe("JSON Schema for the tool's arguments, as reported by the server.") diff --git a/apps/sim/lib/api/contracts/v2/openapi/resources.ts b/apps/sim/lib/api/contracts/v2/openapi/resources.ts index 9a527057188..44cfb7aa718 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/resources.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/resources.ts @@ -21,12 +21,12 @@ import { FULL_SET_LIST, RATE_LIMIT_HEADERS, RESOURCE_CONFLICT_ERRORS, + RESOURCE_ERRORS, V2_API_KEY_SECURITY, V2_API_KEY_SECURITY_SCHEMES, V2_COMMON_HEADERS, V2_ERROR_SCHEMA, WORKSPACE_API_KEY_DENIED, - WORKSPACE_ERRORS, } from '@/lib/api/contracts/v2/openapi/shared' import { v2DeleteSecretContract, @@ -209,7 +209,7 @@ const routes = [ summary: 'Get Workspace', description: 'Return public metadata for one accessible workspace. Governance identities, billing identities, and internal membership identifiers are intentionally omitted.', - errors: [...WORKSPACE_ERRORS, 'NotFound'], + errors: RESOURCE_ERRORS, success: { description: 'Public workspace metadata.' }, }), { @@ -235,7 +235,7 @@ const routes = [ summary: 'List Workspace Members', description: "List the workspace's effective members ordered by email. Explicit workspace grants and inherited organization-administrator grants are merged; internal membership and billing identities are omitted.", - errors: [...WORKSPACE_ERRORS, 'NotFound'], + errors: RESOURCE_ERRORS, success: { description: 'An email-ordered page of effective workspace members.' }, }), { @@ -267,7 +267,7 @@ const routes = [ summary: 'List MCP Servers', description: 'List MCP servers registered in a workspace. Request-header values and OAuth client secrets are never returned. Nothing caps how many servers a workspace registers, so this list is paginated: paginate with `limit` and `cursor`, stopping when `nextCursor` is null. `connectionStatus`, `toolCount`, `lastError`, and `lastToolsRefresh` describe the most recent tool discovery and stay at their registration defaults until one runs — call `GET /api/v2/mcp-servers/{id}/tools` to run it.', - errors: [...WORKSPACE_ERRORS, 'NotFound'], + errors: RESOURCE_ERRORS, success: { description: 'MCP servers registered in the workspace.' }, }), { @@ -293,7 +293,7 @@ const routes = [ summary: 'Create MCP Server', description: 'Register an MCP server in a workspace. The endpoint URL determines server identity, must be absolute HTTP or HTTPS, and cannot contain environment-variable references. Header values and OAuth client secrets are write-only. `transport`, `timeout`, `retries`, and `enabled` are applied server-side when omitted; the effective values are in the response.', - errors: [...WORKSPACE_ERRORS, 'NotFound', 'Conflict'], + errors: RESOURCE_CONFLICT_ERRORS, success: { description: 'The MCP server was registered.' }, }), { @@ -328,7 +328,7 @@ const routes = [ summary: 'Get MCP Server', description: 'Fetch one MCP server by identifier. Request-header values and OAuth client secrets are never returned.', - errors: [...WORKSPACE_ERRORS, 'NotFound'], + errors: RESOURCE_ERRORS, success: { description: 'The MCP server.' }, }), { @@ -360,7 +360,7 @@ const routes = [ summary: 'Update MCP Server', description: 'Update the supplied MCP server fields. The URL is immutable because it determines server identity; delete and recreate the server to change endpoints. Two fields do not follow the omitted-fields-are-retained rule. `headers` is replaced wholesale rather than merged: sending it drops every stored header it does not repeat, and the only way to keep a header is to resend it. Changing `oauthClientId`, or sending `oauthClientSecret` as null or a new value, revokes the stored OAuth grant and forces reauthorization; switching away from OAuth authentication revokes it too.', - errors: [...WORKSPACE_ERRORS, 'NotFound'], + errors: RESOURCE_ERRORS, success: { description: 'The updated MCP server.' }, }), { @@ -393,7 +393,7 @@ const routes = [ summary: 'Delete MCP Server', description: "Remove an MCP server and revoke its OAuth tokens. Workflows retain blocks that referenced the server's tools, but those tools can no longer be called.", - errors: [...WORKSPACE_ERRORS, 'NotFound'], + errors: RESOURCE_ERRORS, success: { description: 'The MCP server was deleted.' }, }), { @@ -456,7 +456,7 @@ const routes = [ summary: 'List Skills', description: 'List workspace and built-in skills. Built-ins are marked read-only. The list omits skill bodies; fetch one skill to read its content. Paginate with `limit` and `cursor`, stopping when `nextCursor` is null.', - errors: [...WORKSPACE_ERRORS, 'NotFound'], + errors: RESOURCE_ERRORS, success: { description: 'Skills available in the workspace.' }, }), { @@ -482,7 +482,7 @@ const routes = [ summary: 'Create Skill', description: 'Create one skill in a workspace. Its kebab-case name must be unique and cannot be reserved by a built-in skill. Note that a workspace API key may create a skill but may not later update or delete it.', - errors: [...WORKSPACE_ERRORS, 'NotFound', 'Conflict'], + errors: RESOURCE_CONFLICT_ERRORS, success: { description: 'The skill was created.' }, }), { @@ -516,7 +516,7 @@ const routes = [ summary: 'Get Skill', description: 'Fetch one workspace or built-in skill, including its full content. Built-in skills are marked read-only.', - errors: [...WORKSPACE_ERRORS, 'NotFound'], + errors: RESOURCE_ERRORS, success: { description: 'The skill.' }, }), { @@ -547,7 +547,7 @@ const routes = [ operationId: 'updateSkill', summary: 'Update Skill', description: `Update the supplied fields on a workspace skill. Omitted fields retain their stored values. Built-in skills are read-only. ${WORKSPACE_API_KEY_DENIED}`, - errors: [...WORKSPACE_ERRORS, 'NotFound', 'Conflict'], + errors: RESOURCE_CONFLICT_ERRORS, success: { description: 'The updated skill.' }, }), { @@ -579,7 +579,7 @@ const routes = [ operationId: 'deleteSkill', summary: 'Delete Skill', description: `Delete a workspace skill. Built-in skills are read-only and cannot be deleted. ${WORKSPACE_API_KEY_DENIED}`, - errors: [...WORKSPACE_ERRORS, 'NotFound'], + errors: RESOURCE_ERRORS, success: { description: 'The skill was deleted.' }, }), { @@ -611,7 +611,7 @@ const routes = [ summary: 'List Custom Tools', description: 'List code-backed custom tools defined in a workspace. Legacy personal tools are excluded. Paginate with `limit` and `cursor`, stopping when `nextCursor` is null.', - errors: [...WORKSPACE_ERRORS, 'NotFound'], + errors: RESOURCE_ERRORS, success: { description: 'Custom tools defined in the workspace.' }, }), { @@ -637,7 +637,7 @@ const routes = [ summary: 'Create Custom Tool', description: 'Create a code-backed custom tool in a workspace. Its title must be unique because tools resolve by title at call time.', - errors: [...WORKSPACE_ERRORS, 'NotFound', 'Conflict'], + errors: RESOURCE_CONFLICT_ERRORS, success: { description: 'The custom tool was created.' }, }), { @@ -670,7 +670,7 @@ const routes = [ operationId: 'getCustomTool', summary: 'Get Custom Tool', description: 'Fetch one custom tool by identifier, scoped to its workspace.', - errors: [...WORKSPACE_ERRORS, 'NotFound'], + errors: RESOURCE_ERRORS, success: { description: 'The custom tool.' }, }), { @@ -702,7 +702,7 @@ const routes = [ summary: 'Update Custom Tool', description: 'Update the supplied custom tool fields. Omitted fields retain their stored values, and titles must remain unique within the workspace.', - errors: [...WORKSPACE_ERRORS, 'NotFound', 'Conflict'], + errors: RESOURCE_CONFLICT_ERRORS, success: { description: 'The updated custom tool.' }, }), { @@ -735,7 +735,7 @@ const routes = [ summary: 'Delete Custom Tool', description: 'Delete a custom tool. Agent blocks retain their configuration but can no longer call the deleted tool.', - errors: [...WORKSPACE_ERRORS, 'NotFound'], + errors: RESOURCE_ERRORS, success: { description: 'The custom tool was deleted.' }, }), { @@ -767,7 +767,7 @@ const routes = [ summary: 'List Credentials', description: 'List OAuth and service-account connections visible to the caller. Secret material is never returned. Credential mutations and single-resource reads are intentionally not exposed. Paginate with `limit` and `cursor`, stopping when `nextCursor` is null.', - errors: [...WORKSPACE_ERRORS, 'NotFound'], + errors: RESOURCE_ERRORS, success: { description: 'Credentials visible to the caller.' }, }), { @@ -792,7 +792,7 @@ const routes = [ operationId: 'listSecrets', summary: 'List Secrets', description: `List workspace and caller-owned personal secret metadata. Only names, scope, role, and timestamps are returned; secret values are never read or returned. Paginate with \`limit\` and \`cursor\`, stopping when \`nextCursor\` is null. ${WORKSPACE_API_KEY_DENIED}`, - errors: [...WORKSPACE_ERRORS, 'NotFound'], + errors: RESOURCE_ERRORS, success: { description: 'Secret metadata visible to the caller.' }, }), { @@ -817,7 +817,7 @@ const routes = [ operationId: 'setSecret', summary: 'Set Secret', description: `Create or replace a workspace or caller-owned personal secret. The value is encrypted at rest, is write-only, and is never included in the response. ${WORKSPACE_API_KEY_DENIED}`, - errors: [...WORKSPACE_ERRORS, 'NotFound'], + errors: RESOURCE_ERRORS, success: { byStatus: { 200: { description: 'The existing secret value was replaced.' }, @@ -860,7 +860,7 @@ const routes = [ operationId: 'deleteSecret', summary: 'Delete Secret', description: `Delete a workspace or caller-owned personal secret without reading or returning its stored value. ${WORKSPACE_API_KEY_DENIED}`, - errors: [...WORKSPACE_ERRORS, 'NotFound'], + errors: RESOURCE_ERRORS, success: { description: 'The secret was deleted.' }, }), { diff --git a/apps/sim/lib/api/contracts/v2/secrets.ts b/apps/sim/lib/api/contracts/v2/secrets.ts index 13744bf83cb..80df2e68f73 100644 --- a/apps/sim/lib/api/contracts/v2/secrets.ts +++ b/apps/sim/lib/api/contracts/v2/secrets.ts @@ -88,10 +88,12 @@ export const v2SetSecretBodySchema = z .strict() export type V2SetSecretBody = z.input -export const v2DeleteSecretQuerySchema = z.object({ - workspaceId: workspaceIdSchema.describe('Workspace in which the secret is available.'), - scope: v2SecretScopeSchema, -}) +export const v2DeleteSecretQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace in which the secret is available.'), + scope: v2SecretScopeSchema, + }) + .strict() export type V2DeleteSecretQuery = z.output /** diff --git a/apps/sim/lib/api/contracts/v2/skills.ts b/apps/sim/lib/api/contracts/v2/skills.ts index 31a182d3b67..48a3d29bd03 100644 --- a/apps/sim/lib/api/contracts/v2/skills.ts +++ b/apps/sim/lib/api/contracts/v2/skills.ts @@ -88,9 +88,11 @@ export const v2SkillParamsSchema = z.object({ }) export type V2SkillParams = z.output -export const v2SkillWorkspaceQuerySchema = z.object({ - workspaceId: workspaceIdSchema.describe('Workspace that owns the skill.'), -}) +export const v2SkillWorkspaceQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the skill.'), + }) + .strict() export type V2SkillWorkspaceQuery = z.output export const v2SkillSortFields = ['name', 'createdAt', 'updatedAt'] as const diff --git a/apps/sim/lib/mcp/application/use-cases.test.ts b/apps/sim/lib/mcp/application/use-cases.test.ts index 3c99924a008..73eb5404a19 100644 --- a/apps/sim/lib/mcp/application/use-cases.test.ts +++ b/apps/sim/lib/mcp/application/use-cases.test.ts @@ -162,6 +162,57 @@ describe('MCP server application use cases', () => { expect(mocks.effects).not.toHaveBeenCalled() }) + /** + * A server id is derived from the workspace and endpoint URL, so re-registering + * a URL that was soft-deleted reuses the same row. That is a create from the + * caller's side — the resource they asked for did not exist a moment ago — so it + * must succeed with the create's 201 rather than collide with its own tombstone. + * The conflict guard therefore keys on the id state's `deleted` flag, not on + * whether the writer reported an update. + */ + it('creates over a soft-deleted registration rather than colliding with its tombstone', async () => { + mocks.idState.mockResolvedValueOnce({ deleted: true }) + mocks.create.mockResolvedValueOnce({ + success: true, + serverId: server.id, + server, + updated: true, + }) + + const result = await createMcpServerUseCase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: workspace.workspaceId, name: server.name, url: server.url }, + }) + + expect(result.server.id).toBe(server.id) + expect(mocks.create).toHaveBeenCalledWith( + expect.objectContaining({ existingServerBehavior: 'reject' }) + ) + expect(events).toEqual(['audit', 'effects']) + }) + + /** + * The pre-check reads the id state outside the write, so two concurrent creates + * of the same URL can both pass it. The unique index is what actually decides, + * and its `23505` must surface as the same conflict the pre-check reports — + * otherwise the loser of the race gets a 500 for a condition the API defines. + */ + it('reports the unique-index loser of a concurrent create as a conflict', async () => { + mocks.create.mockRejectedValueOnce( + Object.assign(new Error('duplicate key value violates unique constraint'), { code: '23505' }) + ) + + await expect( + createMcpServerUseCase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: workspace.workspaceId, name: server.name, url: server.url }, + }) + ).rejects.toMatchObject({ code: 'conflict' }) + + expect(mocks.audit).not.toHaveBeenCalled() + expect(mocks.effects).not.toHaveBeenCalled() + }) + it('rejects workspace-key tool discovery before protected loading', async () => { await expect( discoverMcpToolsUseCase.execute({ diff --git a/apps/sim/lib/mcp/service.ts b/apps/sim/lib/mcp/service.ts index 46276ca6d01..9c2475a9471 100644 --- a/apps/sim/lib/mcp/service.ts +++ b/apps/sim/lib/mcp/service.ts @@ -31,9 +31,9 @@ import { type McpCacheStorageAdapter, } from '@/lib/mcp/storage' import { - McpConnectionError, McpOauthAuthorizationRequiredError, type McpServerConfig, + McpServerCooldownError, type McpServerStatusConfig, type McpServerSummary, type McpTool, @@ -1060,10 +1060,7 @@ class McpService { if (refresh !== 'force' && (await this.isServerUnhealthy(workspaceId, serverId))) { logger.info(`[${requestId}] Skipping recently-failed server ${serverId} (negative-cache)`) - throw new McpConnectionError( - 'Server recently failed and is in cooldown — try again shortly.', - serverId - ) + throw new McpServerCooldownError(serverId) } for (let attempt = 0; attempt < maxRetries; attempt++) { diff --git a/apps/sim/lib/mcp/types.ts b/apps/sim/lib/mcp/types.ts index a6f7d1f9363..c6d4e584666 100644 --- a/apps/sim/lib/mcp/types.ts +++ b/apps/sim/lib/mcp/types.ts @@ -133,6 +133,24 @@ export class McpConnectionError extends McpError { } } +/** + * Thrown when discovery is refused because the server is inside the + * negative-cache cooldown that follows a recent failure. No connection was + * attempted, so the condition clears on its own. + * + * It is a distinct class rather than an `McpConnectionError` whose message + * happens to contain "cooldown" because `McpConnectionError` interpolates the + * server's display name into that message: a server a caller named after the + * word matched the substring test and borrowed this case's wording, telling them + * to wait out a cooldown that was never entered. + */ +export class McpServerCooldownError extends McpConnectionError { + constructor(serverName: string) { + super('Server recently failed and is in cooldown — try again shortly.', serverName) + this.name = 'McpServerCooldownError' + } +} + /** * Thrown when an OAuth-protected MCP server is reachable but the current * user has not yet authorized Sim. This is a benign "pending" state, not a diff --git a/apps/sim/lib/mcp/utils.test.ts b/apps/sim/lib/mcp/utils.test.ts index 30990f62d4a..ae69134b50e 100644 --- a/apps/sim/lib/mcp/utils.test.ts +++ b/apps/sim/lib/mcp/utils.test.ts @@ -1,7 +1,11 @@ import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' import { describe, expect, it } from 'vitest' import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits' -import { McpConnectionError, McpOauthAuthorizationRequiredError } from '@/lib/mcp/types' +import { + McpConnectionError, + McpOauthAuthorizationRequiredError, + McpServerCooldownError, +} from '@/lib/mcp/types' import { categorizeError, createMcpToolId, @@ -319,12 +323,24 @@ describe('categorizeError', () => { expect(result.status).toBe(401) }) - it.concurrent('returns 503 for McpConnectionError with cooldown message', () => { - const error = new McpConnectionError('Server in cooldown — try again shortly.', 'mcp-a') + it.concurrent('returns 503 for the typed discovery-cooldown refusal', () => { + const error = new McpServerCooldownError('mcp-a') const result = categorizeError(error) expect(result.status).toBe(503) }) + /** + * The cooldown branch used to be selected by searching the message for + * `cooldown`, and `McpConnectionError` interpolates the server's display name + * into that message — so a server named after the word was reported as a + * transient 503 when its connection had genuinely failed. + */ + it.concurrent('does not read a cooldown out of a server display name', () => { + const error = new McpConnectionError('connect ECONNREFUSED', 'Cooldown Docs') + const result = categorizeError(error) + expect(result.status).toBe(502) + }) + it.concurrent('returns 502 for other McpConnectionError', () => { const error = new McpConnectionError('connect ECONNREFUSED', 'mcp-a') const result = categorizeError(error) diff --git a/apps/sim/lib/mcp/utils.ts b/apps/sim/lib/mcp/utils.ts index e1fd0eb801b..5f29e46acf8 100644 --- a/apps/sim/lib/mcp/utils.ts +++ b/apps/sim/lib/mcp/utils.ts @@ -5,6 +5,7 @@ import { type McpApiResponse, McpConnectionError, McpOauthAuthorizationRequiredError, + McpServerCooldownError, } from '@/lib/mcp/types' import { isMcpTool, MCP } from '@/executor/constants' @@ -167,10 +168,10 @@ export function categorizeError(error: unknown): { message: string; status: numb if (error instanceof McpOauthAuthorizationRequiredError || error instanceof UnauthorizedError) { return { message: 'Authentication required', status: 401 } } + if (error instanceof McpServerCooldownError) { + return { message: 'Server temporarily unavailable', status: 503 } + } if (error instanceof McpConnectionError) { - if (error.message.toLowerCase().includes('cooldown')) { - return { message: 'Server temporarily unavailable', status: 503 } - } return { message: 'Connection failed', status: 502 } } From 0dc8b27f2929082081c3b68046dc29b7c26711ab Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 15:56:43 -0700 Subject: [PATCH 02/56] fix(v2): close correctness gaps in the workflows deployment surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deploy and rollback bodies were plain objects, so a misspelled key was stripped rather than rejected. On rollback that is silent misbehavior: an omitted `version` legitimately means "reactivate the preceding version", so `{"versoin": 5}` rolled back somewhere else and answered 200. Both v2 bodies, the run-read query, and the versions cursor are now strict. Deployment versions are an `integer` column, but the path param, the versions cursor, and the v1 body each bounded it differently or not at all — an out-of-range value overflowed the comparison into an unclassifiable 500. One exported bound now covers all three. Resume admission raised bare `Error`s for a stale contextId or an already-resumed run, which the resume surfaces could not classify and reported as 500. They now use the sibling `ResumeAdmissionError` already in that file, carrying 404/409/400 and whether an automatic retry can clear the refusal. Docs corrections: rollback publishes the 409 its webhook-path conflict already produces; deploy/undeploy/rollback reject a workspace key with 403, not the concealed 404 they documented; the workflows OpenAPI module imports the shared error sets instead of re-deriving them; import and the folder ops explain their folder-tree 413. The export route is marked `headSafe: false` so a HEAD probe stops filing a WORKFLOW_EXPORTED audit event for an export that never happened. `runId` is one bounded schema across the run and log resources. --- apps/docs/openapi-v2-logs.json | 4 + apps/docs/openapi-v2-workflows.json | 25 +++-- .../v2/workflows/[id]/export/route.test.ts | 11 ++ .../app/api/v2/workflows/[id]/export/route.ts | 6 + .../v2/workflows/[id]/versions/route.test.ts | 40 +++++++ .../api/v2/workflows/[id]/versions/route.ts | 17 +-- .../sim/lib/api/contracts/deployments.test.ts | 23 +++- apps/sim/lib/api/contracts/deployments.ts | 32 +++++- apps/sim/lib/api/contracts/primitives.ts | 16 +++ apps/sim/lib/api/contracts/v1/workflows.ts | 8 +- apps/sim/lib/api/contracts/v2/logs.ts | 19 ++-- .../lib/api/contracts/v2/openapi/workflows.ts | 40 ++----- apps/sim/lib/api/contracts/v2/run-id.test.ts | 27 +++++ .../v2/workflow-deployment-requests.test.ts | 71 ++++++++++++ apps/sim/lib/api/contracts/v2/workflows.ts | 40 +++++-- .../human-in-the-loop-manager.test.ts | 105 ++++++++++++++++++ .../executor/human-in-the-loop-manager.ts | 34 ++++-- 17 files changed, 436 insertions(+), 82 deletions(-) create mode 100644 apps/sim/lib/api/contracts/v2/run-id.test.ts create mode 100644 apps/sim/lib/api/contracts/v2/workflow-deployment-requests.test.ts diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index f25b32e5dbb..e0b41a2eeb6 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -231,6 +231,8 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:-]+$", "description": "Exact run identifier to match." } }, @@ -306,6 +308,8 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:-]+$", "description": "The unique run identifier shared by lifecycle and diagnostic resources." } } diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index e71582276f2..416c1c05178 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -590,7 +590,7 @@ "schema": { "type": "integer", "exclusiveMinimum": 0, - "maximum": 9007199254740991, + "maximum": 2147483647, "description": "Numeric deployment version.", "examples": [3] } @@ -712,7 +712,7 @@ "post": { "operationId": "deployWorkflow", "summary": "Deploy Workflow", - "description": "Create and asynchronously activate a deployment version. This request is not idempotent: it accepts no idempotency key and every call mints a new deployment version, so retrying after a timeout creates a second version rather than returning the first. The response carries `latestDeploymentAttempt` for the accepted attempt, but `GET /workflows/{id}` does not expose that field — poll activation with `isDeployed` and `deployedAt` on the workflow, or with `isActive` on `GET /workflows/{id}/versions`. Returns 409 when the deployment would conflict with an existing webhook path. A workspace API key cannot call this operation. Because unauthorized resources are concealed, the rejection is reported as `404` rather than `403`; use a personal API key.", + "description": "Create and asynchronously activate a deployment version. This request is not idempotent: it accepts no idempotency key and every call mints a new deployment version, so retrying after a timeout creates a second version rather than returning the first. The response carries `latestDeploymentAttempt` for the accepted attempt, but `GET /workflows/{id}` does not expose that field — poll activation with `isDeployed` and `deployedAt` on the workflow, or with `isActive` on `GET /workflows/{id}/versions`. Returns 409 when the deployment would conflict with an existing webhook path. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.", "tags": ["Workflows"], "parameters": [ { @@ -796,7 +796,7 @@ "delete": { "operationId": "undeployWorkflow", "summary": "Undeploy Workflow", - "description": "Deactivate the currently serving workflow version. A workspace API key cannot call this operation. Because unauthorized resources are concealed, the rejection is reported as `404` rather than `403`; use a personal API key.", + "description": "Deactivate the currently serving workflow version. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.", "tags": ["Workflows"], "parameters": [ { @@ -865,7 +865,7 @@ "post": { "operationId": "rollbackWorkflow", "summary": "Rollback Workflow", - "description": "Asynchronously reactivate a previous deployment version, selecting the preceding active version when no version is supplied. A workspace API key cannot call this operation. Because unauthorized resources are concealed, the rejection is reported as `404` rather than `403`; use a personal API key.", + "description": "Asynchronously reactivate a previous deployment version, selecting the preceding active version when no version is supplied. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.", "tags": ["Workflows"], "parameters": [ { @@ -926,6 +926,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, "413": { "$ref": "#/components/responses/PayloadTooLarge" }, @@ -1017,7 +1020,7 @@ "post": { "operationId": "importWorkflow", "summary": "Import Workflow", - "description": "Create a workflow from a portable export object, bare state, or JSON string.", + "description": "Create a workflow from a portable export object, bare state, or JSON string. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", "tags": ["Workflows"], "requestBody": { "required": true, @@ -1698,7 +1701,7 @@ "get": { "operationId": "listWorkflowsFolders", "summary": "List Workflow Folders", - "description": "List canonical workflow folders in a workspace. The bounded set is returned in one page with `nextCursor` always null; there is no second page to fetch.", + "description": "List canonical workflow folders in a workspace. The bounded set is returned in one page with `nextCursor` always null; there is no second page to fetch. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", "tags": ["Workflows"], "parameters": [ { @@ -1810,7 +1813,7 @@ "post": { "operationId": "createWorkflowsFolder", "summary": "Create Workflow Folder", - "description": "Create a canonical workflow folder in a workspace.", + "description": "Create a canonical workflow folder in a workspace. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", "tags": ["Workflows"], "requestBody": { "required": true, @@ -1880,7 +1883,7 @@ "patch": { "operationId": "relocateWorkflowsFolder", "summary": "Rename or Move Workflow Folder", - "description": "Rename or move a workflow folder and its descendants to a canonical path.", + "description": "Rename or move a workflow folder and its descendants to a canonical path. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", "tags": ["Workflows"], "requestBody": { "required": true, @@ -3394,7 +3397,8 @@ } ] } - } + }, + "additionalProperties": false }, "UndeployResult": { "type": "object", @@ -3620,7 +3624,8 @@ "minimum": 1, "maximum": 2147483647 } - } + }, + "additionalProperties": false }, "WorkflowExportPayload": { "type": "object", diff --git a/apps/sim/app/api/v2/workflows/[id]/export/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/export/route.test.ts index 96b5468d343..8de47072a7a 100644 --- a/apps/sim/app/api/v2/workflows/[id]/export/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/export/route.test.ts @@ -29,4 +29,15 @@ describe('/api/v2/workflows/[id]/export route definition', () => { errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, }) }) + + /** + * Next aliases a missing `HEAD` export onto `GET`, and RFC 9110 §9.2.1 defines + * `HEAD` as safe. This `GET` is not: the use case projects a + * `WORKFLOW_EXPORTED` audit event, so an uptime monitor or link checker + * probing the documented URL would file an export that never handed anyone + * the workflow. + */ + it('does not run the audited export for a HEAD probe', () => { + expect(GET).toMatchObject({ headSafe: false }) + }) }) diff --git a/apps/sim/app/api/v2/workflows/[id]/export/route.ts b/apps/sim/app/api/v2/workflows/[id]/export/route.ts index d4011a4f64c..ad3af5d0518 100644 --- a/apps/sim/app/api/v2/workflows/[id]/export/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/export/route.ts @@ -7,11 +7,17 @@ import { workflowOperations } from '@/lib/workflows/application/operations' export const dynamic = 'force-dynamic' export const revalidate = 0 +/** + * `headSafe: false` because the use case projects a `WORKFLOW_EXPORTED` audit + * event. Letting Next alias `HEAD` onto this `GET` would record an export that + * handed the caller no bytes. + */ export const GET = defineV2JsonRoute({ contract: v2ExportWorkflowContract, auth: v2ApiKeyAuth, operation: workflowOperations.export, rateLimit: v2RateLimits.publicApi, + headSafe: false, errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, mapInput: ({ params }) => ({ workflowId: params.id }), useCase: exportWorkflow, diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/versions/route.test.ts index f039ed8ba39..5725141e3c3 100644 --- a/apps/sim/app/api/v2/workflows/[id]/versions/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/versions/route.test.ts @@ -105,6 +105,46 @@ describe('GET /api/v2/workflows/[id]/versions', () => { expect(mocks.listVersions).not.toHaveBeenCalled() }) + /** + * A cursor is caller-controlled bytes, so its decoded payload is validated + * like any request field. `version` is compared against an `integer` column, + * where an out-of-range value overflows the comparison and 500s instead of + * returning an empty page. + */ + it.each([ + ['out of the integer range', { version: 2147483648 }], + ['at zero', { version: 0 }], + ['non-numeric', { version: 'two' }], + ['carrying an unknown key', { version: 2, sort: 'name' }], + ['missing its key', {}], + ])('rejects a forged cursor %s', async (_case, payload) => { + const cursor = Buffer.from(JSON.stringify(payload)).toString('base64') + const response = await GET( + new NextRequest( + `http://localhost/api/v2/workflows/workflow-1/versions?cursor=${encodeURIComponent(cursor)}` + ), + context + ) + + expect(response.status).toBe(400) + expect(mocks.listVersions).not.toHaveBeenCalled() + }) + + it('resumes from a well-formed cursor', async () => { + const cursor = Buffer.from(JSON.stringify({ version: 5 })).toString('base64') + const response = await GET( + new NextRequest( + `http://localhost/api/v2/workflows/workflow-1/versions?cursor=${encodeURIComponent(cursor)}` + ), + context + ) + + expect(response.status).toBe(200) + expect(mocks.listVersions).toHaveBeenCalledWith( + expect.objectContaining({ input: expect.objectContaining({ afterVersion: 5 }) }) + ) + }) + it('rejects an unauthenticated request', async () => { v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/route.ts b/apps/sim/app/api/v2/workflows/[id]/versions/route.ts index 1fbb169fe33..b33e3dada11 100644 --- a/apps/sim/app/api/v2/workflows/[id]/versions/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/versions/route.ts @@ -1,5 +1,8 @@ import type { V2WorkflowVersion } from '@/lib/api/contracts/v2/workflows' -import { v2ListWorkflowVersionsContract } from '@/lib/api/contracts/v2/workflows' +import { + v2ListWorkflowVersionsContract, + v2WorkflowVersionCursorSchema, +} from '@/lib/api/contracts/v2/workflows' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { OrchestrationError } from '@/lib/core/orchestration/types' import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' @@ -10,10 +13,6 @@ import { decodeCursor, encodeCursor } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 -interface WorkflowVersionCursor { - version: number -} - export const GET = defineV2JsonRoute({ contract: v2ListWorkflowVersionsContract, auth: v2ApiKeyAuth, @@ -21,14 +20,16 @@ export const GET = defineV2JsonRoute({ rateLimit: v2RateLimits.publicApi, errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, mapInput: ({ params, query }) => { - const after = query.cursor ? decodeCursor(query.cursor) : null - if (query.cursor && (!after || !Number.isInteger(after.version) || after.version < 1)) { + const decoded = query.cursor + ? v2WorkflowVersionCursorSchema.safeParse(decodeCursor(query.cursor)) + : undefined + if (decoded && !decoded.success) { throw new OrchestrationError('validation', 'Invalid cursor') } return { workflowId: params.id, limit: query.limit, - afterVersion: after?.version, + afterVersion: decoded?.data.version, } }, useCase: listWorkflowVersions, diff --git a/apps/sim/lib/api/contracts/deployments.test.ts b/apps/sim/lib/api/contracts/deployments.test.ts index 9384fd65172..13bc65f2d20 100644 --- a/apps/sim/lib/api/contracts/deployments.test.ts +++ b/apps/sim/lib/api/contracts/deployments.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { deploymentVersionOrActiveParamsSchema } from '@/lib/api/contracts/deployments' +import { + DEPLOYMENT_VERSION_MAX, + deploymentVersionOrActiveParamsSchema, + deploymentVersionParamsSchema, +} from '@/lib/api/contracts/deployments' describe('deployment version route params', () => { it('coerces numeric path params from the server boundary', () => { @@ -19,4 +23,21 @@ describe('deployment version route params', () => { deploymentVersionOrActiveParamsSchema.safeParse({ id: 'workflow-1', version }).success ).toBe(false) }) + + /** + * `workflow_deployment_version.version` is a Postgres `integer`. A larger + * value has no row to miss — it overflows the comparison, which surfaces as + * an unclassifiable 500 on a request the caller could have been told was bad. + */ + it.each([deploymentVersionParamsSchema, deploymentVersionOrActiveParamsSchema])( + 'bounds the path version to the integer column range', + (schema) => { + expect( + schema.safeParse({ id: 'workflow-1', version: String(DEPLOYMENT_VERSION_MAX) }).success + ).toBe(true) + expect( + schema.safeParse({ id: 'workflow-1', version: String(DEPLOYMENT_VERSION_MAX + 1) }).success + ).toBe(false) + } + ) }) diff --git a/apps/sim/lib/api/contracts/deployments.ts b/apps/sim/lib/api/contracts/deployments.ts index f3e165dbd1f..343af5e82a1 100644 --- a/apps/sim/lib/api/contracts/deployments.ts +++ b/apps/sim/lib/api/contracts/deployments.ts @@ -21,14 +21,42 @@ export const deployedWorkflowStateSchema = z additionalProperties: true, }) +/** + * Upper bound of `workflow_deployment_version.version`, whose column is a + * Postgres `integer`. A larger value has no row to address and overflows the + * comparison instead of missing, so every schema carrying a deployment version + * — path param, request body, or cursor payload — must be bounded by this. + */ +export const DEPLOYMENT_VERSION_MAX = 2147483647 + +/** A deployment version number, bounded to the range its column can hold. */ +export const deploymentVersionNumberSchema = z + .number() + .int('version must be an integer') + .min(1, 'version must be a positive integer') + .max(DEPLOYMENT_VERSION_MAX, 'version is out of range') + +/** + * {@link deploymentVersionNumberSchema} for a path segment, which arrives as a + * string. Spelled out rather than piped through the body schema because a + * `ZodPipe` publishes none of its constraints to the generated OpenAPI document, + * which would leave the documented parameter unbounded even though the runtime + * check holds. + */ +const deploymentVersionPathSchema = z.coerce + .number() + .int() + .positive() + .max(DEPLOYMENT_VERSION_MAX, 'version is out of range') + export const deploymentVersionParamsSchema = z.object({ id: z.string().min(1, 'Invalid workflow ID'), - version: z.coerce.number().int().positive(), + version: deploymentVersionPathSchema, }) export const deploymentVersionOrActiveParamsSchema = z.object({ id: z.string().min(1, 'Invalid workflow ID'), - version: z.union([z.coerce.number().int().positive(), z.literal('active')]), + version: z.union([deploymentVersionPathSchema, z.literal('active')]), }) export const deploymentVersionRouteParamsSchema = z.object({ diff --git a/apps/sim/lib/api/contracts/primitives.ts b/apps/sim/lib/api/contracts/primitives.ts index c05dc4679fa..39b7d6492a8 100644 --- a/apps/sim/lib/api/contracts/primitives.ts +++ b/apps/sim/lib/api/contracts/primitives.ts @@ -234,6 +234,22 @@ export const organizationIdSchema = requiredFieldSchema('Organization ID is requ /** Non-empty `workflowId` field with a stable, human-readable message. */ export const workflowIdSchema = requiredFieldSchema('Workflow ID is required') +/** + * A workflow run identifier, shared by the run resources, the caller-supplied + * `X-Run-Id` claim, and the log resources keyed on the same value. One + * identifier gets one schema: the log surfaces address the very rows the run + * surfaces mint, so a bound enforced on one and not the other decides nothing + * except which endpoint an oversized value reaches the database through. + */ +export const runIdSchema = z + .string() + .min(1, 'Invalid run ID') + .max(128, 'Run ID too long') + .regex( + /^[A-Za-z0-9._:-]+$/, + 'Run ID can only contain letters, numbers, dots, underscores, colons, and hyphens' + ) + /** * A `folder.id` value. Not `.uuid()`: the column is free-form `text` and the * legacy `workflow_folder` rows migrated onto it keep their original id shape. diff --git a/apps/sim/lib/api/contracts/v1/workflows.ts b/apps/sim/lib/api/contracts/v1/workflows.ts index 8f15312ffaf..e055ddd9df4 100644 --- a/apps/sim/lib/api/contracts/v1/workflows.ts +++ b/apps/sim/lib/api/contracts/v1/workflows.ts @@ -3,6 +3,7 @@ import { activeDeploymentSummarySchema, deploymentOperationSummarySchema, deploymentVersionMetadataFieldsSchema, + deploymentVersionNumberSchema, } from '@/lib/api/contracts/deployments' import { booleanQueryFlagSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' @@ -71,13 +72,6 @@ export const v1DeployWorkflowBodySchema = z.object({ export type V1DeployWorkflowBody = z.input -/** Bounded to the Postgres `integer` range of `workflow_deployment_version.version`. */ -const deploymentVersionNumberSchema = z - .number() - .int('version must be an integer') - .min(1, 'version must be a positive integer') - .max(2147483647, 'version is out of range') - /** * Optional rollback target accepted by the v1 rollback endpoint. When * `version` is omitted the route rolls back to the deployment version that diff --git a/apps/sim/lib/api/contracts/v2/logs.ts b/apps/sim/lib/api/contracts/v2/logs.ts index 97ab3e76e0d..321e8391e0b 100644 --- a/apps/sim/lib/api/contracts/v2/logs.ts +++ b/apps/sim/lib/api/contracts/v2/logs.ts @@ -1,6 +1,10 @@ import { z } from 'zod' import { traceSpansSchema } from '@/lib/api/contracts/logs' -import { booleanQueryFlagSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { + booleanQueryFlagSchema, + runIdSchema, + workspaceIdSchema, +} from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { v1ListLogsQuerySchema } from '@/lib/api/contracts/v1/logs' import { @@ -189,10 +193,9 @@ export const v2LogDetailSchema = z export type V2LogDetail = z.output export const v2LogParamsSchema = z.object({ - runId: z - .string() - .min(1, 'runId cannot be empty') - .describe('The unique run identifier shared by lifecycle and diagnostic resources.'), + runId: runIdSchema.describe( + 'The unique run identifier shared by lifecycle and diagnostic resources.' + ), }) export const v2ListLogsQuerySchema = v1ListLogsQuerySchema @@ -204,11 +207,7 @@ export const v2ListLogsQuerySchema = v1ListLogsQuerySchema level: z.enum(['info', 'error']).describe('Severity level to include.').optional(), startDate: v2RunWindowBoundSchema('startDate').optional(), endDate: v2RunWindowBoundSchema('endDate').optional(), - runId: z - .string() - .min(1, 'runId cannot be empty') - .describe('Exact run identifier to match.') - .optional(), + runId: runIdSchema.describe('Exact run identifier to match.').optional(), minDurationMs: z.coerce .number() .describe('Minimum total execution duration in milliseconds.') diff --git a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts index 30536db8fec..c2600ca27f6 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts @@ -6,11 +6,14 @@ import { FULL_SET_LIST, RATE_LIMIT_HEADERS, RESOURCE_CONFLICT_ERRORS, + RESOURCE_ERRORS, + RESOURCE_MUTATION_ERRORS, V2_API_KEY_SECURITY, V2_API_KEY_SECURITY_SCHEMES, V2_COMMON_HEADERS, V2_ERROR_SCHEMA, - WORKSPACE_API_KEY_DENIED_AS_NOT_FOUND, + WORKSPACE_API_KEY_DENIED, + WORKSPACE_ERRORS, } from '@/lib/api/contracts/v2/openapi/shared' import { EXECUTE_OPTION_CONSTRAINTS, @@ -105,25 +108,6 @@ const QUEUED_RUN_EXAMPLE = { }, } as const -const WORKSPACE_ERRORS = [ - 'BadRequest', - 'Unauthorized', - 'Forbidden', - 'RateLimited', - 'InternalError', - 'ServiceUnavailable', -] as const satisfies readonly ErrorResponseId[] - -const RESOURCE_ERRORS = [ - ...WORKSPACE_ERRORS, - 'NotFound', -] as const satisfies readonly ErrorResponseId[] -const RESOURCE_MUTATION_ERRORS = [ - ...RESOURCE_ERRORS, - 'Conflict', - 'Locked', -] as const satisfies readonly ErrorResponseId[] - type WorkflowOperationInput = Omit & { errors: readonly ErrorResponseId[] } @@ -380,7 +364,7 @@ const routes = [ workflowOperation({ operationId: 'deployWorkflow', summary: 'Deploy Workflow', - description: `Create and asynchronously activate a deployment version. This request is not idempotent: it accepts no idempotency key and every call mints a new deployment version, so retrying after a timeout creates a second version rather than returning the first. The response carries \`latestDeploymentAttempt\` for the accepted attempt, but \`GET /workflows/{id}\` does not expose that field — poll activation with \`isDeployed\` and \`deployedAt\` on the workflow, or with \`isActive\` on \`GET /workflows/{id}/versions\`. Returns 409 when the deployment would conflict with an existing webhook path. ${WORKSPACE_API_KEY_DENIED_AS_NOT_FOUND}`, + description: `Create and asynchronously activate a deployment version. This request is not idempotent: it accepts no idempotency key and every call mints a new deployment version, so retrying after a timeout creates a second version rather than returning the first. The response carries \`latestDeploymentAttempt\` for the accepted attempt, but \`GET /workflows/{id}\` does not expose that field — poll activation with \`isDeployed\` and \`deployedAt\` on the workflow, or with \`isActive\` on \`GET /workflows/{id}/versions\`. Returns 409 when the deployment would conflict with an existing webhook path. ${WORKSPACE_API_KEY_DENIED}`, errors: [...RESOURCE_ERRORS, 'Conflict', 'PayloadTooLarge', 'Locked'], success: jsonSuccess('The accepted deployment attempt.'), }), @@ -424,7 +408,7 @@ const routes = [ workflowOperation({ operationId: 'undeployWorkflow', summary: 'Undeploy Workflow', - description: `Deactivate the currently serving workflow version. ${WORKSPACE_API_KEY_DENIED_AS_NOT_FOUND}`, + description: `Deactivate the currently serving workflow version. ${WORKSPACE_API_KEY_DENIED}`, errors: [...RESOURCE_ERRORS, 'Locked'], success: jsonSuccess('The workflow was undeployed.'), }), @@ -455,8 +439,8 @@ const routes = [ workflowOperation({ operationId: 'rollbackWorkflow', summary: 'Rollback Workflow', - description: `Asynchronously reactivate a previous deployment version, selecting the preceding active version when no version is supplied. ${WORKSPACE_API_KEY_DENIED_AS_NOT_FOUND}`, - errors: [...RESOURCE_ERRORS, 'PayloadTooLarge', 'Locked'], + description: `Asynchronously reactivate a previous deployment version, selecting the preceding active version when no version is supplied. ${WORKSPACE_API_KEY_DENIED}`, + errors: [...RESOURCE_ERRORS, 'Conflict', 'PayloadTooLarge', 'Locked'], success: jsonSuccess('The accepted rollback attempt.'), }), { @@ -534,7 +518,7 @@ const routes = [ workflowOperation({ operationId: 'importWorkflow', summary: 'Import Workflow', - description: 'Create a workflow from a portable export object, bare state, or JSON string.', + description: `Create a workflow from a portable export object, bare state, or JSON string. ${FOLDER_TREE_TOO_LARGE}`, errors: [...RESOURCE_MUTATION_ERRORS, 'PayloadTooLarge'], success: jsonSuccess('The imported workflow.'), }), @@ -745,7 +729,7 @@ const routes = [ workflowOperation({ operationId: 'listWorkflowsFolders', summary: 'List Workflow Folders', - description: `List canonical workflow folders in a workspace. ${FULL_SET_LIST}`, + description: `List canonical workflow folders in a workspace. ${FULL_SET_LIST} ${FOLDER_TREE_TOO_LARGE}`, errors: [...WORKSPACE_ERRORS, 'NotFound', 'PayloadTooLarge'], success: jsonSuccess('A list of workflow folders.'), }), @@ -770,7 +754,7 @@ const routes = [ workflowOperation({ operationId: 'createWorkflowsFolder', summary: 'Create Workflow Folder', - description: 'Create a canonical workflow folder in a workspace.', + description: `Create a canonical workflow folder in a workspace. ${FOLDER_TREE_TOO_LARGE}`, errors: [...RESOURCE_MUTATION_ERRORS, 'PayloadTooLarge'], success: jsonSuccess('The created workflow folder.'), }), @@ -796,7 +780,7 @@ const routes = [ workflowOperation({ operationId: 'relocateWorkflowsFolder', summary: 'Rename or Move Workflow Folder', - description: 'Rename or move a workflow folder and its descendants to a canonical path.', + description: `Rename or move a workflow folder and its descendants to a canonical path. ${FOLDER_TREE_TOO_LARGE}`, errors: [...RESOURCE_MUTATION_ERRORS, 'PayloadTooLarge'], success: jsonSuccess('The relocated workflow folder.'), }), diff --git a/apps/sim/lib/api/contracts/v2/run-id.test.ts b/apps/sim/lib/api/contracts/v2/run-id.test.ts new file mode 100644 index 00000000000..e869d7a3412 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/run-id.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { v2ListLogsQuerySchema, v2LogParamsSchema } from '@/lib/api/contracts/v2/logs' +import { v2WorkflowRunIdSchema } from '@/lib/api/contracts/v2/workflows' + +const OVERSIZED_RUN_ID = 'r'.repeat(129) + +/** + * `runId` names the same rows on the run resources and on the log resources, so + * a bound enforced on one and not the other only decides which endpoint an + * unbounded value reaches the database through. These pin both surfaces to the + * single shared primitive. + */ +describe('v2 run identifier', () => { + it.each([ + ['run resource', (value: string) => v2WorkflowRunIdSchema.safeParse(value).success], + ['log path param', (value: string) => v2LogParamsSchema.safeParse({ runId: value }).success], + [ + 'log list filter', + (value: string) => + v2ListLogsQuerySchema.safeParse({ workspaceId: 'workspace-1', runId: value }).success, + ], + ])('bounds the run identifier on the %s', (_surface, accepts) => { + expect(accepts('run_8f14e45f-ceea-467f-a')).toBe(true) + expect(accepts(OVERSIZED_RUN_ID)).toBe(false) + expect(accepts('')).toBe(false) + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/workflow-deployment-requests.test.ts b/apps/sim/lib/api/contracts/v2/workflow-deployment-requests.test.ts new file mode 100644 index 00000000000..28d76c57727 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/workflow-deployment-requests.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { DEPLOYMENT_VERSION_MAX } from '@/lib/api/contracts/deployments' +import { + v2DeployWorkflowContract, + v2GetWorkflowRunContract, + v2RollbackWorkflowContract, + v2WorkflowVersionCursorSchema, +} from '@/lib/api/contracts/v2/workflows' + +/** + * The v2 deployment requests carry at most one meaningful field each, and every + * one of them has a legitimate "omitted" meaning. A stripping schema therefore + * cannot tell a deliberate omission from a misspelled key, so it answers 200 + * having done something other than what the caller asked for. These pin the + * strictness that makes the two distinguishable, plus the `integer` bound every + * caller-supplied deployment version has to respect before it reaches SQL. + */ +describe('v2 deployment request contracts', () => { + const deployBody = v2DeployWorkflowContract.body + const rollbackBody = v2RollbackWorkflowContract.body + + it('rejects a misspelled deploy metadata field instead of deploying unnamed', () => { + expect(deployBody.safeParse({ nmae: 'Escalation routing' }).success).toBe(false) + }) + + it('accepts the deploy fields it documents', () => { + expect(deployBody.parse({ name: 'Escalation routing', description: 'note' })).toEqual({ + name: 'Escalation routing', + description: 'note', + }) + }) + + it('rejects a misspelled rollback version instead of rolling back to the previous one', () => { + expect(rollbackBody.safeParse({ versoin: 5 }).success).toBe(false) + }) + + /** + * The one behavior strictness must not take away: rollback with no body at + * all still means "reactivate the version preceding the active one". + */ + it('keeps an omitted rollback version meaning the previous version', () => { + expect(rollbackBody.parse(undefined)).toEqual({}) + expect(rollbackBody.parse({})).toEqual({}) + }) + + it('rejects a rollback version past the range its column can hold', () => { + expect(rollbackBody.safeParse({ version: DEPLOYMENT_VERSION_MAX }).success).toBe(true) + expect(rollbackBody.safeParse({ version: DEPLOYMENT_VERSION_MAX + 1 }).success).toBe(false) + }) + + it('bounds the version a forged versions cursor can carry into the query', () => { + expect(v2WorkflowVersionCursorSchema.safeParse({ version: 2 }).success).toBe(true) + expect( + v2WorkflowVersionCursorSchema.safeParse({ version: DEPLOYMENT_VERSION_MAX + 1 }).success + ).toBe(false) + expect(v2WorkflowVersionCursorSchema.safeParse({ version: 0 }).success).toBe(false) + expect(v2WorkflowVersionCursorSchema.safeParse({ version: 'two' }).success).toBe(false) + expect(v2WorkflowVersionCursorSchema.safeParse({}).success).toBe(false) + }) + + /** + * `includeOutputs` is the plural typo of `includeOutput`. Stripped, it makes + * the run read answer 200 with `output: null`, which is indistinguishable + * from a run that genuinely produced nothing. + */ + it('rejects a misspelled run-output flag instead of reporting a null output', () => { + const query = v2GetWorkflowRunContract.query + expect(query.safeParse({ includeOutputs: 'true' }).success).toBe(false) + expect(query.parse({ includeOutput: 'true' })).toMatchObject({ includeOutput: true }) + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 167e8244e2a..de75ca08055 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -3,10 +3,15 @@ import { activeDeploymentSummarySchema, deployedWorkflowStateSchema, deploymentOperationSummarySchema, + deploymentVersionNumberSchema, deploymentVersionParamsSchema, deploymentVersionSchema, } from '@/lib/api/contracts/deployments' -import { booleanQueryFlagSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { + booleanQueryFlagSchema, + runIdSchema, + workspaceIdSchema, +} from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { V1_IMPORT_DESCRIPTION_MAX_LENGTH, @@ -42,14 +47,7 @@ import { PERSISTED_WORKFLOW_EXECUTION_STATUSES } from '@/lib/logs/types' export const V2_WORKFLOW_RUN_ID_HEADER = 'X-Run-Id' -export const v2WorkflowRunIdSchema = z - .string() - .min(1, 'Invalid run ID') - .max(128, 'Run ID too long') - .regex( - /^[A-Za-z0-9._:-]+$/, - 'Run ID can only contain letters, numbers, dots, underscores, colons, and hyphens' - ) +export const v2WorkflowRunIdSchema = runIdSchema .describe('Unique workflow run identifier.') .meta({ examples: ['run_8f14e45f-ceea-467f-a'] }) @@ -599,6 +597,19 @@ export const v2ListWorkflowVersionsQuerySchema = z }) export type V2ListWorkflowVersionsQuery = z.output +/** + * Payload of the opaque cursor this list mints. A cursor is caller-controlled + * bytes, so its decoded `version` is validated exactly like a request field — + * it is compared against the `integer` column, where an out-of-range value + * overflows the comparison rather than matching nothing. + */ +export const v2WorkflowVersionCursorSchema = z + .object({ + version: deploymentVersionNumberSchema.describe('Version at which the next page begins.'), + }) + .strict() +export type V2WorkflowVersionCursor = z.output + /** * A single version plus the workflow state it pins. `state` is the deployed * graph snapshot — the same portable blob the internal deployment reader @@ -674,6 +685,7 @@ export const v2DeployWorkflowContract = defineRouteContract({ 'Optional release note for the deployment version.' ), }) + .strict() .optional() .default({}) .meta({ @@ -700,6 +712,14 @@ export const v2UndeployWorkflowContract = defineRouteContract({ }, }) +/** + * Rollback carries a single optional field, and omitting it is a legitimate + * request meaning "reactivate the version preceding the active one". A + * stripping body schema therefore cannot distinguish an intentional omission + * from a misspelled `version`, and silently performs the wrong rollback while + * answering `200`. `.strict()` is what makes the two distinguishable, so it is + * load-bearing here rather than hygiene. + */ export const v2RollbackWorkflowContract = defineRouteContract({ method: 'POST', path: '/api/v2/workflows/[id]/rollback', @@ -710,6 +730,7 @@ export const v2RollbackWorkflowContract = defineRouteContract({ 'Deployment version to reactivate. Omit to select the previous active version.' ), }) + .strict() .optional() .default({}) .meta({ @@ -1201,6 +1222,7 @@ export const v2GetWorkflowRunContract = defineRouteContract({ 'Comma-separated block output references to include.' ), }) + .strict() .meta({ id: 'GetWorkflowRunQuery', title: 'Get workflow run query', diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts index 60c6cccbe4e..30a1706697d 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts @@ -1696,3 +1696,108 @@ describe('PauseResumeManager resume log claims', () => { }) }) }) + +/** + * Every refusal here is an ordinary client outcome — a stale `contextId`, a run + * someone else already resumed, a pause of the wrong kind for the endpoint. The + * resume surfaces classify a failure by its `statusCode`, so an untyped throw + * for any of these reaches the caller as a `500` and tells them nothing about + * what to fix. + */ +describe('PauseResumeManager.enqueueOrStartResume admission refusals', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + function pausedRow(overrides: Record = {}) { + return { + id: 'paused-exec-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + status: 'paused', + pausePoints: { + 'ctx-1': { contextId: 'ctx-1', resumeStatus: 'paused', snapshotReady: true }, + }, + ...overrides, + } + } + + function enqueue(allowedPauseKinds?: ('human' | 'time')[]) { + return PauseResumeManager.enqueueOrStartResume({ + executionId: 'execution-1', + workflowId: 'workflow-1', + contextId: 'ctx-1', + resumeInput: {}, + userId: 'user-1', + allowedPauseKinds, + }) + } + + it.each([ + ['a run with no paused row', undefined, 404, 'Paused execution not found or already resumed'], + [ + 'a paused row in a terminal state', + pausedRow({ status: 'cancelled' }), + 409, + 'Paused execution is not resumable', + ], + [ + 'an unknown pause point', + pausedRow({ pausePoints: {} }), + 404, + 'Pause point not found for execution', + ], + [ + 'a pause point already being resumed', + pausedRow({ + pausePoints: { 'ctx-1': { resumeStatus: 'resuming', snapshotReady: true } }, + }), + 409, + 'Pause point already resumed or in progress', + ], + [ + 'a pause still finalizing its snapshot', + pausedRow({ pausePoints: { 'ctx-1': { resumeStatus: 'paused', snapshotReady: false } } }), + 409, + 'Snapshot not ready; execution still finalizing pause', + ], + ])('reports %s with its own status', async (_case, row, statusCode, message) => { + dbChainMockFns.limit.mockResolvedValueOnce(row ? [row] : []) + + await expect(enqueue()).rejects.toMatchObject({ + name: 'ResumeAdmissionError', + message, + statusCode, + }) + }) + + it('reports a pause of the wrong kind for the endpoint as a bad request', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + pausedRow({ + pausePoints: { + 'ctx-1': { resumeStatus: 'paused', snapshotReady: true, pauseKind: 'time' }, + }, + }), + ]) + + await expect(enqueue(['human'])).rejects.toMatchObject({ + name: 'ResumeAdmissionError', + statusCode: 400, + }) + }) + + /** + * A snapshot that has not finished persisting is the one refusal that a later + * automatic attempt can clear; the rest read identically on every retry. + */ + it('marks only the still-finalizing snapshot as worth retrying', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + pausedRow({ pausePoints: { 'ctx-1': { resumeStatus: 'paused', snapshotReady: false } } }), + ]) + await expect(enqueue()).rejects.toMatchObject({ retryable: true }) + + dbChainMockFns.limit.mockResolvedValueOnce([]) + await expect(enqueue()).rejects.toMatchObject({ retryable: false }) + }) +}) diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts index 155b385351d..75951205a45 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts @@ -105,6 +105,20 @@ async function releaseCancelledResumeReservations( ) } +/** + * A resume attempt that was not admitted, carrying the status the caller should + * see. Every admission refusal must be raised through this rather than a bare + * `Error`: the resume surfaces classify a failure by its `statusCode`, so an + * untyped throw for an ordinary client mistake — a stale `contextId`, an + * already-resumed pause — reaches the caller as a `500`. + * + * `retryable` says whether an automatic resume should try the attempt again. + * Only a pause still finalizing its snapshot is; a pause that is absent, in the + * wrong state, or of the wrong kind will read the same on every retry. + * + * Messages must stay free of identifiers, snapshot contents, and ORM detail — + * they are forwarded verbatim to API callers. + */ class ResumeAdmissionError extends Error { constructor( message: string, @@ -654,29 +668,35 @@ export class PauseResumeManager { .then((rows) => rows[0]) if (!pausedExecution) { - throw new Error('Paused execution not found or already resumed') + throw new ResumeAdmissionError('Paused execution not found or already resumed', 404, false) } if (!isResumablePausedStatus(pausedExecution.status)) { - throw new Error('Paused execution is not resumable') + throw new ResumeAdmissionError('Paused execution is not resumable', 409, false) } const pausePoints = pausedExecution.pausePoints as Record const pausePoint = pausePoints?.[contextId] if (!pausePoint) { - throw new Error('Pause point not found for execution') + throw new ResumeAdmissionError('Pause point not found for execution', 404, false) } if (pausePoint.resumeStatus !== 'paused') { - throw new Error('Pause point already resumed or in progress') + throw new ResumeAdmissionError('Pause point already resumed or in progress', 409, false) } if (!pausePoint.snapshotReady) { - throw new Error('Snapshot not ready; execution still finalizing pause') + throw new ResumeAdmissionError( + 'Snapshot not ready; execution still finalizing pause', + 409, + true + ) } const pauseKind: PauseKind = pausePoint.pauseKind ?? 'human' if (allowedPauseKinds && !allowedPauseKinds.includes(pauseKind)) { - throw new Error( - `Pause kind '${pauseKind}' is not allowed for this resume endpoint (allowed: ${allowedPauseKinds.join(', ')})` + throw new ResumeAdmissionError( + `Pause kind '${pauseKind}' is not allowed for this resume endpoint (allowed: ${allowedPauseKinds.join(', ')})`, + 400, + false ) } From ac276e8619463a90bbed8afd2218f0e0cdcf6e7e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 15:59:08 -0700 Subject: [PATCH 03/56] fix(v2): conceal knowledge upload existence, tighten knowledge/files bounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security: the four knowledge document-upload routes rendered a bare upload error policy with no resource concealment, while every sibling knowledge route uses one. Because the use case resolves the knowledge-base context before workspace authorization, the unconcealed 403 told any valid API-key holder that a knowledge base exists in a workspace it cannot reach — the exact signal GET /api/v2/knowledge/{id} withholds by answering 404 either way. All four now use the composed concealing policy, which also renders the 415/402/413 the route-local renderer already handled; that duplicate renderer is deleted. Contracts: - POST /knowledge/search is strict. It was the only non-strict v2 request body, so a mis-cased rerankerEnabled or topK returned 200 with the key stripped, changing what the caller was billed and silently disabling reranking. - The document list takes limit, cursor, and search from the shared v2 schemas. search was an unbounded, empty-accepting v1 string, so ?search= answered 200 with a full page here and 400 on GET /knowledge, and the term reached an unindexed filename LIKE scan with no ceiling. - The 16 non-strict single-field workspace query slices across both families are strict, matching GET /knowledge/{id}/tags. - GET /audit-logs takes workspaceIdSchema instead of a bare string (?workspaceId= was forwarded as a filter and returned zero rows) and the shared run-window bounds for startDate/endDate. Documentation: - listAuditLogs drops the 404 it has no code path to emit. - upsertFileShare describes its workspace-key refusal as the 403 it renders; the operation denies the key by principal kind, which the concealment policy does not rewrite. - The 12 body-reading knowledge and files operations publish the 413 their pre-validation body read raises, and the file list publishes the folder-tree 413 its now-capped path index raises. Correctness: queryWorkspaceFilePage loads its folder path index under MAX_FOLDERS_PER_WORKSPACE like the workflow, table, and knowledge lists. An uncapped index does not fail on truncation, so a real folder outside the read rows resolved to undefined and answered "Folder not found". --- apps/docs/openapi-v2-files-audit.json | 56 +++-- apps/docs/openapi-v2-knowledge.json | 22 +- .../uploads/[uploadId]/complete/route.test.ts | 1 - .../uploads/[uploadId]/complete/route.ts | 8 +- .../uploads/[uploadId]/parts/route.ts | 4 +- .../documents/uploads/[uploadId]/route.ts | 8 +- .../documents/uploads/concealment.test.ts | 194 ++++++++++++++++++ .../documents/uploads/control-routes.test.ts | 1 - .../[id]/documents/uploads/route.test.ts | 1 - .../knowledge/[id]/documents/uploads/route.ts | 8 +- .../knowledge/[id]/documents/uploads/utils.ts | 14 -- .../app/api/v2/knowledge/search/route.test.ts | 15 +- .../v2/__tests__/cross-cutting.test.ts | 84 ++++++++ .../contracts/v2/__tests__/knowledge.test.ts | 73 +++++++ apps/sim/lib/api/contracts/v2/audit-logs.ts | 30 ++- apps/sim/lib/api/contracts/v2/files.ts | 16 +- apps/sim/lib/api/contracts/v2/knowledge.ts | 162 ++++++++------- .../api/contracts/v2/openapi/files-audit.ts | 30 +-- .../lib/api/contracts/v2/openapi/knowledge.ts | 6 +- .../routes/resource-concealment.test.ts | 15 ++ .../folders/application-folder-caps.test.ts | 25 +++ .../application/list-workspace-files.ts | 13 +- scripts/openapi/documents.test.ts | 81 ++++++++ 23 files changed, 709 insertions(+), 158 deletions(-) create mode 100644 apps/sim/app/api/v2/knowledge/[id]/documents/uploads/concealment.test.ts diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 54432ad9883..534d304cc90 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -40,7 +40,7 @@ "get": { "operationId": "listFiles", "summary": "List Files", - "description": "List workspace files with search, sorting, folder filtering, and opaque cursor pagination. Defaults to active files; pass `scope=archived` to page over soft-deleted files, whose `deletedAt` is non-null and which `POST /files/{fileId}/restore` can bring back.", + "description": "List workspace files with search, sorting, folder filtering, and opaque cursor pagination. Defaults to active files; pass `scope=archived` to page over soft-deleted files, whose `deletedAt` is non-null and which `POST /files/{fileId}/restore` can bring back. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", "tags": ["Files"], "parameters": [ { @@ -171,6 +171,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -301,6 +304,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -492,6 +498,9 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -834,6 +843,9 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -915,6 +927,9 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1049,27 +1064,32 @@ "description": "Filter to actions in one workspace.", "schema": { "description": "Filter to actions in one workspace.", - "type": "string" + "type": "string", + "minLength": 1 } }, { "name": "startDate", "in": "query", "required": false, - "description": "Inclusive ISO 8601 start timestamp.", + "description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected.", "schema": { - "description": "Inclusive ISO 8601 start timestamp.", - "type": "string" + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected." } }, { "name": "endDate", "in": "query", "required": false, - "description": "Inclusive ISO 8601 end timestamp.", + "description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected.", "schema": { - "description": "Inclusive ISO 8601 end timestamp.", - "type": "string" + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected." } }, { @@ -1161,9 +1181,6 @@ "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { - "$ref": "#/components/responses/NotFound" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1306,6 +1323,9 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1398,7 +1418,7 @@ "patch": { "operationId": "upsertFileShare", "summary": "Enable or Disable File Share", - "description": "Create or partially update a server-tokenized public share. Only isActive is required, and an omitted authType keeps the stored auth mode. What happens to password and allowedEmails depends on the resulting mode, because enabling a share always rewrites the credentials the chosen mode does not use: 'public' clears the stored password and empties allowedEmails; 'password' keeps the stored password when password is omitted but empties allowedEmails; 'email' and 'sso' clear the stored password and keep the stored allowedEmails when the field is omitted. Only disabling with isActive false preserves the whole access configuration untouched — it also retains the token, so re-enabling restores the share as it was. Two enabling combinations are rejected outright with a 400 instead of being partially applied: 'password' when neither a password is supplied nor one is already stored, and 'email' or 'sso' when the resulting allowedEmails would be empty because none was supplied and none is stored. On a file that has never been shared there is nothing stored to fall back on, so enabling any mode other than 'public' must carry its credential in the same request. A workspace API key cannot call this operation. Because unauthorized resources are concealed, the rejection is reported as `404` rather than `403`; use a personal API key.", + "description": "Create or partially update a server-tokenized public share. Only isActive is required, and an omitted authType keeps the stored auth mode. What happens to password and allowedEmails depends on the resulting mode, because enabling a share always rewrites the credentials the chosen mode does not use: 'public' clears the stored password and empties allowedEmails; 'password' keeps the stored password when password is omitted but empties allowedEmails; 'email' and 'sso' clear the stored password and keep the stored allowedEmails when the field is omitted. Only disabling with isActive false preserves the whole access configuration untouched — it also retains the token, so re-enabling restores the share as it was. Two enabling combinations are rejected outright with a 400 instead of being partially applied: 'password' when neither a password is supplied nor one is already stored, and 'email' or 'sso' when the resulting allowedEmails would be empty because none was supplied and none is stored. On a file that has never been shared there is nothing stored to fall back on, so enabling any mode other than 'public' must carry its credential in the same request. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.", "tags": ["Files"], "parameters": [ { @@ -1460,6 +1480,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1607,6 +1630,9 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1782,6 +1808,9 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1846,6 +1875,9 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index 754dc3ade5c..ee359525766 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -648,10 +648,10 @@ "name": "limit", "in": "query", "required": false, - "description": "Maximum documents to return, between 1 and 100.", + "description": "Maximum documents to return per page. Must be a whole number from 1 to 100. Defaults to 50.", "schema": { "default": 50, - "description": "Maximum documents to return, between 1 and 100.", + "description": "Maximum documents to return per page. Must be a whole number from 1 to 100. Defaults to 50.", "type": "integer", "minimum": 1, "maximum": 100 @@ -661,10 +661,12 @@ "name": "search", "in": "query", "required": false, - "description": "Case-insensitive filename search.", + "description": "Case-insensitive substring match against the document filename.", "schema": { - "description": "Case-insensitive filename search.", - "type": "string" + "description": "Case-insensitive substring match against the document filename.", + "type": "string", + "minLength": 1, + "maxLength": 200 } }, { @@ -844,6 +846,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1236,6 +1241,9 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1512,6 +1520,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -2818,6 +2829,7 @@ } }, "required": ["workspaceId", "knowledgeBaseIds"], + "additionalProperties": false, "title": "Search knowledge request", "description": "Knowledge bases, query, result limit, retrieval mode, and optional tag filters." }, diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.test.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.test.ts index e1db73c90fe..96936470e88 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.test.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.test.ts @@ -69,7 +69,6 @@ vi.mock('@/app/api/v2/knowledge/[id]/documents/uploads/utils', () => ({ } : null, }), - v2KnowledgeDocumentUploadError: vi.fn(() => null), })) import { POST } from '@/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route' diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts index aa9bf3e6751..930e3ec1d73 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts @@ -1,20 +1,18 @@ import { v2CompleteKnowledgeDocumentUploadContract } from '@/lib/api/contracts/v2/knowledge' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { PlatformEvents } from '@/lib/core/telemetry' +import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { completeKnowledgeDocumentUpload } from '@/lib/knowledge/application/upload-sessions' import { captureServerEvent } from '@/lib/posthog/server' -import { - toV2KnowledgeDocumentUpload, - v2KnowledgeDocumentUploadError, -} from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' +import { toV2KnowledgeDocumentUpload } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' export const POST = defineV2JsonRoute({ contract: v2CompleteKnowledgeDocumentUploadContract, auth: v2ApiKeyAuth, operation: knowledgeOperations.uploadComplete, rateLimit: v2RateLimits.publicApi, - errorPolicy: { render: v2KnowledgeDocumentUploadError }, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseUploadAuthorization, mapInput: ({ params, query, headers }) => ({ knowledgeBaseId: params.id, assertedWorkspaceId: query.workspaceId, diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts index 6640972b06b..8c7121c2af2 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts @@ -1,15 +1,15 @@ import { v2CreateKnowledgeDocumentUploadPartUrlsContract } from '@/lib/api/contracts/v2/knowledge' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { issueKnowledgeDocumentUploadParts } from '@/lib/knowledge/application/upload-sessions' -import { v2KnowledgeDocumentUploadError } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' export const POST = defineV2JsonRoute({ contract: v2CreateKnowledgeDocumentUploadPartUrlsContract, auth: v2ApiKeyAuth, operation: knowledgeOperations.uploadParts, rateLimit: v2RateLimits.publicApi, - errorPolicy: { render: v2KnowledgeDocumentUploadError }, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseUploadAuthorization, mapInput: ({ params, query, headers, body }) => ({ knowledgeBaseId: params.id, assertedWorkspaceId: query.workspaceId, diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/route.ts index 87194ec5d55..710eae7b8e5 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/route.ts @@ -1,18 +1,16 @@ import { v2AbortKnowledgeDocumentUploadContract } from '@/lib/api/contracts/v2/knowledge' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { cancelKnowledgeDocumentUpload } from '@/lib/knowledge/application/upload-sessions' -import { - toV2KnowledgeDocumentUpload, - v2KnowledgeDocumentUploadError, -} from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' +import { toV2KnowledgeDocumentUpload } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' export const DELETE = defineV2JsonRoute({ contract: v2AbortKnowledgeDocumentUploadContract, auth: v2ApiKeyAuth, operation: knowledgeOperations.uploadCancel, rateLimit: v2RateLimits.publicApi, - errorPolicy: { render: v2KnowledgeDocumentUploadError }, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseUploadAuthorization, mapInput: ({ params, query, headers }) => ({ knowledgeBaseId: params.id, assertedWorkspaceId: query.workspaceId, diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/concealment.test.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/concealment.test.ts new file mode 100644 index 00000000000..b16d4788c37 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/concealment.test.ts @@ -0,0 +1,194 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + authenticateV2ApiKey: vi.fn(), + cancel: vi.fn(), + checkRateLimitDirect: vi.fn(), + checkRateLimitDirectOrThrow: vi.fn(), + complete: vi.fn(), + create: vi.fn(), + gate: vi.fn(), + parts: vi.fn(), +})) + +function operation(id: string) { + return { id, minimumRole: 'write', workspaceApiKey: 'allow' } +} + +vi.mock('@/lib/knowledge/application/upload-sessions', () => ({ + KnowledgeDocumentUnsupportedMediaTypeError: class KnowledgeDocumentUnsupportedMediaTypeError extends Error {}, + createKnowledgeDocumentUpload: { + operation: operation('knowledge.documents.upload.create'), + execute: mocks.create, + }, + cancelKnowledgeDocumentUpload: { + operation: operation('knowledge.documents.upload.cancel'), + execute: mocks.cancel, + }, + issueKnowledgeDocumentUploadParts: { + operation: operation('knowledge.documents.upload.parts'), + execute: mocks.parts, + }, + completeKnowledgeDocumentUpload: { + operation: operation('knowledge.documents.upload.complete'), + execute: mocks.complete, + }, +})) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticateV2ApiKey, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, +})) + +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = mocks.checkRateLimitDirect + checkRateLimitDirectOrThrow = mocks.checkRateLimitDirectOrThrow + }, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) + +import { + InsufficientWorkspacePermissionsError, + NoWorkspaceAccessError, +} from '@/lib/core/application' +import { POST as COMPLETE } from '@/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route' +import { POST as PARTS } from '@/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/parts/route' +import { DELETE as CANCEL } from '@/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/route' +import { POST as CREATE } from '@/app/api/v2/knowledge/[id]/documents/uploads/route' + +const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' +const BASE = `http://localhost:3000/api/v2/knowledge/kb-1/documents/uploads` + +function context() { + return { params: Promise.resolve({ id: 'kb-1', uploadId: 'upload-1' }) } +} + +function controlHeaders() { + return { 'upload-token': 'token', 'x-api-key': 'secret' } +} + +/** + * Each entry pairs the route handler with the mocked use case behind it, so a + * case can make that one operation refuse and read the status the route + * renders. + */ +const routes = [ + { + name: 'create upload session', + useCase: mocks.create, + call: () => + CREATE( + new NextRequest(BASE, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + name: 'guide.pdf', + contentType: 'application/pdf', + size: 1024, + }), + }), + context() + ), + }, + { + name: 'abort upload session', + useCase: mocks.cancel, + call: () => + CANCEL( + new NextRequest(`${BASE}/upload-1?workspaceId=${WORKSPACE_ID}`, { + method: 'DELETE', + headers: controlHeaders(), + }), + context() + ), + }, + { + name: 'issue part urls', + useCase: mocks.parts, + call: () => + PARTS( + new NextRequest(`${BASE}/upload-1/parts?workspaceId=${WORKSPACE_ID}`, { + method: 'POST', + headers: { ...controlHeaders(), 'content-type': 'application/json' }, + body: JSON.stringify({ partNumbers: [1] }), + }), + context() + ), + }, + { + name: 'complete upload session', + useCase: mocks.complete, + call: () => + COMPLETE( + new NextRequest(`${BASE}/upload-1/complete?workspaceId=${WORKSPACE_ID}`, { + method: 'POST', + headers: controlHeaders(), + }), + context() + ), + }, +] as const + +/** + * The four knowledge upload routes are the only knowledge routes naming a + * knowledge base whose failures were not concealed, and their ordering made the + * gap an oracle: the use case resolves the knowledge-base context — which throws + * `not_found` when the base is absent *or* lives in another workspace — before + * workspace authorization runs. So an unconcealed 403 meant "this base exists in + * a workspace you cannot reach" and a 404 meant "it does not exist", while + * `GET /api/v2/knowledge/{id}` answers 404 to both. + */ +describe('v2 knowledge upload resource concealment', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.authenticateV2ApiKey.mockResolvedValue({ + principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'], + rateLimitSubscription: null, + keyType: 'personal', + }) + mocks.gate.mockResolvedValue(null) + for (const limiter of [mocks.checkRateLimitDirect, mocks.checkRateLimitDirectOrThrow]) { + limiter.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-04T21:00:00.000Z'), + }) + } + }) + + it.each(routes)( + '$name reports a cross-tenant refusal as a missing knowledge base', + async ({ useCase, call }) => { + useCase.mockRejectedValue(new NoWorkspaceAccessError()) + + const response = await call() + + expect(response.status).toBe(404) + await expect(response.json()).resolves.toEqual({ + error: { code: 'NOT_FOUND', message: 'Knowledge base not found' }, + }) + } + ) + + it.each(routes)( + '$name still reports a same-workspace role denial as forbidden', + async ({ useCase, call }) => { + useCase.mockRejectedValue(new InsufficientWorkspacePermissionsError()) + + const response = await call() + + expect(response.status).toBe(403) + } + ) +}) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/control-routes.test.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/control-routes.test.ts index 8f90f4b030b..8a28f5c99c0 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/control-routes.test.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/control-routes.test.ts @@ -58,7 +58,6 @@ vi.mock('@/app/api/v2/knowledge/[id]/documents/uploads/utils', () => ({ error: null, document: null, }), - v2KnowledgeDocumentUploadError: vi.fn(() => null), })) import { POST as PARTS } from '@/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/parts/route' diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.test.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.test.ts index 3f2e59f50c0..b8ab41afce9 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.test.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.test.ts @@ -50,7 +50,6 @@ vi.mock('@/app/api/v2/knowledge/[id]/documents/uploads/utils', () => ({ error: null, document: null, }), - v2KnowledgeDocumentUploadError: vi.fn(() => null), })) import { POST } from '@/app/api/v2/knowledge/[id]/documents/uploads/route' diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.ts index 03f1ea7289d..aaf70318147 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.ts @@ -1,18 +1,16 @@ import { v2CreateKnowledgeDocumentUploadContract } from '@/lib/api/contracts/v2/knowledge' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { createKnowledgeDocumentUpload } from '@/lib/knowledge/application/upload-sessions' -import { - toV2KnowledgeDocumentUpload, - v2KnowledgeDocumentUploadError, -} from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' +import { toV2KnowledgeDocumentUpload } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' export const POST = defineV2JsonRoute({ contract: v2CreateKnowledgeDocumentUploadContract, auth: v2ApiKeyAuth, operation: knowledgeOperations.uploadCreate, rateLimit: v2RateLimits.publicApi, - errorPolicy: { render: v2KnowledgeDocumentUploadError }, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseUploadAuthorization, mapInput: ({ params, body }) => { const { workspaceId, name, contentType, size, ...metadata } = body return { diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.ts index 0e48f8489e7..aedafc82f46 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.ts @@ -1,21 +1,7 @@ -import type { NextResponse } from 'next/server' import type { V2KnowledgeDocumentUpload } from '@/lib/api/contracts/v2/knowledge' -import { KnowledgeUsageLimitExceededError } from '@/lib/knowledge/application/billing' -import { KnowledgeDocumentUnsupportedMediaTypeError } from '@/lib/knowledge/application/upload-sessions' import type { CreatedKnowledgeDocument } from '@/lib/knowledge/orchestration/documents' import type { UploadSessionRecord } from '@/lib/uploads/upload-session/service' import { toV2DocumentSummary } from '@/app/api/v2/knowledge/utils' -import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response' - -export function v2KnowledgeDocumentUploadError(error: unknown): NextResponse | null { - if (error instanceof KnowledgeDocumentUnsupportedMediaTypeError) { - return v2Error('UNSUPPORTED_MEDIA_TYPE', error.message) - } - if (error instanceof KnowledgeUsageLimitExceededError) { - return v2Error('USAGE_LIMIT_EXCEEDED', error.message) - } - return v2CaughtOrchestrationError(error) -} export function toV2KnowledgeDocumentUpload( session: UploadSessionRecord, diff --git a/apps/sim/app/api/v2/knowledge/search/route.test.ts b/apps/sim/app/api/v2/knowledge/search/route.test.ts index e705128e562..52caea4ff99 100644 --- a/apps/sim/app/api/v2/knowledge/search/route.test.ts +++ b/apps/sim/app/api/v2/knowledge/search/route.test.ts @@ -203,7 +203,15 @@ describe('POST /api/v2/knowledge/search', () => { expect(mockSearch).not.toHaveBeenCalled() }) - it('drops a caller-supplied reranker key instead of forwarding it', async () => { + /** + * The search body is strict, so an undeclared key is refused rather than + * stripped. That matters most for a bring-your-own reranker key: dropping it + * silently left the caller believing the secret it sent was in use. It + * matters for an ordinary mis-spelling too — `rerankerenabled` used to parse + * to 200 with reranking off, and `topk` with `topK` back at its default, both + * of which change what the search is billed. + */ + it('refuses a caller-supplied reranker key instead of silently dropping it', async () => { const response = await POST( buildRequest( JSON.stringify({ @@ -218,9 +226,8 @@ describe('POST /api/v2/knowledge/search', () => { ) ) - expect(response.status).toBe(200) - const [{ input }] = mockSearch.mock.calls[0] - expect(input).not.toHaveProperty('rerankerApiKey') + expect(response.status).toBe(400) + expect(mockSearch).not.toHaveBeenCalled() }) it('forwards an opted-in hybrid search mode to the application use case', async () => { diff --git a/apps/sim/lib/api/contracts/v2/__tests__/cross-cutting.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/cross-cutting.test.ts index 56be75963fc..a1155c2f66b 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/cross-cutting.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/cross-cutting.test.ts @@ -3,7 +3,10 @@ */ import { describe, expect, it } from 'vitest' import { sortSpecSchema, tableViewConfigSchema } from '@/lib/api/contracts/tables' +import { rejectsUnknownKeys } from '@/lib/api/contracts/v2/__tests__/schema-introspection' import { v2ListAuditLogsContract } from '@/lib/api/contracts/v2/audit-logs' +import { filesAuditOpenApiDocument } from '@/lib/api/contracts/v2/openapi/files-audit' +import { knowledgeOpenApiDocument } from '@/lib/api/contracts/v2/openapi/knowledge' import { ERROR_RESPONSES } from '@/lib/api/contracts/v2/openapi/shared' import { v2CreateTableViewContract, v2QueryRowsBodySchema } from '@/lib/api/contracts/v2/tables' import { v2GetWorkflowRunContract } from '@/lib/api/contracts/v2/workflows' @@ -131,3 +134,84 @@ describe('tables nested strictness', () => { ).toBe(true) }) }) + +/** + * Every caller-authored knowledge and files/audit request slice must reject the + * keys it does not declare. + * + * These two families held the last non-strict slices in v2: four single-field + * `{ workspaceId }` query objects reused across 15 operations, and the knowledge + * search body. Zod strips what it does not declare, so a caller that mis-spelt a + * parameter got a 200 for a request the server never honoured — and on + * `POST /knowledge/search` the stripped keys were the ones that decide how many + * search units the call is billed. The strictness was already there on + * `GET /knowledge/{id}/tags`, which is what made the divergence visible: + * `?foo=1` was a 400 on that one route and a 200 on its siblings. + * + * Only `query` and `body` are swept. `params` are produced by the router from + * the path pattern and `headers` are projected from the schema's own keys, so + * neither carries a key the caller chose and neither can strip one. + */ +describe('knowledge and files request-slice strictness', () => { + const documents = [ + ['knowledge', knowledgeOpenApiDocument], + ['files & audit', filesAuditOpenApiDocument], + ] as const + + const slices = documents.flatMap(([family, document]) => + document.routes.flatMap((route) => + (['query', 'body'] as const) + .filter((slice) => route.contract[slice] !== undefined) + .map( + (slice) => + [ + `${family} ${route.operation.operationId} ${slice}`, + route.contract[slice] as unknown, + ] as const + ) + ) + ) + + it('sweeps every documented query and body slice', () => { + expect(slices.length).toBe(45) + }) + + it.each(slices)('%s rejects an undeclared key', (_name, schema) => { + expect(rejectsUnknownKeys(schema)).toBe(true) + }) +}) + +/** + * `GET /api/v2/audit-logs` was the only v2 query param declaring a workspace + * identifier as a bare string, so `?workspaceId=` parsed and reached + * `buildFilterConditions` as a real filter — an empty page rather than the 400 + * an empty required identifier gets on every other v2 read. + */ +describe('v2 audit-log filter bounds', () => { + const query = v2ListAuditLogsContract.query + + it('rejects an empty workspaceId instead of filtering on it', () => { + const parsed = query?.safeParse({ organizationId: 'org-1', workspaceId: '' }) + expect(parsed?.success).toBe(false) + }) + + it('still accepts an omitted workspaceId', () => { + expect(query?.safeParse({ organizationId: 'org-1' }).success).toBe(true) + }) + + it.each(['startDate', 'endDate'])( + '%s takes the shared UTC run-window form rather than a loose Date.parse', + (field) => { + const dateOnly = query?.safeParse({ organizationId: 'org-1', [field]: '2026-08-06' }) + const offsetBearing = query?.safeParse({ + organizationId: 'org-1', + [field]: '2026-08-06T00:00:00+02:00', + }) + const utc = query?.safeParse({ organizationId: 'org-1', [field]: '2026-08-06T00:00:00Z' }) + + expect(dateOnly?.success).toBe(false) + expect(offsetBearing?.success).toBe(false) + expect(utc?.success).toBe(true) + } + ) +}) diff --git a/apps/sim/lib/api/contracts/v2/__tests__/knowledge.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/knowledge.test.ts index 23960193484..a66b13d857c 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/knowledge.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/knowledge.test.ts @@ -3,10 +3,15 @@ import { v2CreateKnowledgeBaseContract, v2CreateKnowledgeDocumentUploadContract, v2CreateKnowledgeFolderContract, + v2ListKnowledgeDocumentsContract, v2SearchKnowledgeContract, v2UploadKnowledgeDocumentContract, } from '@/lib/api/contracts/v2/knowledge' +function issueMessages(result: { error?: { issues: readonly { message: string }[] } }): string[] { + return (result.error?.issues ?? []).map((issue) => issue.message) +} + describe('v2 knowledge contracts', () => { it('declares 201 for every resource-creation response', () => { expect(v2CreateKnowledgeBaseContract.response.status).toBe(201) @@ -39,4 +44,72 @@ describe('v2 knowledge contracts', () => { expect(tooManyKnowledgeBases?.success).toBe(false) expect(excessiveTopK?.success).toBe(false) }) + + /** + * A dropped key here is not a cosmetic difference: `rerankerEnabled` and + * `topK` both decide how many search units the request is billed, so a + * mis-cased key that parses to 200 charges the caller for a search they did + * not ask for and returns results they did not configure. + */ + it.each(['rerankerenabled', 'topk', 'rerankermodel', 'searchmode'])( + 'rejects the mis-cased billing-relevant key %s instead of dropping it', + (key) => { + const parsed = v2SearchKnowledgeContract.body?.safeParse({ + workspaceId: 'workspace-1', + knowledgeBaseIds: ['kb-1'], + query: 'support', + [key]: key === 'topk' ? 50 : true, + }) + expect(parsed?.success).toBe(false) + } + ) + + it('still accepts the correctly spelled reranking fields', () => { + const parsed = v2SearchKnowledgeContract.body?.safeParse({ + workspaceId: 'workspace-1', + knowledgeBaseIds: ['kb-1'], + query: 'support', + topK: 50, + rerankerEnabled: true, + }) + expect(parsed?.success).toBe(true) + }) +}) + +/** + * The document list inherited `limit` and `search` from the v1 shape, so it was + * the one v2 list whose bounds and messages diverged from every sibling. An + * empty `search` reaching an unindexed `LOWER(filename) LIKE` scan is the + * concrete cost: `?search=` answered 200 with the full page while the sibling + * `GET /knowledge?search=` answered 400. + */ +describe('v2 knowledge document list query', () => { + const query = v2ListKnowledgeDocumentsContract.query + + it('rejects an empty search term', () => { + const parsed = query?.safeParse({ workspaceId: 'ws-1', search: '' }) + expect(parsed?.success).toBe(false) + expect(issueMessages(parsed as never)).toContain('search cannot be empty') + }) + + it('rejects a search term past the shared 200-character bound', () => { + const parsed = query?.safeParse({ workspaceId: 'ws-1', search: 'a'.repeat(201) }) + expect(parsed?.success).toBe(false) + expect(issueMessages(parsed as never)).toContain('search is too long') + }) + + it('trims a search term the way every other v2 list does', () => { + const parsed = query?.safeParse({ workspaceId: 'ws-1', search: ' invoice ' }) + expect(parsed?.success).toBe(true) + expect((parsed?.data as { search?: string } | undefined)?.search).toBe('invoice') + }) + + it.each([ + [0, 'limit must be at least 1'], + [101, 'limit cannot exceed 100'], + ])('names the failing bound for limit %s', (limit, message) => { + const parsed = query?.safeParse({ workspaceId: 'ws-1', limit }) + expect(parsed?.success).toBe(false) + expect(issueMessages(parsed as never)).toContain(message) + }) }) diff --git a/apps/sim/lib/api/contracts/v2/audit-logs.ts b/apps/sim/lib/api/contracts/v2/audit-logs.ts index 05da3ec2d0b..916688c0c32 100644 --- a/apps/sim/lib/api/contracts/v2/audit-logs.ts +++ b/apps/sim/lib/api/contracts/v2/audit-logs.ts @@ -1,5 +1,9 @@ import { z } from 'zod' -import { booleanQueryFlagSchema, organizationIdSchema } from '@/lib/api/contracts/primitives' +import { + booleanQueryFlagSchema, + organizationIdSchema, + workspaceIdSchema, +} from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { v1AuditLogParamsSchema, @@ -9,6 +13,7 @@ import { v2CursorListResponse, v2DataResponse, v2PaginationFields, + v2RunWindowBoundSchema, } from '@/lib/api/contracts/v2/shared' /** @@ -78,13 +83,22 @@ export const v2ListAuditLogsQuerySchema = v1ListAuditLogsQuerySchema resourceId: v1ListAuditLogsQuerySchema.shape.resourceId.describe( 'Filter by exact resource identifier.' ), - workspaceId: v1ListAuditLogsQuerySchema.shape.workspaceId.describe( - 'Filter to actions in one workspace.' - ), - startDate: v1ListAuditLogsQuerySchema.shape.startDate.describe( - 'Inclusive ISO 8601 start timestamp.' - ), - endDate: v1ListAuditLogsQuerySchema.shape.endDate.describe('Inclusive ISO 8601 end timestamp.'), + /** + * The one v2 query param still declared as a bare `z.string()` rather than + * the shared identifier schema, so `?workspaceId=` parsed and was forwarded + * as a real filter — a page of zero rows where every sibling answers 400. + */ + workspaceId: workspaceIdSchema.optional().describe('Filter to actions in one workspace.'), + /** + * The shared run-window bound rather than the v1 `Date.parse` refine, which + * accepts partial and locale-dependent forms whose meaning varies by + * runtime. Both bounds are turned into `Date`s before they reach the query, + * so the strict UTC form is what keeps an unrepresentable value a 400 + * instead of a driver-level 500. `GET /logs` and `GET /workflows/{id}/runs` + * already share it, and an audit trail is read alongside them. + */ + startDate: v2RunWindowBoundSchema('startDate').optional(), + endDate: v2RunWindowBoundSchema('endDate').optional(), /** * Declared with the shared boolean flag rather than reused from the v1 * shape: v1 spells it as a `'true'`/`'false'` string enum, and every other diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts index 8c8b5b7231d..745b0ddf2db 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -171,9 +171,11 @@ export const v2CreateFileUploadBodySchema = z .strict() export type V2CreateFileUploadBody = z.input -export const v2FileUploadWorkspaceQuerySchema = z.object({ - workspaceId: workspaceIdSchema.describe('Workspace that owns the upload session.'), -}) +export const v2FileUploadWorkspaceQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the upload session.'), + }) + .strict() export type V2FileUploadWorkspaceQuery = z.output export const v2FileUploadSchema = z @@ -322,9 +324,11 @@ export const v2ListFilesQuerySchema = z export type V2ListFilesQuery = z.output /** Download/delete both target a single file within a workspace-scoped query. */ -export const v2FileWorkspaceQuerySchema = z.object({ - workspaceId: workspaceIdSchema.describe('Workspace that owns the file.'), -}) +export const v2FileWorkspaceQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the file.'), + }) + .strict() export type V2FileWorkspaceQuery = z.output diff --git a/apps/sim/lib/api/contracts/v2/knowledge.ts b/apps/sim/lib/api/contracts/v2/knowledge.ts index 6e3078d2c37..bd10b93f731 100644 --- a/apps/sim/lib/api/contracts/v2/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/knowledge.ts @@ -402,9 +402,11 @@ export const v2KnowledgeSearchDataSchema = z export type V2KnowledgeSearchData = z.output /** Upload carries the workspace as a query param so auth runs before the multipart body is buffered. */ -export const v2UploadKnowledgeDocumentQuerySchema = z.object({ - workspaceId: workspaceIdSchema.describe('Workspace that owns the knowledge base.'), -}) +export const v2UploadKnowledgeDocumentQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the knowledge base.'), + }) + .strict() export type V2UploadKnowledgeDocumentQuery = z.output export const v2KnowledgeBaseParamsSchema = knowledgeBaseParamsSchema.extend({ @@ -670,11 +672,13 @@ export const v2GetKnowledgeBaseContract = defineRouteContract({ method: 'GET', path: '/api/v2/knowledge/[id]', params: v2KnowledgeBaseParamsSchema, - query: v1KnowledgeWorkspaceQuerySchema.extend({ - workspaceId: v1KnowledgeWorkspaceQuerySchema.shape.workspaceId.describe( - 'Workspace that owns the knowledge base.' - ), - }), + query: v1KnowledgeWorkspaceQuerySchema + .extend({ + workspaceId: v1KnowledgeWorkspaceQuerySchema.shape.workspaceId.describe( + 'Workspace that owns the knowledge base.' + ), + }) + .strict(), response: { mode: 'json', schema: v2DataResponse(v2KnowledgeBaseSchema), @@ -700,11 +704,13 @@ export const v2DeleteKnowledgeBaseContract = defineRouteContract({ method: 'DELETE', path: '/api/v2/knowledge/[id]', params: v2KnowledgeBaseParamsSchema, - query: v1KnowledgeWorkspaceQuerySchema.extend({ - workspaceId: v1KnowledgeWorkspaceQuerySchema.shape.workspaceId.describe( - 'Workspace that owns the knowledge base.' - ), - }), + query: v1KnowledgeWorkspaceQuerySchema + .extend({ + workspaceId: v1KnowledgeWorkspaceQuerySchema.shape.workspaceId.describe( + 'Workspace that owns the knowledge base.' + ), + }) + .strict(), response: { mode: 'json', schema: v2DataResponse(v2KnowledgeDeleteDataSchema), @@ -782,47 +788,56 @@ export const v2KnowledgeSearchTagFilterSchema = v1SearchTagFilterSchema description: 'A structured tag filter applied to knowledge search.', }) -export const v2KnowledgeSearchBodySchema = v1KnowledgeSearchBodySchema.safeExtend({ - workspaceId: v1KnowledgeSearchBodySchema.shape.workspaceId.describe( - 'Workspace that owns the knowledge bases.' - ), - knowledgeBaseIds: v1KnowledgeSearchBodySchema.shape.knowledgeBaseIds - .describe('One knowledge base identifier or an array of up to 20 identifiers.') - .meta({ examples: [['7c9e6679-7425-40de-944b-e07fc1f90ae7']] }), - query: v1KnowledgeSearchBodySchema.shape.query - .describe('Natural-language query; required when tag filters are omitted.') - .meta({ examples: ['How do I reset my password?'] }), - topK: v1KnowledgeSearchBodySchema.shape.topK.describe( - 'Maximum number of search results to return. Must be a whole number between 1 and 100; the boundary schema only bounds the range, so a fractional value is admitted here and then rejected with 400 during search.' - ), - tagFilters: z - .array(v2KnowledgeSearchTagFilterSchema) - .optional() - .describe( - 'Structured tag filters. Supported across multiple knowledge bases, but each filtered tag must resolve to the same slot and field type in every knowledge base selected; a tag missing from one of them, or defined inconsistently across them, is rejected and those knowledge bases must be searched separately. A tag name defined in none of the selected knowledge bases is rejected, never ignored; list the available names with GET /api/v2/knowledge/{id}/tags.' +export const v2KnowledgeSearchBodySchema = v1KnowledgeSearchBodySchema + .safeExtend({ + workspaceId: v1KnowledgeSearchBodySchema.shape.workspaceId.describe( + 'Workspace that owns the knowledge bases.' ), - searchMode: v1KnowledgeSearchBodySchema.shape.searchMode.describe( - 'Retrieval strategy: vector is semantic-only, while hybrid also runs full-text search.' - ), - rerankerEnabled: z - .boolean() - .optional() - .describe( - 'Re-order retrieved chunks with a reranking model before truncating to `topK`. Ignored for a tag-only search, which has no query to rank against. Reranking is billed as an additional search unit.' + knowledgeBaseIds: v1KnowledgeSearchBodySchema.shape.knowledgeBaseIds + .describe('One knowledge base identifier or an array of up to 20 identifiers.') + .meta({ examples: [['7c9e6679-7425-40de-944b-e07fc1f90ae7']] }), + query: v1KnowledgeSearchBodySchema.shape.query + .describe('Natural-language query; required when tag filters are omitted.') + .meta({ examples: ['How do I reset my password?'] }), + topK: v1KnowledgeSearchBodySchema.shape.topK.describe( + 'Maximum number of search results to return. Must be a whole number between 1 and 100; the boundary schema only bounds the range, so a fractional value is admitted here and then rejected with 400 during search.' ), - rerankerModel: rerankerModelSchema - .optional() - .describe('Reranking model to use; required for reranking to run.'), - rerankerInputCount: z - .number() - .int('rerankerInputCount must be a whole number') - .min(1, 'rerankerInputCount must be at least 1') - .max(100, 'rerankerInputCount cannot exceed 100') - .optional() - .describe( - 'How many candidate chunks to retrieve before reranking. Defaults to four times `topK`, capped at 100. A larger pool costs more retrieval work but gives the reranker more to choose from.' + tagFilters: z + .array(v2KnowledgeSearchTagFilterSchema) + .optional() + .describe( + 'Structured tag filters. Supported across multiple knowledge bases, but each filtered tag must resolve to the same slot and field type in every knowledge base selected; a tag missing from one of them, or defined inconsistently across them, is rejected and those knowledge bases must be searched separately. A tag name defined in none of the selected knowledge bases is rejected, never ignored; list the available names with GET /api/v2/knowledge/{id}/tags.' + ), + searchMode: v1KnowledgeSearchBodySchema.shape.searchMode.describe( + 'Retrieval strategy: vector is semantic-only, while hybrid also runs full-text search.' ), -}) + rerankerEnabled: z + .boolean() + .optional() + .describe( + 'Re-order retrieved chunks with a reranking model before truncating to `topK`. Ignored for a tag-only search, which has no query to rank against. Reranking is billed as an additional search unit.' + ), + rerankerModel: rerankerModelSchema + .optional() + .describe('Reranking model to use; required for reranking to run.'), + rerankerInputCount: z + .number() + .int('rerankerInputCount must be a whole number') + .min(1, 'rerankerInputCount must be at least 1') + .max(100, 'rerankerInputCount cannot exceed 100') + .optional() + .describe( + 'How many candidate chunks to retrieve before reranking. Defaults to four times `topK`, capped at 100. A larger pool costs more retrieval work but gives the reranker more to choose from.' + ), + }) + /** + * Strict because the dropped keys are the billed ones. Zod strips what it + * does not declare, so a mis-cased `rerankerenabled` or `topk` returned 200 + * with reranking off and `topK` silently back at its default — the caller was + * charged for a search it did not configure and had no signal that its + * parameters never arrived. + */ + .strict() export type V2KnowledgeSearchBody = z.input export const v2SearchKnowledgeContract = defineRouteContract({ @@ -883,9 +898,15 @@ export function parseV2KnowledgeTagFiltersParam( } /** - * Document list query: the v1 search/filter/sort/limit shape with `offset` - * swapped for an opaque `cursor`. Total doc count is available as `docCount` on - * the knowledge base. + * Document list query: the v1 filter and sort shape, with `offset` swapped for + * an opaque `cursor` and with `limit`, `cursor`, and `search` taken from the + * shared v2 schemas rather than v1. Total doc count is available as `docCount` + * on the knowledge base. + * + * Sharing `search` is what closed the last gap: the v1 shape was an unbounded, + * empty-accepting string, so `?search=` answered 200 with the full page here + * while the sibling `GET /knowledge?search=` answered 400, and the term reached + * an unindexed filename `LIKE` scan with no length ceiling. */ export const v2ListKnowledgeDocumentsQuerySchema = v1ListKnowledgeDocumentsQuerySchema .omit({ offset: true }) @@ -893,11 +914,9 @@ export const v2ListKnowledgeDocumentsQuerySchema = v1ListKnowledgeDocumentsQuery workspaceId: v1ListKnowledgeDocumentsQuerySchema.shape.workspaceId.describe( 'Workspace that owns the knowledge base.' ), - limit: v1ListKnowledgeDocumentsQuerySchema.shape.limit.describe( - 'Maximum documents to return, between 1 and 100.' - ), - search: v1ListKnowledgeDocumentsQuerySchema.shape.search.describe( - 'Case-insensitive filename search.' + ...v2PaginationFields({ description: 'Maximum documents to return per page.' }), + search: v2SearchSchema.describe( + 'Case-insensitive substring match against the document filename.' ), enabledFilter: v1ListKnowledgeDocumentsQuerySchema.shape.enabledFilter.describe( 'Filter by whether documents are enabled for search.' @@ -906,7 +925,6 @@ export const v2ListKnowledgeDocumentsQuerySchema = v1ListKnowledgeDocumentsQuery 'Document field used to sort results.' ), sortOrder: v1ListKnowledgeDocumentsQuerySchema.shape.sortOrder.describe('Sort direction.'), - cursor: z.string().min(1).optional().describe('Opaque cursor returned by the previous page.'), tagFilters: z .string() .optional() @@ -985,11 +1003,13 @@ export const v2GetKnowledgeDocumentContract = defineRouteContract({ method: 'GET', path: '/api/v2/knowledge/[id]/documents/[documentId]', params: v2KnowledgeDocumentParamsSchema, - query: v1KnowledgeWorkspaceQuerySchema.extend({ - workspaceId: v1KnowledgeWorkspaceQuerySchema.shape.workspaceId.describe( - 'Workspace that owns the knowledge base.' - ), - }), + query: v1KnowledgeWorkspaceQuerySchema + .extend({ + workspaceId: v1KnowledgeWorkspaceQuerySchema.shape.workspaceId.describe( + 'Workspace that owns the knowledge base.' + ), + }) + .strict(), response: { mode: 'json', schema: v2DataResponse(v2KnowledgeDocumentSchema), @@ -1330,11 +1350,13 @@ export const v2DeleteKnowledgeDocumentContract = defineRouteContract({ method: 'DELETE', path: '/api/v2/knowledge/[id]/documents/[documentId]', params: v2KnowledgeDocumentParamsSchema, - query: v1KnowledgeWorkspaceQuerySchema.extend({ - workspaceId: v1KnowledgeWorkspaceQuerySchema.shape.workspaceId.describe( - 'Workspace that owns the knowledge base.' - ), - }), + query: v1KnowledgeWorkspaceQuerySchema + .extend({ + workspaceId: v1KnowledgeWorkspaceQuerySchema.shape.workspaceId.describe( + 'Workspace that owns the knowledge base.' + ), + }) + .strict(), response: { mode: 'json', schema: v2DataResponse(v2KnowledgeDeleteDataSchema), diff --git a/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts b/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts index 5a875a657f8..2d4d2ad3b47 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts @@ -26,6 +26,7 @@ import { documentedSchema, ERROR_RESPONSES, type ErrorResponseId, + FOLDER_TREE_TOO_LARGE, FULL_SET_LIST, RATE_LIMIT_HEADERS, RESOURCE_CONFLICT_ERRORS, @@ -35,7 +36,7 @@ import { V2_COMMON_HEADERS, V2_ERROR_SCHEMA, WORKSPACE_API_KEY_DENIED, - WORKSPACE_API_KEY_DENIED_AS_NOT_FOUND, + WORKSPACE_ERRORS, } from '@/lib/api/contracts/v2/openapi/shared' import { defineOpenApiDocument, @@ -121,9 +122,8 @@ const routes = [ filesOperation({ operationId: 'listFiles', summary: 'List Files', - description: - 'List workspace files with search, sorting, folder filtering, and opaque cursor pagination. Defaults to active files; pass `scope=archived` to page over soft-deleted files, whose `deletedAt` is non-null and which `POST /files/{fileId}/restore` can bring back.', - errors: RESOURCE_ERRORS, + description: `List workspace files with search, sorting, folder filtering, and opaque cursor pagination. Defaults to active files; pass \`scope=archived\` to page over soft-deleted files, whose \`deletedAt\` is non-null and which \`POST /files/{fileId}/restore\` can bring back. ${FOLDER_TREE_TOO_LARGE}`, + errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], success: { description: 'A page of workspace files.' }, }), { @@ -182,7 +182,7 @@ const routes = [ summary: 'Create File Upload', description: 'Create a resumable upload session and receive either a signed PUT URL or multipart instructions.', - errors: RESOURCE_ERRORS, + errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], success: { description: 'The created upload session and transfer instructions.' }, }), { @@ -250,7 +250,7 @@ const routes = [ operationId: 'createFileUploadPartUrls', summary: 'Create File Upload Part URLs', description: 'Create signed URLs for a bounded set of multipart upload part numbers.', - errors: RESOURCE_CONFLICT_ERRORS, + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], success: { description: 'Signed URLs for the requested upload parts.' }, }), { @@ -390,7 +390,7 @@ const routes = [ operationId: 'renameFile', summary: 'Rename File', description: 'Rename a workspace file without changing its containing folder.', - errors: RESOURCE_CONFLICT_ERRORS, + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], success: { description: 'The renamed file.' }, }), { @@ -428,7 +428,7 @@ const routes = [ summary: 'Restore File', description: 'Reverse a soft delete and return the file to the workspace. Restore is not a pure undo: the file comes back at the workspace root regardless of the folder it was deleted from, and it gains a `_restored` suffix when another file at the root already holds its name — so read `folderPath` and `name` off the response rather than assuming the pre-delete values. Restoring a file that is already active is a no-op that returns that file, so a retry is safe. Returns 400 when the workspace itself has been archived, and 409 when no free restore name could be found.', - errors: RESOURCE_CONFLICT_ERRORS, + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], success: { description: 'The file as it exists after the restore.' }, }), { @@ -494,7 +494,7 @@ const routes = [ operationId: 'listAuditLogs', summary: 'List Audit Logs', description: `List an organization audit trail with filters and opaque cursor pagination. Requires an Enterprise subscription and organization admin or owner access. ${WORKSPACE_API_KEY_DENIED}`, - errors: RESOURCE_ERRORS, + errors: WORKSPACE_ERRORS, success: { description: 'A page of audit-log entries.' }, }), { @@ -550,7 +550,7 @@ const routes = [ operationId: 'moveFileItems', summary: 'Move Files', description: 'Move up to 1,000 files to a canonical folder path or the workspace root.', - errors: RESOURCE_CONFLICT_ERRORS, + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], success: { description: 'Count of moved files.' }, }), { @@ -613,8 +613,8 @@ const routes = [ filesOperation({ operationId: 'upsertFileShare', summary: 'Enable or Disable File Share', - description: `Create or partially update a server-tokenized public share. Only isActive is required, and an omitted authType keeps the stored auth mode. What happens to password and allowedEmails depends on the resulting mode, because enabling a share always rewrites the credentials the chosen mode does not use: 'public' clears the stored password and empties allowedEmails; 'password' keeps the stored password when password is omitted but empties allowedEmails; 'email' and 'sso' clear the stored password and keep the stored allowedEmails when the field is omitted. Only disabling with isActive false preserves the whole access configuration untouched — it also retains the token, so re-enabling restores the share as it was. Two enabling combinations are rejected outright with a 400 instead of being partially applied: 'password' when neither a password is supplied nor one is already stored, and 'email' or 'sso' when the resulting allowedEmails would be empty because none was supplied and none is stored. On a file that has never been shared there is nothing stored to fall back on, so enabling any mode other than 'public' must carry its credential in the same request. ${WORKSPACE_API_KEY_DENIED_AS_NOT_FOUND}`, - errors: RESOURCE_ERRORS, + description: `Create or partially update a server-tokenized public share. Only isActive is required, and an omitted authType keeps the stored auth mode. What happens to password and allowedEmails depends on the resulting mode, because enabling a share always rewrites the credentials the chosen mode does not use: 'public' clears the stored password and empties allowedEmails; 'password' keeps the stored password when password is omitted but empties allowedEmails; 'email' and 'sso' clear the stored password and keep the stored allowedEmails when the field is omitted. Only disabling with isActive false preserves the whole access configuration untouched — it also retains the token, so re-enabling restores the share as it was. Two enabling combinations are rejected outright with a 400 instead of being partially applied: 'password' when neither a password is supplied nor one is already stored, and 'email' or 'sso' when the resulting allowedEmails would be empty because none was supplied and none is stored. On a file that has never been shared there is nothing stored to fall back on, so enabling any mode other than 'public' must carry its credential in the same request. ${WORKSPACE_API_KEY_DENIED}`, + errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], success: { description: 'The updated file share.' }, }), { @@ -693,7 +693,7 @@ const routes = [ operationId: 'bulkDeleteFiles', summary: 'Delete Files', description: 'Delete up to 1,000 workspace files in one operation.', - errors: RESOURCE_CONFLICT_ERRORS, + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], success: { description: 'Count of deleted files.' }, }), { @@ -748,7 +748,7 @@ const routes = [ operationId: 'createFilesFolder', summary: 'Create Folder', description: 'Create a canonical folder path in a workspace.', - errors: RESOURCE_CONFLICT_ERRORS, + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], success: { description: 'The created folder.' }, }), { @@ -778,7 +778,7 @@ const routes = [ operationId: 'relocateFilesFolder', summary: 'Rename or Move Folder', description: 'Rename or move a folder and atomically rewrite descendant canonical paths.', - errors: RESOURCE_CONFLICT_ERRORS, + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], success: { description: 'The relocated folder.' }, }), { diff --git a/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts b/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts index 9ab7f2aa4d3..3503cddf005 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts @@ -305,7 +305,7 @@ const routes = [ operationId: 'bulkUpdateKnowledgeDocuments', summary: 'Bulk Enable or Disable Documents', description: `Enable or disable many documents in one request, either by identifier (up to 100) or, with \`selectAll\`, every document in the knowledge base optionally narrowed by \`enabledFilter\`. Disabling keeps a document indexed but excludes it from search. Bulk delete is deliberately not offered: the bulk path records no audit entries, so deletions go through \`DELETE /api/v2/knowledge/{id}/documents/{documentId}\`, which audits each one. An identifier request echoes the documents it changed in \`documentIds\`; a \`selectAll\` request omits that field because the selection is unbounded, and reports \`updatedCount\` alone. ${WORKSPACE_API_KEY_DENIED}`, - errors: RESOURCE_ERRORS, + errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], success: { description: 'The number and identifiers of the documents that changed.' }, }), { @@ -469,7 +469,7 @@ const routes = [ operationId: 'createKnowledgeDocumentUploadPartUrls', summary: 'Create Document Upload Part URLs', description: 'Issue short-lived signed PUT URLs for up to 100 multipart part numbers.', - errors: RESOURCE_CONFLICT_ERRORS, + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], success: { description: 'Signed URLs for the requested upload parts.' }, }), { @@ -579,7 +579,7 @@ const routes = [ operationId: 'updateKnowledgeDocument', summary: 'Update Document', description: `Rename a document, enable or disable it for search, set any of its 17 tag slots, or requeue it for processing. A tag slot takes its declared type — a string for \`tag1\`..\`tag7\`, a number for \`number1\`..\`number5\`, a \`YYYY-MM-DD\` string for \`date1\`..\`date2\`, a boolean for \`boolean1\`..\`boolean3\` — and a value that is not valid for the slot is a \`400\` rather than a silently cleared tag. Resolve a display name to its slot with \`GET /api/v2/knowledge/{id}/tags\`. Absent fields are unchanged. Only caller-owned fields are accepted: derived indexing state (\`chunkCount\`, \`tokenCount\`, \`characterCount\`, \`processingStatus\`, \`processingError\`) is written by the processing pipeline and cannot be asserted here. \`retryProcessing: true\` re-queues a failed or stuck document and must be sent on its own — it runs instead of, not alongside, the field updates — and it answers with a queue acknowledgement rather than the document. Otherwise the updated document is returned; it omits the connector provenance the detail read carries, so re-read with GET when that is needed. ${WORKSPACE_API_KEY_DENIED}`, - errors: RESOURCE_ERRORS, + errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], success: { description: 'The updated document, or the requeue acknowledgement.' }, }), { diff --git a/apps/sim/lib/api/server/routes/resource-concealment.test.ts b/apps/sim/lib/api/server/routes/resource-concealment.test.ts index 54670f57b5c..e7c0a5475fe 100644 --- a/apps/sim/lib/api/server/routes/resource-concealment.test.ts +++ b/apps/sim/lib/api/server/routes/resource-concealment.test.ts @@ -70,6 +70,21 @@ const policies: Array<{ policy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, notFoundMessage: 'Knowledge base not found', }, + { + domain: 'knowledge base upload', + policy: v2KnowledgeErrorPolicies.concealKnowledgeBaseUploadAuthorization, + notFoundMessage: 'Knowledge base not found', + }, + { + domain: 'knowledge base search', + policy: v2KnowledgeErrorPolicies.concealKnowledgeBaseUsageAuthorization, + notFoundMessage: 'Knowledge base not found', + }, + { + domain: 'file upload', + policy: v2FileErrorPolicies.concealUploadAuthorization, + notFoundMessage: 'Upload session not found', + }, ] const crossTenantAuthorizationErrors = [ diff --git a/apps/sim/lib/folders/application-folder-caps.test.ts b/apps/sim/lib/folders/application-folder-caps.test.ts index 08c63d8798a..619378c8d02 100644 --- a/apps/sim/lib/folders/application-folder-caps.test.ts +++ b/apps/sim/lib/folders/application-folder-caps.test.ts @@ -8,7 +8,9 @@ const mocks = vi.hoisted(() => ({ listTables: vi.fn(), listWorkflows: vi.fn(), loadFolderIndex: vi.fn(), + queryWorkspaceFiles: vi.fn(), resolvePermission: vi.fn(), + resolveWorkspaceFileWorkspace: vi.fn(), resolveTableWorkspace: vi.fn(), resolveWorkflowWorkspace: vi.fn(), })) @@ -43,12 +45,19 @@ vi.mock('@/lib/table', () => ({ updateTableDescription: vi.fn(), })) vi.mock('@/lib/table/events', () => ({ signalTableSchemaChanged: vi.fn() })) +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + listWorkspaceFiles: vi.fn(), + loadActiveWorkspaceContext: mocks.resolveWorkspaceFileWorkspace, + queryWorkspaceFiles: mocks.queryWorkspaceFiles, +})) +vi.mock('@/lib/public-shares/share-manager', () => ({ getWorkspaceShares: vi.fn() })) import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' import { listTableFoldersUseCase } from '@/lib/table/application/folders' import { listTablesUseCase } from '@/lib/table/application/tables' import { listWorkflows } from '@/lib/workflows/application/list-workflows' import { listWorkflowFolders } from '@/lib/workflows/application/workflow-folders' +import { queryWorkspaceFilePage } from '@/lib/workspace-files/application/list-workspace-files' const context = { workspaceId: 'workspace-1', @@ -73,6 +82,8 @@ describe('workflow and table application folder caps', () => { mocks.listFolderRows.mockResolvedValue([]) mocks.listWorkflows.mockResolvedValue({ data: [], nextCursorKeys: null }) mocks.listTables.mockResolvedValue({ tables: [], nextKeys: null }) + mocks.resolveWorkspaceFileWorkspace.mockResolvedValue(context) + mocks.queryWorkspaceFiles.mockResolvedValue({ files: [], nextKeys: null }) }) it.each([ @@ -140,6 +151,20 @@ describe('workflow and table application folder caps', () => { }, }), ], + [ + 'file', + () => + queryWorkspaceFilePage.execute({ + principal, + input: { + workspaceId: context.workspaceId, + sortBy: 'name', + sortOrder: 'asc', + limit: 25, + cursorSort: 'name:asc', + }, + }), + ], ] as const)('bounds the %s paged-resource folder index', async (resourceType, execute) => { await execute() diff --git a/apps/sim/lib/workspace-files/application/list-workspace-files.ts b/apps/sim/lib/workspace-files/application/list-workspace-files.ts index 92238a897c4..71973e59e34 100644 --- a/apps/sim/lib/workspace-files/application/list-workspace-files.ts +++ b/apps/sim/lib/workspace-files/application/list-workspace-files.ts @@ -1,5 +1,6 @@ import type { CursorKey } from '@/lib/api/list-query' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' import { ROOT_FOLDER_PATH } from '@/lib/folders/paths' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { getWorkspaceShares } from '@/lib/public-shares/share-manager' @@ -53,7 +54,17 @@ export const queryWorkspaceFilePage = defineAuthorizedWorkspaceFileUseCase({ resolveContext: ({ input }: { input: QueryWorkspaceFilePageInput }) => resolveListWorkspaceFileContext(input.workspaceId), async execute({ input, context }) { - const folderIndex = await loadActiveFolderPathIndex(context.workspaceId, 'file') + /** + * Capped the way the workflow, table, and knowledge lists cap theirs. A + * truncated index does not fail — it silently loses paths, and the only + * consumer here is the `folderPath` filter, so a real folder outside the + * read rows resolves to `undefined` and the caller gets "Folder not found" + * for a folder that exists. The cap turns that into the same 413 the + * sibling lists answer. + */ + const folderIndex = await loadActiveFolderPathIndex(context.workspaceId, 'file', undefined, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) const folderId = input.folderPath === undefined ? undefined diff --git a/scripts/openapi/documents.test.ts b/scripts/openapi/documents.test.ts index 862b138819c..058c1911986 100644 --- a/scripts/openapi/documents.test.ts +++ b/scripts/openapi/documents.test.ts @@ -6,6 +6,11 @@ import { filesAuditOpenApiDocument } from '../../apps/sim/lib/api/contracts/v2/o import { knowledgeOpenApiDocument } from '../../apps/sim/lib/api/contracts/v2/openapi/knowledge' import { logsOpenApiDocument } from '../../apps/sim/lib/api/contracts/v2/openapi/logs' import { resourcesOpenApiDocument } from '../../apps/sim/lib/api/contracts/v2/openapi/resources' +import { + FOLDER_TREE_TOO_LARGE, + WORKSPACE_API_KEY_DENIED, + WORKSPACE_API_KEY_DENIED_AS_NOT_FOUND, +} from '../../apps/sim/lib/api/contracts/v2/openapi/shared' import { tablesOpenApiDocument } from '../../apps/sim/lib/api/contracts/v2/openapi/tables' import { workflowsOpenApiDocument } from '../../apps/sim/lib/api/contracts/v2/openapi/workflows' import { generateOpenApiDocument, serializeOpenApiDocument } from './generator' @@ -324,3 +329,79 @@ describe('generated OpenAPI documents', () => { } }) }) + +/** + * Documented error sets for the knowledge and files/audit families. + * + * Scoped to those two documents deliberately: they are the families this pass + * audited, and the same sweep over tables and resources still reports gaps that + * belong to their owners. + */ +describe('knowledge and files documented error sets', () => { + const SCOPED_DOCUMENTS = [knowledgeOpenApiDocument, filesAuditOpenApiDocument] as const + + /** + * A v2 JSON route whose contract declares a body reads that body through + * `parseJsonBody` under `DEFAULT_MAX_JSON_BODY_BYTES` *before* schema + * validation, with the builders supplying `V2_PARSE_DEFAULTS`. So an + * oversized body is a real 413 on every one of them, and an operation that + * does not publish it is documenting a response its callers can hit. The + * converse does not hold — several bodyless folder reads publish 413 because + * materializing an oversized folder tree raises one — so this is one + * directional. + */ + it.each( + SCOPED_DOCUMENTS.flatMap((document) => + document.routes + .filter((route) => route.contract.body !== undefined) + .map((route) => [route.operation.operationId, route.operation.errors] as const) + ) + )('%s publishes the 413 its body read can raise', (_operationId, errors) => { + expect(errors).toContain('PayloadTooLarge') + }) + + /** + * The file list resolves its `folderPath` filter through the capped folder + * path index, so an oversized workspace tree is a 413 here exactly as it is on + * the knowledge, workflow, and table lists. + */ + it('publishes the folder-tree 413 the file list can raise', () => { + const listFiles = filesAuditOpenApiDocument.routes.find( + (route) => route.operation.operationId === 'listFiles' + )?.operation + + expect(listFiles?.errors).toContain('PayloadTooLarge') + expect(listFiles?.description).toContain(FOLDER_TREE_TOO_LARGE) + }) + + /** + * `listAuditLogs` has no not-found path to publish. It throws only + * `validation` (a bad cursor, a workspaceId outside the organization), + * `resolveEnterpriseAuditAccess` returns 403 shapes only, and an empty + * selection is an empty page. `getAuditLog` does 404 and keeps it. + */ + it('does not publish a 404 the audit-log list cannot emit', () => { + const spec = generateOpenApiDocument(filesAuditOpenApiDocument) + expect( + Object.keys(getOperation(spec, '/api/v2/audit-logs', 'get').responses as JsonObject) + ).not.toContain('404') + expect( + Object.keys(getOperation(spec, '/api/v2/audit-logs/{id}', 'get').responses as JsonObject) + ).toContain('404') + }) + + /** + * `files.share.update` denies the workspace key through its principal-kind + * list, which raises `PrincipalKindAuthorizationError` — not one of the + * cross-tenant errors the concealment policy rewrites — so the caller sees + * 403. The description claimed 404. + */ + it('describes the file-share workspace-key refusal as the 403 it renders', () => { + const description = filesAuditOpenApiDocument.routes.find( + (route) => route.operation.operationId === 'upsertFileShare' + )?.operation.description + + expect(description).toContain(WORKSPACE_API_KEY_DENIED) + expect(description).not.toContain(WORKSPACE_API_KEY_DENIED_AS_NOT_FOUND) + }) +}) From 86e12f04000f7c97616eeb7fd5d3b3eb7792073f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 16:04:34 -0700 Subject: [PATCH 04/56] fix(v2): publish the reachable 413 on body-carrying resources ops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `parseRequest` buffers a JSON body through `parseJsonBody` under `DEFAULT_MAX_JSON_BODY_BYTES` before any schema runs, and the v2 builders supply `V2_PARSE_DEFAULTS.payloadTooLargeResponse`, so every operation whose contract declares a body already answers 413 above the cap. The resources family published it on none of them. A status a caller cannot see in the spec is a status they will not handle. Adds `RESOURCE_BODY_ERRORS` and `RESOURCE_CONFLICT_BODY_ERRORS` to the shared sets and applies them to the seven affected operations: createMcpServer, updateMcpServer, createSkill, updateSkill, createCustomTool, updateCustomTool, and setSecret. All seven are `defineV2JsonRoute` handlers on non-GET methods with no `parseOptions` override, so the 413 is genuinely reachable on each. The new sets are opt-in rather than folded into the base sets precisely because reachability is not automatic — an operation with no body, or one whose payload reaches it through an uncapped path, would be publishing a response that can never arrive. A sweep test pins the invariant across the resources, billing, and logs documents. It is one-directional by construction: several bodyless operations publish 413 for their own folder-tree and render ceilings, so the converse would flag correct documentation. Also completes the shared-constant consolidation started in cd3efefab9: `openapi/billing.ts` and `openapi/logs.ts` each re-derived `RESOURCE_ERRORS` inline in two operations. Both now import it, and both regenerate byte-identical. --- apps/docs/openapi-v2-resources.json | 21 ++++++++ .../lib/api/contracts/v2/openapi/billing.ts | 6 +-- apps/sim/lib/api/contracts/v2/openapi/logs.ts | 6 +-- .../contracts/v2/openapi/resources.test.ts | 54 +++++++++++++++++++ .../lib/api/contracts/v2/openapi/resources.ts | 16 +++--- .../lib/api/contracts/v2/openapi/shared.ts | 29 ++++++++++ 6 files changed, 119 insertions(+), 13 deletions(-) create mode 100644 apps/sim/lib/api/contracts/v2/openapi/resources.test.ts diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 15b1bf55db9..3e068024064 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -383,6 +383,9 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -533,6 +536,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -885,6 +891,9 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1038,6 +1047,9 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1301,6 +1313,9 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1454,6 +1469,9 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1910,6 +1928,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, diff --git a/apps/sim/lib/api/contracts/v2/openapi/billing.ts b/apps/sim/lib/api/contracts/v2/openapi/billing.ts index d282d7625cd..c1555ad90a0 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/billing.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/billing.ts @@ -7,11 +7,11 @@ import { ERROR_RESPONSES, type ErrorResponseId, RATE_LIMIT_HEADERS, + RESOURCE_ERRORS, V2_API_KEY_SECURITY, V2_API_KEY_SECURITY_SCHEMES, V2_COMMON_HEADERS, V2_ERROR_SCHEMA, - WORKSPACE_ERRORS, } from '@/lib/api/contracts/v2/openapi/shared' import { defineOpenApiDocument, @@ -81,7 +81,7 @@ const routes = [ summary: 'Get Billing Status', description: "Return the current plan, billing standing, credit allowance, and storage quota. `credits` and `storage` report the payer's pooled allowances and are null unless the caller can manage that payer's billing; they are always null for a workspace API key. Billing history lives at `GET /api/v2/billing/logs`. Without a Stripe subscription — notably on the free plan — there is no real billing period: `period` is the open interval 1970-01-01 to 9999-12-31 and `credits.used` is lifetime consumption, not consumption since a period start.", - errors: [...WORKSPACE_ERRORS, 'NotFound'], + errors: RESOURCE_ERRORS, success: { description: 'The current billing and storage status.' }, }), { @@ -107,7 +107,7 @@ const routes = [ summary: 'List Billing Logs', description: 'List the credit-denominated billing ledger with source filtering and opaque cursor pagination. `period` defaults to `30d`, so an unqualified request covers only the last 30 days: paginating to `nextCursor: null` exhausts that window, not the whole ledger. Pass `period=all` for full history, or `period=custom` with `startDate` and `endDate` for a specific range.', - errors: [...WORKSPACE_ERRORS, 'NotFound'], + errors: RESOURCE_ERRORS, success: { description: 'A page of usage events.' }, }), { diff --git a/apps/sim/lib/api/contracts/v2/openapi/logs.ts b/apps/sim/lib/api/contracts/v2/openapi/logs.ts index fb473759d8f..b0cbc32eb83 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/logs.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/logs.ts @@ -4,11 +4,11 @@ import { ERROR_RESPONSES, type ErrorResponseId, RATE_LIMIT_HEADERS, + RESOURCE_ERRORS, V2_API_KEY_SECURITY, V2_API_KEY_SECURITY_SCHEMES, V2_COMMON_HEADERS, V2_ERROR_SCHEMA, - WORKSPACE_ERRORS, } from '@/lib/api/contracts/v2/openapi/shared' import { defineOpenApiDocument, @@ -96,7 +96,7 @@ const routes = [ summary: 'List Logs', description: 'List workflow execution logs for a workspace with filters, selectable detail, and opaque cursor pagination. This list predates the shared sort convention: it has no `sortBy` (the sort column is fixed to execution start time) and spells the direction `order` rather than `sortOrder`. Trace spans are stored separately from the log row and are pruned on their own retention schedule: `includeTraceSpans=true` on a run whose stored spans have aged out returns `traceSpans: []` rather than an error, so an empty array does not mean the run recorded no spans.', - errors: [...WORKSPACE_ERRORS, 'NotFound'], + errors: RESOURCE_ERRORS, success: { description: 'A page of execution logs matching the filters.' }, }), { @@ -122,7 +122,7 @@ const routes = [ summary: 'Get Log', description: 'Retrieve the diagnostic representation of a run, including its workflow snapshot, trace spans, final output, and cost. The returned `workflowState` snapshot has credential values redacted: OAuth credential references and secret (`password`) sub-block values are null, while `{{VAR}}` environment-variable references are preserved so consecutive snapshots stay diffable. Trace spans are stored separately from the log row and are pruned on their own retention schedule: a run whose stored spans have aged out returns `traceSpans: []` rather than an error, so an empty array does not mean the run recorded no spans.', - errors: [...WORKSPACE_ERRORS, 'NotFound'], + errors: RESOURCE_ERRORS, success: { description: 'The requested diagnostic log representation.' }, }), { diff --git a/apps/sim/lib/api/contracts/v2/openapi/resources.test.ts b/apps/sim/lib/api/contracts/v2/openapi/resources.test.ts new file mode 100644 index 00000000000..b4276912e8a --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/openapi/resources.test.ts @@ -0,0 +1,54 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { billingOpenApiDocument } from '@/lib/api/contracts/v2/openapi/billing' +import { logsOpenApiDocument } from '@/lib/api/contracts/v2/openapi/logs' +import { resourcesOpenApiDocument } from '@/lib/api/contracts/v2/openapi/resources' +import type { OpenApiDocumentDefinition, OpenApiRouteDefinition } from '@/lib/api/openapi/types' + +/** + * Mirrors `shouldReadJsonBody` in `lib/api/server/validation`: a contract's body + * is read, and therefore size-capped, exactly when it is declared on a non-`GET` + * method. Restating the predicate rather than importing it keeps this file out + * of the server graph, which the spec generator must not pull in. + */ +function readsJsonBody(route: OpenApiRouteDefinition): boolean { + return Boolean(route.contract.body) && route.contract.method !== 'GET' +} + +function label(route: OpenApiRouteDefinition): string { + return `${route.operation.operationId} (${route.contract.method} ${route.contract.path})` +} + +const DOCUMENTS: readonly OpenApiDocumentDefinition[] = [ + resourcesOpenApiDocument, + billingOpenApiDocument, + logsOpenApiDocument, +] + +describe.each(DOCUMENTS.map((document) => [document.output, document] as const))( + '%s', + (_output, document) => { + /** + * `parseRequest` buffers the JSON body under `DEFAULT_MAX_JSON_BODY_BYTES` + * before any schema runs, and the v2 builders supply the 413 renderer, so + * every body-carrying operation can answer 413 whether or not it says so. A + * status a caller cannot see in the spec is a status they will not handle. + * + * The sweep is deliberately one-directional. Several bodyless operations + * publish 413 for their own ceilings — a folder tree too large to load, a + * generated artifact too large to render — so the converse is false and + * asserting it would flag correct documentation. + */ + it('publishes 413 on every operation whose contract carries a request body', () => { + const undocumented = document.routes + .filter( + (route) => readsJsonBody(route) && !route.operation.errors.includes('PayloadTooLarge') + ) + .map(label) + + expect(undocumented).toEqual([]) + }) + } +) diff --git a/apps/sim/lib/api/contracts/v2/openapi/resources.ts b/apps/sim/lib/api/contracts/v2/openapi/resources.ts index 44cfb7aa718..5d0843332f3 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/resources.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/resources.ts @@ -20,6 +20,8 @@ import { type ErrorResponseId, FULL_SET_LIST, RATE_LIMIT_HEADERS, + RESOURCE_BODY_ERRORS, + RESOURCE_CONFLICT_BODY_ERRORS, RESOURCE_CONFLICT_ERRORS, RESOURCE_ERRORS, V2_API_KEY_SECURITY, @@ -293,7 +295,7 @@ const routes = [ summary: 'Create MCP Server', description: 'Register an MCP server in a workspace. The endpoint URL determines server identity, must be absolute HTTP or HTTPS, and cannot contain environment-variable references. Header values and OAuth client secrets are write-only. `transport`, `timeout`, `retries`, and `enabled` are applied server-side when omitted; the effective values are in the response.', - errors: RESOURCE_CONFLICT_ERRORS, + errors: RESOURCE_CONFLICT_BODY_ERRORS, success: { description: 'The MCP server was registered.' }, }), { @@ -360,7 +362,7 @@ const routes = [ summary: 'Update MCP Server', description: 'Update the supplied MCP server fields. The URL is immutable because it determines server identity; delete and recreate the server to change endpoints. Two fields do not follow the omitted-fields-are-retained rule. `headers` is replaced wholesale rather than merged: sending it drops every stored header it does not repeat, and the only way to keep a header is to resend it. Changing `oauthClientId`, or sending `oauthClientSecret` as null or a new value, revokes the stored OAuth grant and forces reauthorization; switching away from OAuth authentication revokes it too.', - errors: RESOURCE_ERRORS, + errors: RESOURCE_BODY_ERRORS, success: { description: 'The updated MCP server.' }, }), { @@ -482,7 +484,7 @@ const routes = [ summary: 'Create Skill', description: 'Create one skill in a workspace. Its kebab-case name must be unique and cannot be reserved by a built-in skill. Note that a workspace API key may create a skill but may not later update or delete it.', - errors: RESOURCE_CONFLICT_ERRORS, + errors: RESOURCE_CONFLICT_BODY_ERRORS, success: { description: 'The skill was created.' }, }), { @@ -547,7 +549,7 @@ const routes = [ operationId: 'updateSkill', summary: 'Update Skill', description: `Update the supplied fields on a workspace skill. Omitted fields retain their stored values. Built-in skills are read-only. ${WORKSPACE_API_KEY_DENIED}`, - errors: RESOURCE_CONFLICT_ERRORS, + errors: RESOURCE_CONFLICT_BODY_ERRORS, success: { description: 'The updated skill.' }, }), { @@ -637,7 +639,7 @@ const routes = [ summary: 'Create Custom Tool', description: 'Create a code-backed custom tool in a workspace. Its title must be unique because tools resolve by title at call time.', - errors: RESOURCE_CONFLICT_ERRORS, + errors: RESOURCE_CONFLICT_BODY_ERRORS, success: { description: 'The custom tool was created.' }, }), { @@ -702,7 +704,7 @@ const routes = [ summary: 'Update Custom Tool', description: 'Update the supplied custom tool fields. Omitted fields retain their stored values, and titles must remain unique within the workspace.', - errors: RESOURCE_CONFLICT_ERRORS, + errors: RESOURCE_CONFLICT_BODY_ERRORS, success: { description: 'The updated custom tool.' }, }), { @@ -817,7 +819,7 @@ const routes = [ operationId: 'setSecret', summary: 'Set Secret', description: `Create or replace a workspace or caller-owned personal secret. The value is encrypted at rest, is write-only, and is never included in the response. ${WORKSPACE_API_KEY_DENIED}`, - errors: RESOURCE_ERRORS, + errors: RESOURCE_BODY_ERRORS, success: { byStatus: { 200: { description: 'The existing secret value was replaced.' }, diff --git a/apps/sim/lib/api/contracts/v2/openapi/shared.ts b/apps/sim/lib/api/contracts/v2/openapi/shared.ts index a2de98da422..083657a8291 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/shared.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/shared.ts @@ -136,6 +136,35 @@ export const RESOURCE_MUTATION_ERRORS = [ 'Locked', ] as const satisfies readonly ErrorResponseId[] +/** + * The two sets below add the `413` that every body-carrying operation can emit. + * + * It is not a property of the resource but of the request: `parseRequest` reads + * the JSON body through `parseJsonBody` under `DEFAULT_MAX_JSON_BODY_BYTES` + * before schema validation runs, and the v2 builders supply + * `V2_PARSE_DEFAULTS.payloadTooLargeResponse`, so a body over + * the cap is answered `413` on any route whose contract declares one. That made + * `413` reachable-but-unpublished across a whole family, which is the mirror of + * the defect these sets exist to prevent — a caller cannot handle a status the + * spec never mentions. + * + * Reachability is not automatic, so these are opt-in rather than folded into the + * base sets. An operation with no request body cannot emit this `413` at all, + * and neither can one whose handler reads its payload through a path that + * applies no cap; documenting it there would publish a response that can never + * arrive. + */ +export const RESOURCE_BODY_ERRORS = [ + ...RESOURCE_ERRORS, + 'PayloadTooLarge', +] as const satisfies readonly ErrorResponseId[] + +/** {@link RESOURCE_CONFLICT_ERRORS} plus the body-size `413`. */ +export const RESOURCE_CONFLICT_BODY_ERRORS = [ + ...RESOURCE_CONFLICT_ERRORS, + 'PayloadTooLarge', +] as const satisfies readonly ErrorResponseId[] + export const V2_API_KEY_SECURITY = [{ apiKey: [] }] as const export const V2_API_KEY_SECURITY_SCHEMES = { From 76c6602d9f123c370f1887dab7eb06877f1c6b30 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 16:07:21 -0700 Subject: [PATCH 05/56] fix(v2): head-safe binary downloads, coded 403s, and truthful surface docs Adds `headSafe` to `defineV2BinaryRoute`, mirroring the JSON builder: a HEAD on a route that declares itself unsafe is authenticated and rate-limited, then answered bodiless before parsing or executing. `GET /api/v2/files/{fileId}` is the one binary v2 route and it records a `FILE_DOWNLOADED` audit event, so a HEAD probe used to fabricate a download that never happened. Names the cause of five refusals that reached the wire as codeless 403s (billing principal-kind, personal-keys-disabled and role, secret admin and write, the workspace table quota, and public sharing), adding three members to the closed `FORBIDDEN_DETAIL_CODES` set. The billing cross-tenant refusal is concealed as a 404 instead of coded, and the credential-list and knowledge file-ownership refusals stay codeless deliberately, documented at the site. Makes `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` reachable: an operation that denies workspace keys also omits them from `principalKinds`, so the kind guard always fired first and callers got `PRINCIPAL_KIND_NOT_PERMITTED` instead of the published code. Drops the unused 410 response, shares one `order` schema between the two run reads so both specs spell the enum the same way, and corrects the false statements about 403 codes, 413 causes, cursor schemes, and full-set lists in the conventions skill and the contract TSDoc. --- .agents/skills/v2-api-conventions/SKILL.md | 16 ++- .claude/commands/v2-api-conventions.md | 16 ++- .cursor/commands/v2-api-conventions.md | 16 ++- apps/docs/openapi-v2-billing.json | 14 +- apps/docs/openapi-v2-files-audit.json | 16 +-- apps/docs/openapi-v2-knowledge.json | 14 +- apps/docs/openapi-v2-logs.json | 18 +-- apps/docs/openapi-v2-resources.json | 14 +- apps/docs/openapi-v2-tables.json | 14 +- apps/docs/openapi-v2-workflows.json | 14 +- apps/sim/app/api/v2/billing/logs/route.ts | 10 +- .../app/api/v2/billing/status/route.test.ts | 32 ++++- apps/sim/app/api/v2/billing/status/route.ts | 10 +- apps/sim/app/api/v2/files/[fileId]/route.ts | 9 ++ apps/sim/app/api/v2/tables/route.test.ts | 32 +++++ .../v2/__tests__/list-pagination.test.ts | 23 ++-- apps/sim/lib/api/contracts/v2/logs.ts | 12 +- .../api/contracts/v2/openapi/files-audit.ts | 2 +- .../lib/api/contracts/v2/openapi/shared.ts | 45 +++++-- apps/sim/lib/api/contracts/v2/shared.ts | 61 +++++++-- apps/sim/lib/api/contracts/v2/workflows.ts | 12 +- .../api/server/routes/v2-binary-route.test.ts | 127 ++++++++++++++++++ .../lib/api/server/routes/v2-binary-route.ts | 19 ++- apps/sim/lib/billing/api/route-policies.ts | 13 ++ .../authorized-billing-read-use-case.ts | 26 +++- apps/sim/lib/core/application/forbidden.ts | 12 ++ .../workspace-authorization.test.ts | 25 ++++ .../application/workspace-authorization.ts | 14 ++ .../application/list-workspace-credentials.ts | 10 +- apps/sim/lib/knowledge/documents/service.ts | 7 + apps/sim/lib/secrets/application/use-cases.ts | 10 +- apps/sim/lib/table/billing.ts | 11 ++ apps/sim/lib/table/service.ts | 23 +++- .../compiled-check-workspace-file.test.ts | 12 +- .../application/share-workspace-file.ts | 3 +- 35 files changed, 522 insertions(+), 190 deletions(-) create mode 100644 apps/sim/lib/api/server/routes/v2-binary-route.test.ts create mode 100644 apps/sim/lib/billing/api/route-policies.ts diff --git a/.agents/skills/v2-api-conventions/SKILL.md b/.agents/skills/v2-api-conventions/SKILL.md index 3431b558056..cd82f3c07ce 100644 --- a/.agents/skills/v2-api-conventions/SKILL.md +++ b/.agents/skills/v2-api-conventions/SKILL.md @@ -19,7 +19,7 @@ Nothing else at the top level. No `success: true`, no bare `{ "error": "string" That promise is worth stating as a rule because it has been broken five separate ways, each time by a route or a builder taking a shortcut that looked local: - `GET /workflows?limit=1.5` returned **500**. The contract was copied from a sibling and lost its `.int()`, so a fractional limit passed validation and reached Postgres as `LIMIT 2.5`. -- A malformed JSON body returned **`{"error":"Request body must be valid JSON"}`** — a bare string. The envelope was a per-route opt-in that only 8 of 77 routes remembered. +- A malformed JSON body returned **`{"error":"Request body must be valid JSON"}`** — a bare string. The envelope was a per-route opt-in that only 8 routes remembered. - `GET /api/v2/nonexistent` returned a **full HTML 404 document**, because no route file matched and the request fell through to the app's global not-found page. - Four collections returned `nextCursor` while **silently discarding** any `limit` the caller sent, because Zod strips unknown keys unless the schema is `.strict()`. - Handing back a `nextCursor` from any timestamp-sorted list and passing it straight in returned **500**. The value was validated and bound — but bound with no SQL type, into `date_trunc`, which is overloaded, so Postgres could resolve no overload. Validation was never the missing half; the type was. @@ -52,10 +52,10 @@ A route built with `defineV2JsonRoute` gets this for free: its `present` returns | 200 / 201 | — | Success. 201 only for a created resource. | | 400 | `BAD_REQUEST` | Contract validation. Carries field-level `details` from `serializeZodIssues`. | | 401 | `UNAUTHORIZED` | No/!valid API key. **Runs before the rollout gate.** | -| 403 | `FORBIDDEN` | Authenticated, same tenant, insufficient rights. Carries a machine-readable `details.code` from the closed set in `lib/core/application/forbidden.ts` (e.g. `INSUFFICIENT_WORKSPACE_ROLE`, `PERSONAL_API_KEYS_DISABLED`). | +| 403 | `FORBIDDEN` | Authenticated, same tenant, insufficient rights. Where the cause is one a caller can act on it is named in `details.code`, from the closed set in `lib/core/application/forbidden.ts` (e.g. `INSUFFICIENT_WORKSPACE_ROLE`, `PERSONAL_API_KEYS_DISABLED`). A few domain refusals still reach the wire without one. | | 404 | `NOT_FOUND` | Not found, **and** cross-tenant concealment, **and** the rollout gate, **and** an unknown path. | | 409 | `CONFLICT` | Uniqueness/state conflict, human-readable message. | -| 413 | `PAYLOAD_TOO_LARGE` | Body over the route's `maxBodyBytes`. | +| 413 | `PAYLOAD_TOO_LARGE` | Body over the route's `maxBodyBytes` — **and** a collection the response must materialize that is over *its* ceiling. Fourteen bodyless `GET`/`DELETE` operations publish it for the folder-tree cap (`FolderCollectionLimitExceededError`) or the rendered-artifact cap. | | 429 | `RATE_LIMITED` | With `Retry-After` and `X-RateLimit-*`. | | 500 | `INTERNAL_ERROR` | Genuine server fault only. Message is always generic. | @@ -74,7 +74,7 @@ And this class survives a green test suite — `keysetAfter` returned well-forme - An operation whose `minimumRole` is `write` or `admin` can always 403 — a member with a lower role hits it. Document 403. - An operation whose `minimumRole` is `read` cannot 403 *that* way, because `read` is the floor of the `read < write < admin` ordering and anyone without access is concealed as 404 instead. It can still 403 through `PersonalApiKeysDisabledError` (a personal API key against a workspace whose organization disabled them) or `WorkspaceApiKeyAuthorizationError` (`workspaceApiKey: 'deny'`), and every v2 operation is reachable by a personal API key. **So in practice every workspace-scoped v2 operation documents 403**, and the reads that omitted it were wrong, not principled. -**Every 403 names its cause in `error.details.code`.** One status covers four different remedies — raise a member's role, issue a personal key instead of a workspace-scoped one, re-point a workspace key, buy an enterprise plan — and prose is not branchable, so a client that must tell them apart was string-matching messages, which turns every reword into a silent break. +**A 403 a caller can act on names its cause in `error.details.code` — but not every 403 does yet.** One status covers several different remedies — raise a member's role, issue a personal key instead of a workspace-scoped one, re-point a workspace key, buy an enterprise plan, delete a resource to get under a quota — and prose is not branchable, so a client that must tell them apart was string-matching messages, which turns every reword into a silent break. A handful of domain refusals still throw a bare `OrchestrationError('forbidden', …)` and reach the wire with no code, so **write client code that treats `details.code` as optional**, and read `openapi/shared.ts`'s `FORBIDDEN_DESCRIPTION` for the current position rather than assuming the sweep is finished. For code you are *writing*, the rule below is unconditional. The vocabulary is a closed set, `FORBIDDEN_DETAIL_CODES` in `lib/core/application/forbidden.ts`, with a `Record` of descriptions beside it that the generated OpenAPI 403 description is built from. Adding a member fails to compile until it is documented, so a code cannot reach the wire unpublished. Do not invent a code at a route: throw `ForbiddenOperationError(code, message)` from the domain and let `v2CaughtOrchestrationError` — the function every v2 error policy falls through to — attach it. `InsufficientWorkspacePermissionsError`, `PersonalApiKeysDisabledError`, `WorkspaceApiKeyAuthorizationError`, and `PrincipalKindAuthorizationError` already carry theirs. @@ -84,6 +84,8 @@ Use the shared sets in `contracts/v2/openapi/shared.ts` — `RESOURCE_ERRORS`, ` **HEAD is answered by the `GET` handler, not rejected.** Next aliases a missing `HEAD` export onto `GET` and drops the body when sending, so a route's `GET` legitimately runs with `request.method === 'HEAD'`. The builders' method guard accepts that pairing via `methodMatchesContract`; any other mismatch stays a hard error. Never hand-write a `HEAD` export to "fix" this. +**A `GET` with side effects must declare `headSafe: false`.** The aliasing above is only sound because RFC 9110 §9.2.1 defines `HEAD` as safe. A `GET` that writes a row or opens an outbound connection is not, and an uptime monitor or link checker walking the documented URL list would drive those effects on every probe — `GET /files/{fileId}` records a `FILE_DOWNLOADED` audit event, so a `HEAD` used to fabricate a download that never happened. Both the JSON and binary builders take `headSafe`: a route that sets it `false` still authenticates and rate-limits a `HEAD`, then answers `v2HeadNoEffect()` — a bodiless 200 — before parsing or executing. Nothing observable is lost, because `HEAD` carries no body either way. Audit this whenever a read acquires an audit projection or an outbound call. + ## Rule 3 — a collection that returns `nextCursor` must accept `limit` + `cursor`, and must apply them Every list returns `{ data, nextCursor }`. Whether it *pages* is a separate, pinned decision — see `lib/api/contracts/v2/__tests__/list-pagination.test.ts`, which enumerates both sets and fails when a new list is in neither. @@ -96,11 +98,13 @@ Build the query slice from the shared helper, never by hand: That gives `limit` (integer, 1..`V2_MAX_PAGE_SIZE`, defaulting to `V2_DEFAULT_PAGE_SIZE` = 50) and an opaque `cursor`. Re-declaring `limit: z.coerce.number()...` inline is how the 500 happened; there is one schema so the family cannot drift again. -Two cursor schemes exist, both opaque base64-JSON from `response.ts`. Which one you use is decided by what the read can express, not by taste: +Three cursor schemes exist. Two are the shared codecs in `response.ts`, both opaque base64-JSON, and which of them you use is decided by what the read can express, not by taste: - **Keyset** (`readSortedCursor` in, `encodeSortedCursor` out) — the default. Requires the page to come from one ordered SQL read. The sort is stamped into the cursor and re-checked on replay, so changing `sortBy` mid-pagination is a 400, not a silently skipped page. - **Offset** (`decodeOffsetCursor` / `encodeOffsetCursor`) — only when a keyset is impossible. Two lists qualify: `GET /skills` merges a static in-code registry with DB rows and re-sorts in JS, and `GET /knowledge/{id}/documents` sits on a limit/offset query. An offset cursor **must** be stamped with `offsetCursorScope(...)` covering every param that filters or orders the sequence (not `limit`, which only selects how much of it to return). A bare offset replayed against a re-sorted or re-filtered sequence names a different row, which skips or repeats results — the exact failure the keyset's sort stamp already prevents. +The third is **per-domain**: a list whose read predates the shared codecs, or whose page boundary is not expressible as one, mints its own — a bare `encodeCursor({ version })` on `GET /workflows/{id}/versions` and `encodeCursor({ email })` on the workspace member list, the local codecs in `lib/audit-logs/query.ts`, `lib/logs/list-logs.ts`, and `lib/table/rows/cursor.ts`, and a usage-event id passed straight through by `GET /billing/logs`. They are opaque to a caller in exactly the same way, but they do not get the shared codec's sort stamp, so they cannot reject a cursor replayed under a changed sort. **A new list picks one of the two shared schemes.** Do not add a fourth. + **A keyset's key list must end in a unique column (`id`).** A non-unique trailing key cannot separate tied rows, so the page boundary either repeats or drops them. `lib/api/list-keyset-paging.test.ts` demonstrates the failure. Return `nextCursor: null` on the last page and only then. Never construct a cursor client-side. @@ -153,7 +157,7 @@ Do not add a default for any other code. 400/403/404/409 are not fixed by waitin **A failure whose outcome is unknown must not advise a retry.** `ASYNC_ENQUEUE_AMBIGUOUS` is a 503 whose enqueue may have succeeded — it deliberately retains its execution-ID claim. Telling that caller to come back in 5 seconds invites a client with no `X-Run-Id` to start and bill a second run. It passes `omitRetryAfter: true` and returns the run id so the caller reconciles instead. Any future "we don't know if it happened" failure does the same. -RFC 9110 §10.2.3 gives 503 this field's clearest meaning — "how long the service is expected to be unavailable to the client". Note the requirement level is only `MAY`, on 503 (§15.6.4) and, via RFC 6585 §4, on 429. It is `SHOULD` on exactly one status, 413, and only when the condition is temporary; Sim's 413 is a fixed byte ceiling, so it correctly sends none. +RFC 9110 §10.2.3 gives 503 this field's clearest meaning — "how long the service is expected to be unavailable to the client". Note the requirement level is only `MAY`, on 503 (§15.6.4) and, via RFC 6585 §4, on 429. It is `SHOULD` on exactly one status, 413, and only when the condition is temporary; none of Sim's 413s are temporary — they are fixed ceilings, on the request body and on the collections a response must materialize — so it correctly sends none. ## Deliberate non-adoptions diff --git a/.claude/commands/v2-api-conventions.md b/.claude/commands/v2-api-conventions.md index 89095067c66..9f43a888e23 100644 --- a/.claude/commands/v2-api-conventions.md +++ b/.claude/commands/v2-api-conventions.md @@ -18,7 +18,7 @@ Nothing else at the top level. No `success: true`, no bare `{ "error": "string" That promise is worth stating as a rule because it has been broken five separate ways, each time by a route or a builder taking a shortcut that looked local: - `GET /workflows?limit=1.5` returned **500**. The contract was copied from a sibling and lost its `.int()`, so a fractional limit passed validation and reached Postgres as `LIMIT 2.5`. -- A malformed JSON body returned **`{"error":"Request body must be valid JSON"}`** — a bare string. The envelope was a per-route opt-in that only 8 of 77 routes remembered. +- A malformed JSON body returned **`{"error":"Request body must be valid JSON"}`** — a bare string. The envelope was a per-route opt-in that only 8 routes remembered. - `GET /api/v2/nonexistent` returned a **full HTML 404 document**, because no route file matched and the request fell through to the app's global not-found page. - Four collections returned `nextCursor` while **silently discarding** any `limit` the caller sent, because Zod strips unknown keys unless the schema is `.strict()`. - Handing back a `nextCursor` from any timestamp-sorted list and passing it straight in returned **500**. The value was validated and bound — but bound with no SQL type, into `date_trunc`, which is overloaded, so Postgres could resolve no overload. Validation was never the missing half; the type was. @@ -51,10 +51,10 @@ A route built with `defineV2JsonRoute` gets this for free: its `present` returns | 200 / 201 | — | Success. 201 only for a created resource. | | 400 | `BAD_REQUEST` | Contract validation. Carries field-level `details` from `serializeZodIssues`. | | 401 | `UNAUTHORIZED` | No/!valid API key. **Runs before the rollout gate.** | -| 403 | `FORBIDDEN` | Authenticated, same tenant, insufficient rights. Carries a machine-readable `details.code` from the closed set in `lib/core/application/forbidden.ts` (e.g. `INSUFFICIENT_WORKSPACE_ROLE`, `PERSONAL_API_KEYS_DISABLED`). | +| 403 | `FORBIDDEN` | Authenticated, same tenant, insufficient rights. Where the cause is one a caller can act on it is named in `details.code`, from the closed set in `lib/core/application/forbidden.ts` (e.g. `INSUFFICIENT_WORKSPACE_ROLE`, `PERSONAL_API_KEYS_DISABLED`). A few domain refusals still reach the wire without one. | | 404 | `NOT_FOUND` | Not found, **and** cross-tenant concealment, **and** the rollout gate, **and** an unknown path. | | 409 | `CONFLICT` | Uniqueness/state conflict, human-readable message. | -| 413 | `PAYLOAD_TOO_LARGE` | Body over the route's `maxBodyBytes`. | +| 413 | `PAYLOAD_TOO_LARGE` | Body over the route's `maxBodyBytes` — **and** a collection the response must materialize that is over *its* ceiling. Fourteen bodyless `GET`/`DELETE` operations publish it for the folder-tree cap (`FolderCollectionLimitExceededError`) or the rendered-artifact cap. | | 429 | `RATE_LIMITED` | With `Retry-After` and `X-RateLimit-*`. | | 500 | `INTERNAL_ERROR` | Genuine server fault only. Message is always generic. | @@ -73,7 +73,7 @@ And this class survives a green test suite — `keysetAfter` returned well-forme - An operation whose `minimumRole` is `write` or `admin` can always 403 — a member with a lower role hits it. Document 403. - An operation whose `minimumRole` is `read` cannot 403 *that* way, because `read` is the floor of the `read < write < admin` ordering and anyone without access is concealed as 404 instead. It can still 403 through `PersonalApiKeysDisabledError` (a personal API key against a workspace whose organization disabled them) or `WorkspaceApiKeyAuthorizationError` (`workspaceApiKey: 'deny'`), and every v2 operation is reachable by a personal API key. **So in practice every workspace-scoped v2 operation documents 403**, and the reads that omitted it were wrong, not principled. -**Every 403 names its cause in `error.details.code`.** One status covers four different remedies — raise a member's role, issue a personal key instead of a workspace-scoped one, re-point a workspace key, buy an enterprise plan — and prose is not branchable, so a client that must tell them apart was string-matching messages, which turns every reword into a silent break. +**A 403 a caller can act on names its cause in `error.details.code` — but not every 403 does yet.** One status covers several different remedies — raise a member's role, issue a personal key instead of a workspace-scoped one, re-point a workspace key, buy an enterprise plan, delete a resource to get under a quota — and prose is not branchable, so a client that must tell them apart was string-matching messages, which turns every reword into a silent break. A handful of domain refusals still throw a bare `OrchestrationError('forbidden', …)` and reach the wire with no code, so **write client code that treats `details.code` as optional**, and read `openapi/shared.ts`'s `FORBIDDEN_DESCRIPTION` for the current position rather than assuming the sweep is finished. For code you are *writing*, the rule below is unconditional. The vocabulary is a closed set, `FORBIDDEN_DETAIL_CODES` in `lib/core/application/forbidden.ts`, with a `Record` of descriptions beside it that the generated OpenAPI 403 description is built from. Adding a member fails to compile until it is documented, so a code cannot reach the wire unpublished. Do not invent a code at a route: throw `ForbiddenOperationError(code, message)` from the domain and let `v2CaughtOrchestrationError` — the function every v2 error policy falls through to — attach it. `InsufficientWorkspacePermissionsError`, `PersonalApiKeysDisabledError`, `WorkspaceApiKeyAuthorizationError`, and `PrincipalKindAuthorizationError` already carry theirs. @@ -83,6 +83,8 @@ Use the shared sets in `contracts/v2/openapi/shared.ts` — `RESOURCE_ERRORS`, ` **HEAD is answered by the `GET` handler, not rejected.** Next aliases a missing `HEAD` export onto `GET` and drops the body when sending, so a route's `GET` legitimately runs with `request.method === 'HEAD'`. The builders' method guard accepts that pairing via `methodMatchesContract`; any other mismatch stays a hard error. Never hand-write a `HEAD` export to "fix" this. +**A `GET` with side effects must declare `headSafe: false`.** The aliasing above is only sound because RFC 9110 §9.2.1 defines `HEAD` as safe. A `GET` that writes a row or opens an outbound connection is not, and an uptime monitor or link checker walking the documented URL list would drive those effects on every probe — `GET /files/{fileId}` records a `FILE_DOWNLOADED` audit event, so a `HEAD` used to fabricate a download that never happened. Both the JSON and binary builders take `headSafe`: a route that sets it `false` still authenticates and rate-limits a `HEAD`, then answers `v2HeadNoEffect()` — a bodiless 200 — before parsing or executing. Nothing observable is lost, because `HEAD` carries no body either way. Audit this whenever a read acquires an audit projection or an outbound call. + ## Rule 3 — a collection that returns `nextCursor` must accept `limit` + `cursor`, and must apply them Every list returns `{ data, nextCursor }`. Whether it *pages* is a separate, pinned decision — see `lib/api/contracts/v2/__tests__/list-pagination.test.ts`, which enumerates both sets and fails when a new list is in neither. @@ -95,11 +97,13 @@ Build the query slice from the shared helper, never by hand: That gives `limit` (integer, 1..`V2_MAX_PAGE_SIZE`, defaulting to `V2_DEFAULT_PAGE_SIZE` = 50) and an opaque `cursor`. Re-declaring `limit: z.coerce.number()...` inline is how the 500 happened; there is one schema so the family cannot drift again. -Two cursor schemes exist, both opaque base64-JSON from `response.ts`. Which one you use is decided by what the read can express, not by taste: +Three cursor schemes exist. Two are the shared codecs in `response.ts`, both opaque base64-JSON, and which of them you use is decided by what the read can express, not by taste: - **Keyset** (`readSortedCursor` in, `encodeSortedCursor` out) — the default. Requires the page to come from one ordered SQL read. The sort is stamped into the cursor and re-checked on replay, so changing `sortBy` mid-pagination is a 400, not a silently skipped page. - **Offset** (`decodeOffsetCursor` / `encodeOffsetCursor`) — only when a keyset is impossible. Two lists qualify: `GET /skills` merges a static in-code registry with DB rows and re-sorts in JS, and `GET /knowledge/{id}/documents` sits on a limit/offset query. An offset cursor **must** be stamped with `offsetCursorScope(...)` covering every param that filters or orders the sequence (not `limit`, which only selects how much of it to return). A bare offset replayed against a re-sorted or re-filtered sequence names a different row, which skips or repeats results — the exact failure the keyset's sort stamp already prevents. +The third is **per-domain**: a list whose read predates the shared codecs, or whose page boundary is not expressible as one, mints its own — a bare `encodeCursor({ version })` on `GET /workflows/{id}/versions` and `encodeCursor({ email })` on the workspace member list, the local codecs in `lib/audit-logs/query.ts`, `lib/logs/list-logs.ts`, and `lib/table/rows/cursor.ts`, and a usage-event id passed straight through by `GET /billing/logs`. They are opaque to a caller in exactly the same way, but they do not get the shared codec's sort stamp, so they cannot reject a cursor replayed under a changed sort. **A new list picks one of the two shared schemes.** Do not add a fourth. + **A keyset's key list must end in a unique column (`id`).** A non-unique trailing key cannot separate tied rows, so the page boundary either repeats or drops them. `lib/api/list-keyset-paging.test.ts` demonstrates the failure. Return `nextCursor: null` on the last page and only then. Never construct a cursor client-side. @@ -152,7 +156,7 @@ Do not add a default for any other code. 400/403/404/409 are not fixed by waitin **A failure whose outcome is unknown must not advise a retry.** `ASYNC_ENQUEUE_AMBIGUOUS` is a 503 whose enqueue may have succeeded — it deliberately retains its execution-ID claim. Telling that caller to come back in 5 seconds invites a client with no `X-Run-Id` to start and bill a second run. It passes `omitRetryAfter: true` and returns the run id so the caller reconciles instead. Any future "we don't know if it happened" failure does the same. -RFC 9110 §10.2.3 gives 503 this field's clearest meaning — "how long the service is expected to be unavailable to the client". Note the requirement level is only `MAY`, on 503 (§15.6.4) and, via RFC 6585 §4, on 429. It is `SHOULD` on exactly one status, 413, and only when the condition is temporary; Sim's 413 is a fixed byte ceiling, so it correctly sends none. +RFC 9110 §10.2.3 gives 503 this field's clearest meaning — "how long the service is expected to be unavailable to the client". Note the requirement level is only `MAY`, on 503 (§15.6.4) and, via RFC 6585 §4, on 429. It is `SHOULD` on exactly one status, 413, and only when the condition is temporary; none of Sim's 413s are temporary — they are fixed ceilings, on the request body and on the collections a response must materialize — so it correctly sends none. ## Deliberate non-adoptions diff --git a/.cursor/commands/v2-api-conventions.md b/.cursor/commands/v2-api-conventions.md index 7fa3e1a18ae..5629c2ec41d 100644 --- a/.cursor/commands/v2-api-conventions.md +++ b/.cursor/commands/v2-api-conventions.md @@ -13,7 +13,7 @@ Nothing else at the top level. No `success: true`, no bare `{ "error": "string" That promise is worth stating as a rule because it has been broken five separate ways, each time by a route or a builder taking a shortcut that looked local: - `GET /workflows?limit=1.5` returned **500**. The contract was copied from a sibling and lost its `.int()`, so a fractional limit passed validation and reached Postgres as `LIMIT 2.5`. -- A malformed JSON body returned **`{"error":"Request body must be valid JSON"}`** — a bare string. The envelope was a per-route opt-in that only 8 of 77 routes remembered. +- A malformed JSON body returned **`{"error":"Request body must be valid JSON"}`** — a bare string. The envelope was a per-route opt-in that only 8 routes remembered. - `GET /api/v2/nonexistent` returned a **full HTML 404 document**, because no route file matched and the request fell through to the app's global not-found page. - Four collections returned `nextCursor` while **silently discarding** any `limit` the caller sent, because Zod strips unknown keys unless the schema is `.strict()`. - Handing back a `nextCursor` from any timestamp-sorted list and passing it straight in returned **500**. The value was validated and bound — but bound with no SQL type, into `date_trunc`, which is overloaded, so Postgres could resolve no overload. Validation was never the missing half; the type was. @@ -46,10 +46,10 @@ A route built with `defineV2JsonRoute` gets this for free: its `present` returns | 200 / 201 | — | Success. 201 only for a created resource. | | 400 | `BAD_REQUEST` | Contract validation. Carries field-level `details` from `serializeZodIssues`. | | 401 | `UNAUTHORIZED` | No/!valid API key. **Runs before the rollout gate.** | -| 403 | `FORBIDDEN` | Authenticated, same tenant, insufficient rights. Carries a machine-readable `details.code` from the closed set in `lib/core/application/forbidden.ts` (e.g. `INSUFFICIENT_WORKSPACE_ROLE`, `PERSONAL_API_KEYS_DISABLED`). | +| 403 | `FORBIDDEN` | Authenticated, same tenant, insufficient rights. Where the cause is one a caller can act on it is named in `details.code`, from the closed set in `lib/core/application/forbidden.ts` (e.g. `INSUFFICIENT_WORKSPACE_ROLE`, `PERSONAL_API_KEYS_DISABLED`). A few domain refusals still reach the wire without one. | | 404 | `NOT_FOUND` | Not found, **and** cross-tenant concealment, **and** the rollout gate, **and** an unknown path. | | 409 | `CONFLICT` | Uniqueness/state conflict, human-readable message. | -| 413 | `PAYLOAD_TOO_LARGE` | Body over the route's `maxBodyBytes`. | +| 413 | `PAYLOAD_TOO_LARGE` | Body over the route's `maxBodyBytes` — **and** a collection the response must materialize that is over *its* ceiling. Fourteen bodyless `GET`/`DELETE` operations publish it for the folder-tree cap (`FolderCollectionLimitExceededError`) or the rendered-artifact cap. | | 429 | `RATE_LIMITED` | With `Retry-After` and `X-RateLimit-*`. | | 500 | `INTERNAL_ERROR` | Genuine server fault only. Message is always generic. | @@ -68,7 +68,7 @@ And this class survives a green test suite — `keysetAfter` returned well-forme - An operation whose `minimumRole` is `write` or `admin` can always 403 — a member with a lower role hits it. Document 403. - An operation whose `minimumRole` is `read` cannot 403 *that* way, because `read` is the floor of the `read < write < admin` ordering and anyone without access is concealed as 404 instead. It can still 403 through `PersonalApiKeysDisabledError` (a personal API key against a workspace whose organization disabled them) or `WorkspaceApiKeyAuthorizationError` (`workspaceApiKey: 'deny'`), and every v2 operation is reachable by a personal API key. **So in practice every workspace-scoped v2 operation documents 403**, and the reads that omitted it were wrong, not principled. -**Every 403 names its cause in `error.details.code`.** One status covers four different remedies — raise a member's role, issue a personal key instead of a workspace-scoped one, re-point a workspace key, buy an enterprise plan — and prose is not branchable, so a client that must tell them apart was string-matching messages, which turns every reword into a silent break. +**A 403 a caller can act on names its cause in `error.details.code` — but not every 403 does yet.** One status covers several different remedies — raise a member's role, issue a personal key instead of a workspace-scoped one, re-point a workspace key, buy an enterprise plan, delete a resource to get under a quota — and prose is not branchable, so a client that must tell them apart was string-matching messages, which turns every reword into a silent break. A handful of domain refusals still throw a bare `OrchestrationError('forbidden', …)` and reach the wire with no code, so **write client code that treats `details.code` as optional**, and read `openapi/shared.ts`'s `FORBIDDEN_DESCRIPTION` for the current position rather than assuming the sweep is finished. For code you are *writing*, the rule below is unconditional. The vocabulary is a closed set, `FORBIDDEN_DETAIL_CODES` in `lib/core/application/forbidden.ts`, with a `Record` of descriptions beside it that the generated OpenAPI 403 description is built from. Adding a member fails to compile until it is documented, so a code cannot reach the wire unpublished. Do not invent a code at a route: throw `ForbiddenOperationError(code, message)` from the domain and let `v2CaughtOrchestrationError` — the function every v2 error policy falls through to — attach it. `InsufficientWorkspacePermissionsError`, `PersonalApiKeysDisabledError`, `WorkspaceApiKeyAuthorizationError`, and `PrincipalKindAuthorizationError` already carry theirs. @@ -78,6 +78,8 @@ Use the shared sets in `contracts/v2/openapi/shared.ts` — `RESOURCE_ERRORS`, ` **HEAD is answered by the `GET` handler, not rejected.** Next aliases a missing `HEAD` export onto `GET` and drops the body when sending, so a route's `GET` legitimately runs with `request.method === 'HEAD'`. The builders' method guard accepts that pairing via `methodMatchesContract`; any other mismatch stays a hard error. Never hand-write a `HEAD` export to "fix" this. +**A `GET` with side effects must declare `headSafe: false`.** The aliasing above is only sound because RFC 9110 §9.2.1 defines `HEAD` as safe. A `GET` that writes a row or opens an outbound connection is not, and an uptime monitor or link checker walking the documented URL list would drive those effects on every probe — `GET /files/{fileId}` records a `FILE_DOWNLOADED` audit event, so a `HEAD` used to fabricate a download that never happened. Both the JSON and binary builders take `headSafe`: a route that sets it `false` still authenticates and rate-limits a `HEAD`, then answers `v2HeadNoEffect()` — a bodiless 200 — before parsing or executing. Nothing observable is lost, because `HEAD` carries no body either way. Audit this whenever a read acquires an audit projection or an outbound call. + ## Rule 3 — a collection that returns `nextCursor` must accept `limit` + `cursor`, and must apply them Every list returns `{ data, nextCursor }`. Whether it *pages* is a separate, pinned decision — see `lib/api/contracts/v2/__tests__/list-pagination.test.ts`, which enumerates both sets and fails when a new list is in neither. @@ -90,11 +92,13 @@ Build the query slice from the shared helper, never by hand: That gives `limit` (integer, 1..`V2_MAX_PAGE_SIZE`, defaulting to `V2_DEFAULT_PAGE_SIZE` = 50) and an opaque `cursor`. Re-declaring `limit: z.coerce.number()...` inline is how the 500 happened; there is one schema so the family cannot drift again. -Two cursor schemes exist, both opaque base64-JSON from `response.ts`. Which one you use is decided by what the read can express, not by taste: +Three cursor schemes exist. Two are the shared codecs in `response.ts`, both opaque base64-JSON, and which of them you use is decided by what the read can express, not by taste: - **Keyset** (`readSortedCursor` in, `encodeSortedCursor` out) — the default. Requires the page to come from one ordered SQL read. The sort is stamped into the cursor and re-checked on replay, so changing `sortBy` mid-pagination is a 400, not a silently skipped page. - **Offset** (`decodeOffsetCursor` / `encodeOffsetCursor`) — only when a keyset is impossible. Two lists qualify: `GET /skills` merges a static in-code registry with DB rows and re-sorts in JS, and `GET /knowledge/{id}/documents` sits on a limit/offset query. An offset cursor **must** be stamped with `offsetCursorScope(...)` covering every param that filters or orders the sequence (not `limit`, which only selects how much of it to return). A bare offset replayed against a re-sorted or re-filtered sequence names a different row, which skips or repeats results — the exact failure the keyset's sort stamp already prevents. +The third is **per-domain**: a list whose read predates the shared codecs, or whose page boundary is not expressible as one, mints its own — a bare `encodeCursor({ version })` on `GET /workflows/{id}/versions` and `encodeCursor({ email })` on the workspace member list, the local codecs in `lib/audit-logs/query.ts`, `lib/logs/list-logs.ts`, and `lib/table/rows/cursor.ts`, and a usage-event id passed straight through by `GET /billing/logs`. They are opaque to a caller in exactly the same way, but they do not get the shared codec's sort stamp, so they cannot reject a cursor replayed under a changed sort. **A new list picks one of the two shared schemes.** Do not add a fourth. + **A keyset's key list must end in a unique column (`id`).** A non-unique trailing key cannot separate tied rows, so the page boundary either repeats or drops them. `lib/api/list-keyset-paging.test.ts` demonstrates the failure. Return `nextCursor: null` on the last page and only then. Never construct a cursor client-side. @@ -147,7 +151,7 @@ Do not add a default for any other code. 400/403/404/409 are not fixed by waitin **A failure whose outcome is unknown must not advise a retry.** `ASYNC_ENQUEUE_AMBIGUOUS` is a 503 whose enqueue may have succeeded — it deliberately retains its execution-ID claim. Telling that caller to come back in 5 seconds invites a client with no `X-Run-Id` to start and bill a second run. It passes `omitRetryAfter: true` and returns the run id so the caller reconciles instead. Any future "we don't know if it happened" failure does the same. -RFC 9110 §10.2.3 gives 503 this field's clearest meaning — "how long the service is expected to be unavailable to the client". Note the requirement level is only `MAY`, on 503 (§15.6.4) and, via RFC 6585 §4, on 429. It is `SHOULD` on exactly one status, 413, and only when the condition is temporary; Sim's 413 is a fixed byte ceiling, so it correctly sends none. +RFC 9110 §10.2.3 gives 503 this field's clearest meaning — "how long the service is expected to be unavailable to the client". Note the requirement level is only `MAY`, on 503 (§15.6.4) and, via RFC 6585 §4, on 429. It is `SHOULD` on exactly one status, 413, and only when the condition is temporary; none of Sim's 413s are temporary — they are fixed ceilings, on the request body and on the collections a response must materialize — so it correctly sends none. ## Deliberate non-adoptions diff --git a/apps/docs/openapi-v2-billing.json b/apps/docs/openapi-v2-billing.json index ecf30cabc96..dd5eac4314f 100644 --- a/apps/docs/openapi-v2-billing.json +++ b/apps/docs/openapi-v2-billing.json @@ -334,7 +334,7 @@ } }, "Forbidden": { - "description": "The caller lacks the rights this operation requires. Where the cause is one a caller can act on, `error.details.code` names it, drawn from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\nA resource in a workspace the caller cannot reach at all answers `404`, not `403`, so absence and denial are indistinguishable to a caller who was never entitled to tell them apart.", + "description": "The caller lacks the rights this operation requires. Where the cause is one a caller can act on, `error.details.code` names it, drawn from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\nA resource in a workspace the caller cannot reach at all answers `404`, not `403`, so absence and denial are indistinguishable to a caller who was never entitled to tell them apart.", "content": { "application/json": { "schema": { @@ -378,16 +378,6 @@ } } }, - "Gone": { - "description": "The requested generated resource has expired.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - } - } - } - }, "PayloadTooLarge": { "description": "The request, or a resource collection it must materialize, exceeds the allowed size. Besides an oversized request body, this covers a generated artifact that renders past the download ceiling and a workspace folder tree too large to load in full.", "content": { @@ -434,7 +424,7 @@ } }, "ClientClosedRequest": { - "description": "The client closed the connection before the response was produced.", + "description": "The client closed the connection before the response was produced. The response is written to a connection that is already gone, so the caller that caused it never reads it; it is documented only on operations where an abort can leave work running. There, `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", "content": { "application/json": { "schema": { diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 54432ad9883..3802dfd5ae1 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -598,7 +598,7 @@ "get": { "operationId": "downloadFile", "summary": "Download File", - "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.", + "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. Downloading is recorded as an audit event, so it is not a safe read: a `HEAD` request is answered with an empty `200` without fetching the bytes or recording anything, and reports only that the endpoint exists and the caller is authorized.", "tags": ["Files"], "parameters": [ { @@ -2063,7 +2063,7 @@ } }, "Forbidden": { - "description": "The caller lacks the rights this operation requires. Where the cause is one a caller can act on, `error.details.code` names it, drawn from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\nA resource in a workspace the caller cannot reach at all answers `404`, not `403`, so absence and denial are indistinguishable to a caller who was never entitled to tell them apart.", + "description": "The caller lacks the rights this operation requires. Where the cause is one a caller can act on, `error.details.code` names it, drawn from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\nA resource in a workspace the caller cannot reach at all answers `404`, not `403`, so absence and denial are indistinguishable to a caller who was never entitled to tell them apart.", "content": { "application/json": { "schema": { @@ -2107,16 +2107,6 @@ } } }, - "Gone": { - "description": "The requested generated resource has expired.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - } - } - } - }, "PayloadTooLarge": { "description": "The request, or a resource collection it must materialize, exceeds the allowed size. Besides an oversized request body, this covers a generated artifact that renders past the download ceiling and a workspace folder tree too large to load in full.", "content": { @@ -2163,7 +2153,7 @@ } }, "ClientClosedRequest": { - "description": "The client closed the connection before the response was produced.", + "description": "The client closed the connection before the response was produced. The response is written to a connection that is already gone, so the caller that caused it never reads it; it is documented only on operations where an abort can leave work running. There, `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", "content": { "application/json": { "schema": { diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index 754dc3ade5c..56e88cbf155 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -2040,7 +2040,7 @@ } }, "Forbidden": { - "description": "The caller lacks the rights this operation requires. Where the cause is one a caller can act on, `error.details.code` names it, drawn from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\nA resource in a workspace the caller cannot reach at all answers `404`, not `403`, so absence and denial are indistinguishable to a caller who was never entitled to tell them apart.", + "description": "The caller lacks the rights this operation requires. Where the cause is one a caller can act on, `error.details.code` names it, drawn from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\nA resource in a workspace the caller cannot reach at all answers `404`, not `403`, so absence and denial are indistinguishable to a caller who was never entitled to tell them apart.", "content": { "application/json": { "schema": { @@ -2084,16 +2084,6 @@ } } }, - "Gone": { - "description": "The requested generated resource has expired.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - } - } - } - }, "PayloadTooLarge": { "description": "The request, or a resource collection it must materialize, exceeds the allowed size. Besides an oversized request body, this covers a generated artifact that renders past the download ceiling and a workspace folder tree too large to load in full.", "content": { @@ -2140,7 +2130,7 @@ } }, "ClientClosedRequest": { - "description": "The client closed the connection before the response was produced.", + "description": "The client closed the connection before the response was produced. The response is written to a connection that is already gone, so the caller that caused it never reads it; it is documented only on operations where an abort can leave work running. There, `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", "content": { "application/json": { "schema": { diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index f25b32e5dbb..1f3a37f4512 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -218,9 +218,9 @@ "description": "Sort direction by execution start time. This operation deviates from the v2 `sortBy` + `sortOrder` convention: logs are sortable only by start time, so the direction is carried by this single `order` param and `sortBy`/`sortOrder` are not accepted.", "schema": { "default": "desc", + "description": "Sort direction by execution start time. This operation deviates from the v2 `sortBy` + `sortOrder` convention: logs are sortable only by start time, so the direction is carried by this single `order` param and `sortBy`/`sortOrder` are not accepted.", "type": "string", - "enum": ["desc", "asc"], - "description": "Sort direction by execution start time. This operation deviates from the v2 `sortBy` + `sortOrder` convention: logs are sortable only by start time, so the direction is carried by this single `order` param and `sortBy`/`sortOrder` are not accepted." + "enum": ["asc", "desc"] } }, { @@ -449,7 +449,7 @@ } }, "Forbidden": { - "description": "The caller lacks the rights this operation requires. Where the cause is one a caller can act on, `error.details.code` names it, drawn from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\nA resource in a workspace the caller cannot reach at all answers `404`, not `403`, so absence and denial are indistinguishable to a caller who was never entitled to tell them apart.", + "description": "The caller lacks the rights this operation requires. Where the cause is one a caller can act on, `error.details.code` names it, drawn from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\nA resource in a workspace the caller cannot reach at all answers `404`, not `403`, so absence and denial are indistinguishable to a caller who was never entitled to tell them apart.", "content": { "application/json": { "schema": { @@ -493,16 +493,6 @@ } } }, - "Gone": { - "description": "The requested generated resource has expired.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - } - } - } - }, "PayloadTooLarge": { "description": "The request, or a resource collection it must materialize, exceeds the allowed size. Besides an oversized request body, this covers a generated artifact that renders past the download ceiling and a workspace folder tree too large to load in full.", "content": { @@ -549,7 +539,7 @@ } }, "ClientClosedRequest": { - "description": "The client closed the connection before the response was produced.", + "description": "The client closed the connection before the response was produced. The response is written to a connection that is already gone, so the caller that caused it never reads it; it is documented only on operations where an abort can leave work running. There, `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", "content": { "application/json": { "schema": { diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index a1186dc1054..bca265fea96 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -2102,7 +2102,7 @@ } }, "Forbidden": { - "description": "The caller lacks the rights this operation requires. Where the cause is one a caller can act on, `error.details.code` names it, drawn from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\nA resource in a workspace the caller cannot reach at all answers `404`, not `403`, so absence and denial are indistinguishable to a caller who was never entitled to tell them apart.", + "description": "The caller lacks the rights this operation requires. Where the cause is one a caller can act on, `error.details.code` names it, drawn from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\nA resource in a workspace the caller cannot reach at all answers `404`, not `403`, so absence and denial are indistinguishable to a caller who was never entitled to tell them apart.", "content": { "application/json": { "schema": { @@ -2146,16 +2146,6 @@ } } }, - "Gone": { - "description": "The requested generated resource has expired.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - } - } - } - }, "PayloadTooLarge": { "description": "The request, or a resource collection it must materialize, exceeds the allowed size. Besides an oversized request body, this covers a generated artifact that renders past the download ceiling and a workspace folder tree too large to load in full.", "content": { @@ -2202,7 +2192,7 @@ } }, "ClientClosedRequest": { - "description": "The client closed the connection before the response was produced.", + "description": "The client closed the connection before the response was produced. The response is written to a connection that is already gone, so the caller that caused it never reads it; it is documented only on operations where an abort can leave work running. There, `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", "content": { "application/json": { "schema": { diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 809446dc7b9..feb39bf3138 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -3752,7 +3752,7 @@ } }, "Forbidden": { - "description": "The caller lacks the rights this operation requires. Where the cause is one a caller can act on, `error.details.code` names it, drawn from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\nA resource in a workspace the caller cannot reach at all answers `404`, not `403`, so absence and denial are indistinguishable to a caller who was never entitled to tell them apart.", + "description": "The caller lacks the rights this operation requires. Where the cause is one a caller can act on, `error.details.code` names it, drawn from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\nA resource in a workspace the caller cannot reach at all answers `404`, not `403`, so absence and denial are indistinguishable to a caller who was never entitled to tell them apart.", "content": { "application/json": { "schema": { @@ -3796,16 +3796,6 @@ } } }, - "Gone": { - "description": "The requested generated resource has expired.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - } - } - } - }, "PayloadTooLarge": { "description": "The request, or a resource collection it must materialize, exceeds the allowed size. Besides an oversized request body, this covers a generated artifact that renders past the download ceiling and a workspace folder tree too large to load in full.", "content": { @@ -3852,7 +3842,7 @@ } }, "ClientClosedRequest": { - "description": "The client closed the connection before the response was produced.", + "description": "The client closed the connection before the response was produced. The response is written to a connection that is already gone, so the caller that caused it never reads it; it is documented only on operations where an abort can leave work running. There, `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", "content": { "application/json": { "schema": { diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index e71582276f2..203857f5e3e 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -2134,7 +2134,7 @@ } }, "Forbidden": { - "description": "The caller lacks the rights this operation requires. Where the cause is one a caller can act on, `error.details.code` names it, drawn from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\nA resource in a workspace the caller cannot reach at all answers `404`, not `403`, so absence and denial are indistinguishable to a caller who was never entitled to tell them apart.", + "description": "The caller lacks the rights this operation requires. Where the cause is one a caller can act on, `error.details.code` names it, drawn from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\nA resource in a workspace the caller cannot reach at all answers `404`, not `403`, so absence and denial are indistinguishable to a caller who was never entitled to tell them apart.", "content": { "application/json": { "schema": { @@ -2178,16 +2178,6 @@ } } }, - "Gone": { - "description": "The requested generated resource has expired.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - } - } - } - }, "PayloadTooLarge": { "description": "The request, or a resource collection it must materialize, exceeds the allowed size. Besides an oversized request body, this covers a generated artifact that renders past the download ceiling and a workspace folder tree too large to load in full.", "content": { @@ -2234,7 +2224,7 @@ } }, "ClientClosedRequest": { - "description": "The client closed the connection before the response was produced.", + "description": "The client closed the connection before the response was produced. The response is written to a connection that is already gone, so the caller that caused it never reads it; it is documented only on operations where an abort can leave work running. There, `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", "content": { "application/json": { "schema": { diff --git a/apps/sim/app/api/v2/billing/logs/route.ts b/apps/sim/app/api/v2/billing/logs/route.ts index 03d02a8d715..65c6692adf5 100644 --- a/apps/sim/app/api/v2/billing/logs/route.ts +++ b/apps/sim/app/api/v2/billing/logs/route.ts @@ -1,10 +1,6 @@ import { v2ListBillingLogsContract } from '@/lib/api/contracts/v2/billing' -import { - defineV2JsonRoute, - v2ApiKeyAuth, - v2OrchestrationErrorPolicy, - v2RateLimits, -} from '@/lib/api/server/routes' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2BillingErrorPolicies } from '@/lib/billing/api/route-policies' import { listBillingLogs } from '@/lib/billing/application/list-billing-logs' import { billingOperations } from '@/lib/billing/application/operations' import { toBillingUsageLogSource, toInternalUsageLogSources } from '@/lib/billing/usage-sources' @@ -19,7 +15,7 @@ export const GET = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: billingOperations.listLogs, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2OrchestrationErrorPolicy, + errorPolicy: v2BillingErrorPolicies.concealWorkspaceAuthorization, mapInput: ({ query }) => { const dateRange = resolveDateRange(query.period, query.startDate, query.endDate) return { diff --git a/apps/sim/app/api/v2/billing/status/route.test.ts b/apps/sim/app/api/v2/billing/status/route.test.ts index d7ccd3d07e4..bc82837a17b 100644 --- a/apps/sim/app/api/v2/billing/status/route.test.ts +++ b/apps/sim/app/api/v2/billing/status/route.test.ts @@ -24,7 +24,10 @@ vi.mock('@/lib/billing/application/get-billing-status', () => ({ getBillingStatus: { operation: { id: 'billing.status.read' }, execute: mocks.execute }, })) -import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + PersonalApiKeysDisabledError, + WorkspaceApiKeyScopeAuthorizationError, +} from '@/lib/core/application' import { GET } from '@/app/api/v2/billing/status/route' const auth = { @@ -93,17 +96,34 @@ describe('GET /api/v2/billing/status', () => { } ) - it('projects typed workspace-policy errors', async () => { - mocks.execute.mockRejectedValueOnce( - new OrchestrationError('forbidden', 'API key is not authorized for this workspace') + it('names the cause of an actionable workspace-policy refusal', async () => { + mocks.execute.mockRejectedValueOnce(new PersonalApiKeysDisabledError()) + + const response = await GET( + new NextRequest('http://localhost:3000/api/v2/billing/status?workspaceId=workspace-1') ) + expect(response.status).toBe(403) + expect(await response.json()).toMatchObject({ + error: { code: 'FORBIDDEN', details: { code: 'PERSONAL_API_KEYS_DISABLED' } }, + }) + }) + + /** + * A workspace key naming another workspace must not learn that the workspace + * exists, so this refusal is answered exactly as an unknown workspace id is. + */ + it('conceals a cross-tenant workspace-key refusal as a not-found workspace', async () => { + mocks.execute.mockRejectedValueOnce(new WorkspaceApiKeyScopeAuthorizationError()) + const response = await GET( new NextRequest('http://localhost:3000/api/v2/billing/status?workspaceId=workspace-2') ) - expect(response.status).toBe(403) - expect(await response.json()).toMatchObject({ error: { code: 'FORBIDDEN' } }) + expect(response.status).toBe(404) + expect(await response.json()).toMatchObject({ + error: { code: 'NOT_FOUND', message: 'Workspace not found' }, + }) }) it('hides unknown billing infrastructure errors', async () => { diff --git a/apps/sim/app/api/v2/billing/status/route.ts b/apps/sim/app/api/v2/billing/status/route.ts index b5a7fd95b5f..50c7e8bd95d 100644 --- a/apps/sim/app/api/v2/billing/status/route.ts +++ b/apps/sim/app/api/v2/billing/status/route.ts @@ -1,10 +1,6 @@ import { v2GetBillingStatusContract } from '@/lib/api/contracts/v2/billing' -import { - defineV2JsonRoute, - v2ApiKeyAuth, - v2OrchestrationErrorPolicy, - v2RateLimits, -} from '@/lib/api/server/routes' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2BillingErrorPolicies } from '@/lib/billing/api/route-policies' import { getBillingStatus } from '@/lib/billing/application/get-billing-status' import { billingOperations } from '@/lib/billing/application/operations' @@ -17,7 +13,7 @@ export const GET = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: billingOperations.readStatus, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2OrchestrationErrorPolicy, + errorPolicy: v2BillingErrorPolicies.concealWorkspaceAuthorization, mapInput: ({ query }) => ({ workspaceId: query.workspaceId }), useCase: getBillingStatus, present: (data) => ({ data }), diff --git a/apps/sim/app/api/v2/files/[fileId]/route.ts b/apps/sim/app/api/v2/files/[fileId]/route.ts index 0a7bbc896bc..648385c91ef 100644 --- a/apps/sim/app/api/v2/files/[fileId]/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/route.ts @@ -28,10 +28,19 @@ export const revalidate = 0 * Lookups are workspace-scoped (IDOR-safe): a file in another workspace 404s. * * A generated doc whose artifact is still compiling renders `CONFLICT`; retry. + * + * Downloading is not a safe read: it records a `FILE_DOWNLOADED` audit event and + * pulls the bytes out of object storage. Next aliases `HEAD` onto `GET`, and RFC + * 9110 §9.2.1 defines `HEAD` as safe, so this route declares itself not + * head-safe — a `HEAD` is authenticated and rate-limited, then answered bodiless + * without auditing or fetching. Without that, an uptime monitor or link checker + * walking the documented URL list would fabricate a download event on every + * probe, for a download that never happened. */ export const GET = defineV2BinaryRoute({ contract: v2DownloadFileContract, auth: v2ApiKeyAuth, + headSafe: false, operation: fileOperations.download, rateLimit: v2RateLimits.publicApi, errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, diff --git a/apps/sim/app/api/v2/tables/route.test.ts b/apps/sim/app/api/v2/tables/route.test.ts index e27af471956..8c970c86700 100644 --- a/apps/sim/app/api/v2/tables/route.test.ts +++ b/apps/sim/app/api/v2/tables/route.test.ts @@ -36,6 +36,7 @@ vi.mock('@/lib/table/billing', () => ({ getMaxRowsPerTable: mocks.getMaxRowsPerTable, })) +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { GET, POST } from '@/app/api/v2/tables/route' const WORKSPACE_ID = 'workspace-1' @@ -204,6 +205,37 @@ describe('/api/v2/tables', () => { ) }) + /** + * A quota ceiling and a permission refusal share the `403` status but demand + * opposite caller behaviour — delete something and retry, versus stop and + * escalate — so the ceiling names itself rather than leaving a client to + * string-match the message. + */ + it('names a workspace table-quota refusal in error.details.code', async () => { + mocks.create.mockRejectedValueOnce( + new ForbiddenOperationError( + 'WORKSPACE_RESOURCE_LIMIT_REACHED', + 'Workspace has reached maximum table limit (100)' + ) + ) + + const request = new NextRequest('http://localhost:3000/api/v2/tables', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + name: 'Contacts', + schema: { columns: [{ name: 'Name', type: 'string', required: true }] }, + }), + }) + const response = await POST(request) + + expect(response.status).toBe(403) + expect(await response.json()).toMatchObject({ + error: { code: 'FORBIDDEN', details: { code: 'WORKSPACE_RESOURCE_LIMIT_REACHED' } }, + }) + }) + it('rejects an unrecognized key in a table column before calling the use case', async () => { const request = new NextRequest('http://localhost:3000/api/v2/tables', { method: 'POST', diff --git a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts index 9a5c2fcb5bb..06f92b4e5ce 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts @@ -72,17 +72,18 @@ const PAGED_LISTS = [ * Lists that accept neither param and always return `nextCursor: null`, because * the set is small and bounded per workspace, per table, or per server. * - * Every folder list is capped where the tree is loaded - * (`MAX_*_FOLDERS_PER_WORKSPACE`), and one MCP server's tool inventory is capped - * by tool discovery itself (`LIST_TOOLS_MAX_TOOLS` / `LIST_TOOLS_MAX_BYTES`) no - * matter what the upstream server reports — bounded by construction rather than - * by a caller's `limit`. The MCP *server* list is not: nothing caps how many - * servers a workspace registers, which is why it is paged. - * Every remaining entry but the MCP server list and the knowledge tag list is a - * *folder* list, and a folder tree is already capped where it is loaded - * (`MAX_*_FOLDERS_PER_WORKSPACE`) — bounded by construction rather than by a - * caller's `limit`. The knowledge tag list is bounded the same way: a knowledge - * base has a fixed number of tag slots, so its vocabulary cannot grow past them. + * Every entry is bounded by construction rather than by a caller's `limit`: + * + * - The four folder lists are capped where the tree is loaded + * (`MAX_*_FOLDERS_PER_WORKSPACE`). + * - One MCP server's tool inventory is capped by tool discovery itself + * (`LIST_TOOLS_MAX_TOOLS` / `LIST_TOOLS_MAX_BYTES`), whatever the upstream + * server reports. The MCP *server* list is not bounded that way — nothing caps + * how many servers a workspace registers — which is why it is paged and does + * not appear here. + * - A knowledge base has a fixed number of tag slots, so its tag vocabulary + * cannot grow past them. + * - A table's saved views and its dispatchable groups are capped per table. */ const FULL_SET_LISTS = [ 'GET /api/v2/files/folders', diff --git a/apps/sim/lib/api/contracts/v2/logs.ts b/apps/sim/lib/api/contracts/v2/logs.ts index 97ab3e76e0d..f5baa96cd24 100644 --- a/apps/sim/lib/api/contracts/v2/logs.ts +++ b/apps/sim/lib/api/contracts/v2/logs.ts @@ -9,6 +9,7 @@ import { v2FolderPathInputSchema, v2FolderPathSchema, v2PaginationFields, + v2RunOrderSchema, v2RunWindowBoundSchema, v2TimestampSchema, } from '@/lib/api/contracts/v2/shared' @@ -247,14 +248,11 @@ export const v2ListLogsQuerySchema = v1ListLogsQuerySchema * would break every caller, while accepting `sortOrder` as an alias would * add a second spelling of one thing with undefined precedence when both * arrive — so the split is documented rather than papered over. + * + * Shared with `GET /workflows/{id}/runs` so the two spell the enum the same + * way in the generated specs. */ - order: z - .enum(['desc', 'asc']) - .describe( - 'Sort direction by execution start time. This operation deviates from the v2 `sortBy` + `sortOrder` convention: logs are sortable only by start time, so the direction is carried by this single `order` param and `sortBy`/`sortOrder` are not accepted.' - ) - .optional() - .default('desc'), + order: v2RunOrderSchema('execution'), folderPaths: z .string() .describe('Comma-separated workflow folder paths to include.') diff --git a/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts b/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts index 5a875a657f8..1c008ea68db 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts @@ -330,7 +330,7 @@ const routes = [ operationId: 'downloadFile', summary: 'Download File', 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.', + '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. Downloading is recorded as an audit event, so it is not a safe read: a `HEAD` request is answered with an empty `200` without fetching the bytes or recording anything, and reports only that the endpoint exists and the caller is authorized.', errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], success: { description: 'The file bytes.', diff --git a/apps/sim/lib/api/contracts/v2/openapi/shared.ts b/apps/sim/lib/api/contracts/v2/openapi/shared.ts index a2de98da422..08fb707f697 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/shared.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/shared.ts @@ -34,13 +34,16 @@ export const WORKSPACE_ERRORS = [ * 403 a caller can do something about names its cause in `error.details.code`. * * The wording is deliberately "where the cause is one a caller can act on" - * rather than "always". Nine domain refusals still throw a bare - * `OrchestrationError('forbidden', …)` and reach the wire without a code — - * `GET /api/v2/billing/status` with a personal key against a workspace that - * disallows them is one. Reparenting those onto `ForbiddenOperationError` is - * worth doing, but one of them is a cross-tenant refusal that belongs in the - * codeless class and would change its status, so it is a deliberate change - * rather than a sweep. Until then this description must not over-claim. + * rather than "always", and it must stay that way. The billing, secret, table- + * quota, credential-list, and public-sharing refusals have been reparented onto + * `ForbiddenOperationError` (the one cross-tenant refusal among them became a + * concealed `404` instead, which is a status change rather than a code), but a + * handful of domain refusals still throw a bare + * `OrchestrationError('forbidden', …)` and reach the wire with no code — the + * knowledge-base file-ownership guard deliberately, others because nothing in + * the closed set fits them yet. Do not restate this as "every 403 names its + * cause": the audit that produced these codes found the claim false, and it will + * be false again the moment a domain adds a refusal without one. */ const FORBIDDEN_DESCRIPTION = [ 'The caller lacks the rights this operation requires. Where the cause is one a caller can act on, `error.details.code` names it, drawn from a closed set:', @@ -66,7 +69,6 @@ export const ERROR_RESPONSES = { 'The run cannot be started. Two causes share this status, distinguished by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already associated with a different request, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has already reached the maximum workflow-to-workflow call depth.', headers: ['X-Run-Id'], }, - Gone: { status: 410, description: 'The requested generated resource has expired.' }, PayloadTooLarge: { status: 413, description: @@ -82,9 +84,28 @@ export const ERROR_RESPONSES = { description: 'The caller exceeded the request rate limit.', headers: ['Retry-After'], }, + /** + * Published on exactly one operation, and deliberately not on the rest. + * + * Every v2 JSON route can *emit* a 499: `defineV2JsonRoute` renders an + * aborted request as `CLIENT_CLOSED_REQUEST`. But a 499 is written to a socket + * the caller has already closed, so no conforming client ever reads it — it is + * an observability record for Sim's own logs and its proxies, not a response + * an SDK can branch on. Publishing it on every operation would add a branch to + * every generated client that can never be taken. + * + * `POST /workflows/{id}/execute` is the exception because there an abort + * leaves *residue*: the run may keep going and bill, so the response carries + * `error.details.runId` for the caller to reconcile against once it reconnects. + * That is caller-actionable information about state that outlives the + * connection, which is what makes it worth documenting. Anywhere else an abort + * leaves nothing behind to reconcile. Publish a 499 on a new operation only + * when the same is true of it. + */ ClientClosedRequest: { status: 499, - description: 'The client closed the connection before the response was produced.', + description: + 'The client closed the connection before the response was produced. The response is written to a connection that is already gone, so the caller that caused it never reads it; it is documented only on operations where an abort can leave work running. There, `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.', }, InternalError: { status: 500, description: 'An unexpected server error occurred.' }, ServiceUnavailable: { @@ -165,8 +186,10 @@ export const FOLDER_TREE_TOO_LARGE = * in one page. * * Every v2 list returns `{ data, nextCursor }`, so a caller cannot tell a - * single-page list from a paged one by shape alone. Saying so once keeps the six - * such operations from drifting into six paraphrases of the same promise. + * single-page list from a paged one by shape alone. Saying so once keeps the + * eight such operations from drifting into eight paraphrases of the same + * promise. The authoritative membership is pinned in + * `contracts/v2/__tests__/list-pagination.test.ts` as `FULL_SET_LISTS`. */ export const FULL_SET_LIST = 'The bounded set is returned in one page with `nextCursor` always null; there is no second page to fetch.' diff --git a/apps/sim/lib/api/contracts/v2/shared.ts b/apps/sim/lib/api/contracts/v2/shared.ts index 17a9e83073e..a1ce99cdbb7 100644 --- a/apps/sim/lib/api/contracts/v2/shared.ts +++ b/apps/sim/lib/api/contracts/v2/shared.ts @@ -83,9 +83,14 @@ import { FolderPathError, parseFolderPath, requireNonRootFolderPath } from '@/li * response — its OpenAPI description says so explicitly, so a caller never * writes a pagination loop that can only ever run once. * - * Every list whose result set grows with workspace content is now paged. What - * remains full-set is the folder lists, whose trees are already capped where - * they load, plus `GET /mcp-servers`. + * Every list whose result set grows with workspace content is now paged — + * including `GET /mcp-servers`, since nothing caps how many servers a workspace + * registers. What remains full-set is bounded by construction rather than by a + * caller's `limit`: the four folder lists, whose trees are capped where they + * load; `GET /knowledge/{id}/tags`, capped by the fixed tag-slot table; + * `GET /mcp-servers/{id}/tools`, capped by tool discovery itself; and + * `GET /tables/{tableId}/views` and `GET /tables/{tableId}/groups`, capped per + * table. * * Adding `limit`/`cursor` to a full-set list is additive, but giving it a * *default* `limit` truncates callers reading the whole set today, so it is a @@ -94,13 +99,24 @@ import { FolderPathError, parseFolderPath, requireNonRootFolderPath } from '@/li * costs nothing. Once v2 is generally available, moving a shipped full-set list * to a defaulted page size needs a version bump. * - * Both cursor schemes are opaque base64-JSON from `app/api/v2/lib/response.ts`, - * and which one a list uses is decided by what its read can express rather than - * by preference: a keyset (`encodeSortedCursor`) wherever the page comes from - * one ordered SQL read, and an offset (`encodeCursor({ offset })`) only where it - * cannot — `GET /skills`, which merges the static builtin registry into the DB - * rows and re-sorts in JS, and `GET /knowledge/{id}/documents`, whose underlying - * query is limit/offset. Prefer the keyset; an offset needs that kind of reason. + * Three cursor schemes are in use. Two are shared codecs in + * `app/api/v2/lib/response.ts`, and which of them a list uses is decided by what + * its read can express rather than by preference: a keyset + * (`encodeSortedCursor`) wherever the page comes from one ordered SQL read, and + * an offset (`encodeOffsetCursor`) only where it cannot — `GET /skills`, which + * merges the static builtin registry into the DB rows and re-sorts in JS, and + * `GET /knowledge/{id}/documents`, whose underlying query is limit/offset. + * Prefer the keyset; an offset needs that kind of reason. + * + * The third is per-domain: a list whose read predates the shared codecs, or + * whose page boundary is not expressible as one, mints its own — a bare + * `encodeCursor({ version })` on `GET /workflows/{id}/versions` and + * `encodeCursor({ email })` on the workspace member list, the audit-log and run-log + * codecs in `lib/audit-logs/query.ts` and `lib/logs/list-logs.ts`, the table-row + * codec in `lib/table/rows/cursor.ts`, and a usage-event id passed straight + * through by `GET /billing/logs`. They are opaque to a caller in exactly the same + * way, but they do not get the shared codec's sort stamp, so a new list should + * reach for one of the two shared schemes rather than adding a fourth. * * ## Sort and the opaque cursor * @@ -304,6 +320,31 @@ export function v2RunWindowBoundSchema(field: 'startDate' | 'endDate') { .meta({ format: 'date-time' }) } +/** + * The single `order` param the two run-window reads take in place of + * `sortBy` + `sortOrder`, for the same reason they share + * {@link v2RunWindowBoundSchema}: `GET /logs` and `GET /workflows/{id}/runs` are + * sibling reads over the same runs, so a value that works on one must work on + * the other. + * + * Sharing it also keeps the *published* member order identical. Two hand-written + * `z.enum([...])` literals spelled the same set in opposite orders, which the + * generated specs faithfully reproduced — harmless to a parser, but it reads as + * two APIs rather than one, and a caller comparing the two pages has no way to + * tell an ordering accident from a meaningful difference. The order is + * {@link LIST_SORT_ORDERS}, the same one `sortOrder` publishes everywhere else. + */ +export function v2RunOrderSchema(subject: 'execution' | 'run') { + const noun = subject === 'execution' ? 'logs' : 'runs' + return z + .enum(LIST_SORT_ORDERS) + .optional() + .default('desc') + .describe( + `Sort direction by ${subject} start time. This operation deviates from the v2 \`sortBy\` + \`sortOrder\` convention: ${noun} are sortable only by start time, so the direction is carried by this single \`order\` param and \`sortBy\`/\`sortOrder\` are not accepted.` + ) +} + export const v2SearchSchema = z .string() .trim() diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 167e8244e2a..399c552e327 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -27,6 +27,7 @@ import { v2ListFoldersQuerySchema, v2PaginationFields, v2RelocateFolderBodySchema, + v2RunOrderSchema, v2RunWindowBoundSchema, v2SearchSchema, v2SortFields, @@ -1053,14 +1054,11 @@ export const v2ListWorkflowRunsQuerySchema = z * aliasing the param would require a route change and would introduce a * second spelling with undefined precedence when both are sent — so the * deviation is documented rather than papered over. + * + * Shared with `GET /logs` so the two spell the enum the same way in the + * generated specs. */ - order: z - .enum(['asc', 'desc']) - .optional() - .default('desc') - .describe( - 'Sort direction by run start time. This operation deviates from the v2 `sortBy` + `sortOrder` convention: runs are sortable only by start time, so the direction is carried by this single `order` param and `sortBy`/`sortOrder` are not accepted.' - ), + order: v2RunOrderSchema('run'), }) .strict() .refine( diff --git a/apps/sim/lib/api/server/routes/v2-binary-route.test.ts b/apps/sim/lib/api/server/routes/v2-binary-route.test.ts new file mode 100644 index 00000000000..99b75d32068 --- /dev/null +++ b/apps/sim/lib/api/server/routes/v2-binary-route.test.ts @@ -0,0 +1,127 @@ +/** + * @vitest-environment node + */ +import type { PersonalApiKeyPrincipal } from '@sim/auth/principal' +import { + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts' +import type { OperationUseCase } from '@/lib/core/application' + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) + +import { v2ApiKeyAuth, v2OrchestrationErrorPolicy, v2RateLimits } from '@/lib/api/server/routes' +import type { V2ApiKeyAuthContext } from '@/lib/api/server/routes/v2-api-key-auth' +import { defineV2BinaryRoute } from '@/lib/api/server/routes/v2-binary-route' + +const operation = { id: 'widgets.download' } as const +const principal: PersonalApiKeyPrincipal = { + kind: 'personal_api_key', + userId: 'user-1', + keyId: 'key-1', +} +const auth = { + principal, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'], + rateLimitSubscription: null, + keyType: 'personal', +} satisfies V2ApiKeyAuthContext +const resetAt = new Date('2026-08-08T20:00:00.000Z') +const allowedRate = { allowed: true, remaining: 99, resetAt } + +const contract = defineRouteContract({ + method: 'GET', + path: '/api/v2/widgets/[widgetId]', + params: z.object({ widgetId: z.string() }), + response: { mode: 'binary' }, +}) + +interface Input { + widgetId: string +} + +interface Result { + bytes: string +} + +function createHandler(options: { headSafe?: boolean; execute: () => Promise }) { + const useCase: OperationUseCase = { + operation, + execute: options.execute, + } + return defineV2BinaryRoute({ + contract, + auth: v2ApiKeyAuth, + operation, + headSafe: options.headSafe, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params }) => params, + useCase, + present: (result) => ({ body: result.bytes, contentType: 'application/octet-stream' }), + }) +} + +function request(method: 'GET' | 'HEAD'): NextRequest { + return new NextRequest('http://localhost/api/v2/widgets/widget-1', { + method, + headers: { 'x-api-key': 'secret' }, + }) +} + +const context = { params: Promise.resolve({ widgetId: 'widget-1' }) } + +describe('defineV2BinaryRoute', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue({ allowed: true, remaining: 599, resetAt }) + v2RouteMocks.operationRate.mockResolvedValue(allowedRate) + }) + + it('streams the descriptor on GET', async () => { + const execute = vi.fn(async () => ({ bytes: 'payload' })) + const response = await createHandler({ execute })(request('GET'), context) + + expect(response.status).toBe(200) + expect(await response.text()).toBe('payload') + expect(execute).toHaveBeenCalledOnce() + }) + + it('runs the use case for a HEAD when the route is head-safe', async () => { + const execute = vi.fn(async () => ({ bytes: 'payload' })) + const response = await createHandler({ execute })(request('HEAD'), context) + + expect(response.status).toBe(200) + expect(execute).toHaveBeenCalledOnce() + }) + + it('answers a HEAD bodiless without executing when the route is not head-safe', async () => { + const execute = vi.fn(async () => ({ bytes: 'payload' })) + const response = await createHandler({ headSafe: false, execute })(request('HEAD'), context) + + expect(response.status).toBe(200) + expect(await response.text()).toBe('') + expect(execute).not.toHaveBeenCalled() + }) + + it('still authenticates and rate-limits a HEAD on a route that is not head-safe', async () => { + const execute = vi.fn(async () => ({ bytes: 'payload' })) + const handler = createHandler({ headSafe: false, execute }) + + await handler(request('HEAD'), context) + + expect(v2RouteMocks.authenticate).toHaveBeenCalledOnce() + expect(v2RouteMocks.operationRate).toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/api/server/routes/v2-binary-route.ts b/apps/sim/lib/api/server/routes/v2-binary-route.ts index 6709d65e48d..b9d2aa6ac10 100644 --- a/apps/sim/lib/api/server/routes/v2-binary-route.ts +++ b/apps/sim/lib/api/server/routes/v2-binary-route.ts @@ -20,7 +20,7 @@ import { import { parseRequest } from '@/lib/api/server/validation' import type { ApplicationOperation } from '@/lib/core/application' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { v2Error, v2HttpError, v2ValidationError } from '@/app/api/v2/lib/response' +import { v2Error, v2HeadNoEffect, v2HttpError, v2ValidationError } from '@/app/api/v2/lib/response' interface V2BinaryRouteOptions< C extends BinaryApiRouteContract, @@ -31,6 +31,19 @@ interface V2BinaryRouteOptions< auth: typeof v2ApiKeyAuth rateLimit: V2RateLimitPolicy errorPolicy: V2ErrorPolicy + /** + * Whether this route's `GET` is safe enough for Next's `HEAD`→`GET` aliasing + * to run it. Defaults to `true`, which is correct for a read. + * + * Set `false` when the `GET` opens an outbound connection or writes a row. + * Such a route still authenticates and rate-limits a `HEAD`, then answers a + * bodiless 200 without executing the use case — see {@link v2HeadNoEffect}. + * + * A binary `GET` is a download, and a download is the archetypal read that + * records that it happened, so this matters here at least as much as on the + * JSON builder it mirrors. + */ + headSafe?: boolean } export function defineV2BinaryRoute< @@ -61,6 +74,10 @@ export function defineV2BinaryRoute< ) if (!admission.success) return admission.response + if (request.method === 'HEAD' && options.headSafe === false) { + return v2HeadNoEffect() + } + const parsed = await parseRequest(options.contract, request, context ?? {}, { ...V2_PARSE_DEFAULTS, validationErrorResponse: v2ValidationError, diff --git a/apps/sim/lib/billing/api/route-policies.ts b/apps/sim/lib/billing/api/route-policies.ts new file mode 100644 index 00000000000..1ccecdfbbae --- /dev/null +++ b/apps/sim/lib/billing/api/route-policies.ts @@ -0,0 +1,13 @@ +import { createV2ResourceConcealmentPolicy } from '@/lib/api/server/routes' + +/** + * Billing reads are workspace-scoped, so a caller naming a workspace it cannot + * reach must not be able to tell that refusal apart from a workspace that does + * not exist. Both answer `404 "Workspace not found"`, which is the message the + * unknown-workspace path already uses. + */ +export const v2BillingErrorPolicies = { + concealWorkspaceAuthorization: createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Workspace not found', + }), +} as const diff --git a/apps/sim/lib/billing/application/authorized-billing-read-use-case.ts b/apps/sim/lib/billing/application/authorized-billing-read-use-case.ts index 2377044a181..0d4cc811e62 100644 --- a/apps/sim/lib/billing/application/authorized-billing-read-use-case.ts +++ b/apps/sim/lib/billing/application/authorized-billing-read-use-case.ts @@ -8,6 +8,13 @@ import type { BillingReadPrincipal, } from '@/lib/billing/application/operations' import type { OperationUseCase } from '@/lib/core/application' +import { + InsufficientWorkspacePermissionsError, + NoWorkspaceAccessError, + PersonalApiKeysDisabledError, + PrincipalKindAuthorizationError, + WorkspaceApiKeyScopeAuthorizationError, +} from '@/lib/core/application/workspace-authorization' import { OrchestrationError } from '@/lib/core/orchestration/types' import { type ActiveWorkspaceApplicationContext, @@ -36,10 +43,7 @@ function requireBillingReadPrincipal( operation: BillingReadOperation ): asserts principal is BillingReadPrincipal { if (!operation.principalKinds.some((kind) => kind === principal.kind)) { - throw new OrchestrationError( - 'forbidden', - `Principal kind ${principal.kind} cannot perform operation ${operation.id}` - ) + throw new PrincipalKindAuthorizationError(principal.kind, operation.id) } } @@ -50,7 +54,14 @@ async function resolveBillingReadScope( ): Promise { if (principal.kind === 'workspace_api_key') { if (requestedWorkspaceId && requestedWorkspaceId !== principal.workspaceId) { - throw new OrchestrationError('forbidden', 'API key is not authorized for this workspace') + /** + * A cross-tenant refusal, so it must not explain itself: naming the cause + * would confirm the named workspace exists to a key that was never scoped + * to it. The billing routes conceal this class as a `404`, which is also + * the answer a workspace id that does not exist gets, so the two are + * indistinguishable — see `createV2ResourceConcealmentPolicy`. + */ + throw new WorkspaceApiKeyScopeAuthorizationError() } } else if (!requestedWorkspaceId) { return { kind: 'account', userId: principal.userId } @@ -65,15 +76,16 @@ async function resolveBillingReadScope( if (principal.kind === 'personal_api_key') { if (!workspace.allowPersonalApiKeys) { - throw new OrchestrationError('forbidden', 'Personal API keys are disabled for this workspace') + throw new PersonalApiKeysDisabledError() } const permission = await resolveEffectiveWorkspacePermission( principal.userId, workspace.workspaceId, workspace.workspaceOrganizationId ) + if (permission === null) throw new NoWorkspaceAccessError() if (!permissionSatisfies(permission, operation.workspaceMinimumRole)) { - throw new OrchestrationError('forbidden', 'Access denied') + throw new InsufficientWorkspacePermissionsError() } } diff --git a/apps/sim/lib/core/application/forbidden.ts b/apps/sim/lib/core/application/forbidden.ts index 9c99901591d..5ac8b5c55cc 100644 --- a/apps/sim/lib/core/application/forbidden.ts +++ b/apps/sim/lib/core/application/forbidden.ts @@ -42,6 +42,12 @@ export const FORBIDDEN_DETAIL_CODES = [ 'AUDIT_LOGS_DISABLED', /** The caller holds workspace write but is not an editor of this skill. */ 'SKILL_EDITOR_ACCESS_REQUIRED', + /** The caller holds workspace write but is not an admin of this secret. */ + 'SECRET_ADMIN_ACCESS_REQUIRED', + /** The workspace is already at its ceiling for this kind of resource. */ + 'WORKSPACE_RESOURCE_LIMIT_REACHED', + /** The workspace's organization does not permit public sharing. */ + 'PUBLIC_SHARING_NOT_ALLOWED', /** The MCP server URL is outside the allowed domains or resolves internally. */ 'MCP_SERVER_URL_NOT_ALLOWED', ] as const @@ -71,6 +77,12 @@ export const FORBIDDEN_DETAIL_CODE_DESCRIPTIONS: Record { authorizeWorkspaceOperation(workspaceKeyPrincipal, workspaceKeyOperation, context) ).rejects.toBeInstanceOf(WorkspaceApiKeyScopeAuthorizationError) }) + + /** + * An operation that denies workspace keys necessarily omits + * `workspace_api_key` from `principalKinds`, so the kind guard is the only + * place this refusal can be raised. Reported as a generic kind refusal, the + * published `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` code is unmatchable by any + * client. + */ + it('names a workspace key refused by an operation that denies workspace keys', async () => { + await expect( + authorizeWorkspaceOperation( + { ...workspaceKeyPrincipal, workspaceId: context.workspaceId }, + writeOperation, + context + ) + ).rejects.toBeInstanceOf(WorkspaceApiKeyAuthorizationError) + }) + + it('still reports another disallowed principal kind as a kind refusal', async () => { + await expect( + authorizeWorkspaceOperation(principal, workspaceKeyOperation, context) + ).rejects.toBeInstanceOf(PrincipalKindAuthorizationError) + }) }) diff --git a/apps/sim/lib/core/application/workspace-authorization.ts b/apps/sim/lib/core/application/workspace-authorization.ts index bc3a61be9f8..4014afb158f 100644 --- a/apps/sim/lib/core/application/workspace-authorization.ts +++ b/apps/sim/lib/core/application/workspace-authorization.ts @@ -109,6 +109,20 @@ export function requireAllowedWorkspacePrincipal( operation: O ): asserts principal is PrincipalForOperation { if (!operation.principalKinds.some((kind) => kind === principal.kind)) { + /** + * A workspace key refused because the operation does not delegate to one is + * the case {@link WorkspaceApiKeyAuthorizationError} exists to name, and the + * one the `WORKSPACE_API_KEY_DENIED` OpenAPI sentence promises. It has to be + * separated here rather than left to `authorizeWorkspaceOperation`: an + * operation that denies workspace keys also omits `workspace_api_key` from + * `principalKinds` — `defineWorkspaceOperation` enforces that the two agree + * — so this guard always fires first and the later branch can never see such + * a principal. Reported as the generic kind refusal, a client branching on + * the published `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` never matched. + */ + if (principal.kind === 'workspace_api_key' && operation.workspaceApiKey === 'deny') { + throw new WorkspaceApiKeyAuthorizationError() + } throw new PrincipalKindAuthorizationError(principal.kind, operation.id) } if (principal.kind !== 'delegated') return diff --git a/apps/sim/lib/credentials/application/list-workspace-credentials.ts b/apps/sim/lib/credentials/application/list-workspace-credentials.ts index 45f2eee5c67..643c24a2074 100644 --- a/apps/sim/lib/credentials/application/list-workspace-credentials.ts +++ b/apps/sim/lib/credentials/application/list-workspace-credentials.ts @@ -1,5 +1,6 @@ import type { CursorKey, ListSortOrder } from '@/lib/api/list-query' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { NoWorkspaceAccessError } from '@/lib/core/application/workspace-authorization' import { OrchestrationError } from '@/lib/core/orchestration/types' import { credentialOperations } from '@/lib/credentials/application/operations' import { @@ -61,7 +62,14 @@ export const listWorkspaceCredentials = defineAuthorizedWorkspaceUseCase({ const workspaceAccess = await checkWorkspaceAccess(context.workspaceId, principal.userId) if (!workspaceAccess.hasAccess) { - throw new OrchestrationError('forbidden', 'Access denied') + /** + * `hasAccess` is `permission !== null` — the same condition + * `requirePermission` classifies as no reach into the workspace at all — + * so it raises the canonical error rather than a bare `forbidden`. It + * stays codeless deliberately: this is the concealed cross-tenant class, + * not one a caller can act on. + */ + throw new NoWorkspaceAccessError() } const page = await listVisibleWorkspaceCredentials({ workspaceId: context.workspaceId, diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index da51bd325df..1c57c07fb4a 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -122,6 +122,13 @@ const logger = createLogger('DocumentService') * Thrown when a knowledge-base document's `fileUrl` references an internal * knowledge-base storage object not owned by the target knowledge base's workspace. * Routes map this to a 403. + * + * Deliberately carries no `details.code`. It belongs to the cross-tenant class + * the closed set in `lib/core/application/forbidden.ts` excludes: it fires + * identically for a key bound to another tenant and for a key bound to nothing + * at all, so the single fixed message is the whole of what a caller may learn, + * and a machine-readable name would only invite a client to read resource + * existence into it. */ export class KnowledgeBaseFileOwnershipError extends OrchestrationError { constructor(public readonly storageKey: string) { diff --git a/apps/sim/lib/secrets/application/use-cases.ts b/apps/sim/lib/secrets/application/use-cases.ts index 2fa1f1a5a07..5d8856629e7 100644 --- a/apps/sim/lib/secrets/application/use-cases.ts +++ b/apps/sim/lib/secrets/application/use-cases.ts @@ -2,6 +2,7 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import type { Principal } from '@sim/auth/principal' import type { CursorKey, ListSortOrder } from '@/lib/api/list-query' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { OrchestrationError } from '@/lib/core/orchestration/types' import { getWorkspaceEnvKeyAdminAccess } from '@/lib/credentials/environment' import { @@ -99,15 +100,18 @@ async function requireWorkspaceSecretMutationAccess(params: { if (keyAccess.knownKeys.has(params.name)) { if (!workspaceAccess.canAdmin && !keyAccess.adminKeys.has(params.name)) { - throw new OrchestrationError( - 'forbidden', + throw new ForbiddenOperationError( + 'SECRET_ADMIN_ACCESS_REQUIRED', 'Credential admin permission required for this secret' ) } return } if (!workspaceAccess.canWrite) { - throw new OrchestrationError('forbidden', 'Write permission required to set this secret') + throw new ForbiddenOperationError( + 'INSUFFICIENT_WORKSPACE_ROLE', + 'Write permission required to set this secret' + ) } } diff --git a/apps/sim/lib/table/billing.ts b/apps/sim/lib/table/billing.ts index 824b102d17b..c76e32197a3 100644 --- a/apps/sim/lib/table/billing.ts +++ b/apps/sim/lib/table/billing.ts @@ -188,6 +188,17 @@ function cacheLimits(workspaceId: string, limits: TablePlanLimits): void { * 400 with the real reason — the message used to have to carry a lowercase * `row limit` token for a substring match to find it, which made the wording * load-bearing. + * + * The 400 disagrees with the sibling ceiling on how many tables a workspace may + * hold, which answers 403 with `error.details.code` + * `WORKSPACE_RESOURCE_LIMIT_REACHED`. Two ceilings of the same kind reporting as + * different statuses is a real inconsistency, and 409 is arguably the right + * answer for both: the request is well formed and the caller is authorized, and + * the conflict is with the collection's current state, which the caller can + * clear. It is not changed here because this error is reachable from the + * internal surface as well, where the 400 is shipped and not behind the v2 + * flag — unifying the two is a deliberate cross-surface change, not part of a + * v2-only pass. */ export class TableRowLimitError extends OrchestrationError { constructor(readonly limit: number) { diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 7cb7e35c760..9fc18554370 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -28,6 +28,7 @@ import { textKey, timestampKey, } from '@/lib/api/list-query' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRestoreName } from '@/lib/core/utils/restore-name' import type { DbOrTx } from '@/lib/db/types' @@ -554,10 +555,24 @@ export async function createTable( ) if (Number(existingCount) >= maxTables) { - // A quota ceiling, not bad input — both create routes have always - // answered 403 for it. - throw new OrchestrationError( - 'forbidden', + /** + * A quota ceiling, not bad input — both create routes have always + * answered 403 for it. It names its cause so a client can tell a + * ceiling apart from a role or key-kind refusal: one is cleared by + * deleting a table, the other by changing who is calling. + * + * The status is left as it shipped, and it disagrees with its sibling: + * {@link TableRowLimitError} answers 400 for the row ceiling. Neither is + * obviously right — a capacity ceiling is arguably a 409, since the + * request is well-formed, the caller is authorized, and the conflict is + * with the collection's current state, which the caller can clear. The + * disagreement is recorded rather than resolved here because the row + * ceiling is also reachable from the internal surface, where the 400 is + * shipped and not behind the v2 flag, so restatusing one and not the + * other would widen the split instead of closing it. + */ + throw new ForbiddenOperationError( + 'WORKSPACE_RESOURCE_LIMIT_REACHED', `Workspace has reached maximum table limit (${maxTables})` ) } diff --git a/apps/sim/lib/workspace-files/application/compiled-check-workspace-file.test.ts b/apps/sim/lib/workspace-files/application/compiled-check-workspace-file.test.ts index 04172b19904..27967b13cee 100644 --- a/apps/sim/lib/workspace-files/application/compiled-check-workspace-file.test.ts +++ b/apps/sim/lib/workspace-files/application/compiled-check-workspace-file.test.ts @@ -102,6 +102,16 @@ describe('compiledCheckWorkspaceFile', () => { }, ] + /** + * A workspace key is refused with the code naming *why* — the operation + * denies workspace keys, so the remedy is a personal key — while any other + * disallowed kind gets the generic kind refusal. + */ + const expectedDetailCode = { + personal_api_key: 'PRINCIPAL_KIND_NOT_PERMITTED', + workspace_api_key: 'WORKSPACE_KEY_OPERATION_NOT_PERMITTED', + } as const + for (const principal of unsupportedPrincipals) { await expect( compiledCheckWorkspaceFile.execute({ @@ -110,7 +120,7 @@ describe('compiledCheckWorkspaceFile', () => { }) ).rejects.toMatchObject({ code: 'forbidden', - message: `Principal kind ${principal.kind} cannot perform operation files.compiled_check`, + detailCode: expectedDetailCode[principal.kind], }) } diff --git a/apps/sim/lib/workspace-files/application/share-workspace-file.ts b/apps/sim/lib/workspace-files/application/share-workspace-file.ts index 051957f390c..e28789a8846 100644 --- a/apps/sim/lib/workspace-files/application/share-workspace-file.ts +++ b/apps/sim/lib/workspace-files/application/share-workspace-file.ts @@ -2,6 +2,7 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { requirePrincipalSubjectUserId } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import type { ShareAuthType, ShareRecord } from '@/lib/api/contracts/public-shares' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { OrchestrationError } from '@/lib/core/orchestration/types' import { getShareForResource, @@ -84,7 +85,7 @@ export const updateWorkspaceFileShare = defineAuthorizedWorkspaceFileUseCase({ await validatePublicFileSharing(subjectUserId, context.workspaceId, effectiveAuthType) } catch (error) { if (error instanceof PublicFileSharingNotAllowedError) - throw new OrchestrationError('forbidden', error.message) + throw new ForbiddenOperationError('PUBLIC_SHARING_NOT_ALLOWED', error.message) throw error } } From f815f1a72ecc0183a092bb636ceac2582d1f7118 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 16:09:21 -0700 Subject: [PATCH 06/56] fix(tables): close the v2 tables correctness and contract gaps - updateColumnOptions was the only column mutator with no lock assert: an options-only PATCH applied on a schema-locked table, and an option REMOVAL cleared cells on a delete-locked one. Assert schema always, escalate to the destructive gate only when options are dropped. - GET/DELETE /tables/imports/{id} 500'd on a first-party import job (null payload) or an unrepresentable status. Both now read as absent, so the answer is the 404 it always was. - Offset cursors stamped the sort but not the filters, so a page-2 cursor replayed under a different predicate paged an unrelated sequence silently. Offsets now carry a filter fingerprint and refuse a mismatch. - Publish 413 on every tables operation that accepts a request body: the v2 JSON builder reads the body under a byte ceiling before validation, so the status is reachable on all of them. Derived at document assembly so a new route cannot regress it. - Enforce MAX_VIEWS_PER_TABLE on view create, making the list contract's "small bounded set" claim true. - Accept the upload control token on the import read, so an upload-backed import is readable during the phase its own 201 reported; drop the `queued` status the reads can never return. - Declare the Find search-term cap, the Find match cap, and the run row-id ceiling the domain already enforces. - Uniform 201 on the row and column creates. --- apps/docs/openapi-v2-tables.json | 116 +++++++++++----- .../app/api/table/[tableId]/query/route.ts | 4 +- .../v2/tables/[tableId]/columns/route.test.ts | 2 +- .../v2/tables/[tableId]/rows/route.test.ts | 9 +- .../api/v2/tables/imports/[importId]/route.ts | 3 +- .../app/api/v2/tables/imports/route.test.ts | 2 +- apps/sim/lib/api/contracts/tables.ts | 8 +- .../api/contracts/v2/__tests__/tables.test.ts | 119 +++++++++++++++- .../lib/api/contracts/v2/openapi/tables.ts | 49 +++++-- apps/sim/lib/api/contracts/v2/shared.ts | 9 +- apps/sim/lib/api/contracts/v2/tables.ts | 56 ++++++-- .../sim/lib/table/application/imports.test.ts | 40 ++++++ apps/sim/lib/table/application/imports.ts | 35 ++++- apps/sim/lib/table/application/rows.test.ts | 58 ++++++++ apps/sim/lib/table/application/rows.ts | 6 +- .../lib/table/columns/option-locks.test.ts | 100 ++++++++++++++ apps/sim/lib/table/columns/service.ts | 18 ++- apps/sim/lib/table/constants.ts | 22 +++ apps/sim/lib/table/errors.ts | 1 + .../orchestration/import-resource.test.ts | 74 +++++++++- .../table/orchestration/import-resource.ts | 72 ++++++++-- apps/sim/lib/table/rows/cursor.test.ts | 130 ++++++++++++++++-- apps/sim/lib/table/rows/cursor.ts | 97 +++++++++++-- apps/sim/lib/table/rows/service.ts | 11 +- apps/sim/lib/table/views/service.test.ts | 67 ++++++++- apps/sim/lib/table/views/service.ts | 53 +++++-- 26 files changed, 1036 insertions(+), 125 deletions(-) create mode 100644 apps/sim/lib/table/columns/option-locks.test.ts diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 809446dc7b9..dac0a0c2408 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -504,7 +504,7 @@ } }, "responses": { - "200": { + "201": { "description": "The updated table columns.", "headers": { "X-RateLimit-Limit": { @@ -537,6 +537,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "423": { "$ref": "#/components/responses/Locked" }, @@ -614,6 +617,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "423": { "$ref": "#/components/responses/Locked" }, @@ -691,6 +697,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "423": { "$ref": "#/components/responses/Locked" }, @@ -835,7 +844,7 @@ } }, "responses": { - "200": { + "201": { "description": "The inserted row or rows.", "headers": { "X-RateLimit-Limit": { @@ -868,6 +877,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "423": { "$ref": "#/components/responses/Locked" }, @@ -945,6 +957,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "423": { "$ref": "#/components/responses/Locked" }, @@ -1022,6 +1037,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "423": { "$ref": "#/components/responses/Locked" }, @@ -1197,6 +1215,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "423": { "$ref": "#/components/responses/Locked" }, @@ -1364,6 +1385,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "423": { "$ref": "#/components/responses/Locked" }, @@ -1383,7 +1407,7 @@ "post": { "operationId": "queryTableRows", "summary": "Query Rows", - "description": "Query rows with a typed predicate, ordered sort specification, and opaque cursor pagination. Bounded pages are capped at 5MB by default and may contain fewer rows than the requested limit; continue until nextCursor is null.", + "description": "Query rows with a typed predicate, ordered sort specification, and opaque cursor pagination. Bounded pages are capped at 5MB by default and may contain fewer rows than the requested limit; continue until nextCursor is null. A predicate larger than the request-body ceiling is a `413`.", "tags": ["Tables"], "parameters": [ { @@ -1443,6 +1467,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1672,6 +1699,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1844,6 +1874,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -2079,6 +2112,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "423": { "$ref": "#/components/responses/Locked" }, @@ -2159,6 +2195,9 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "423": { "$ref": "#/components/responses/Locked" }, @@ -2236,6 +2275,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "423": { "$ref": "#/components/responses/Locked" }, @@ -2315,6 +2357,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -2413,6 +2458,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -2489,6 +2537,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -2577,7 +2628,7 @@ "get": { "operationId": "getTableImport", "summary": "Get Table Import", - "description": "Read progress and terminal state for a durable table import.", + "description": "Read progress and terminal state for a durable table import.\n\nAn upload-backed import has no durable record until its upload completes, so send the signed upload control token to read it during the `uploading` phase; without the token that phase returns `404`.", "tags": ["Tables"], "parameters": [ { @@ -2601,6 +2652,17 @@ "minLength": 1, "description": "Workspace that owns the transfer resource." } + }, + { + "name": "upload-token", + "in": "header", + "required": false, + "description": "Signed upload control token returned when an upload-backed import was created.", + "schema": { + "description": "Signed upload control token returned when an upload-backed import was created.", + "type": "string", + "minLength": 1 + } } ], "responses": { @@ -2826,6 +2888,9 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -2998,6 +3063,9 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -3306,6 +3374,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -6617,6 +6688,7 @@ "rowIds": { "description": "Explicit row subset to run.", "minItems": 1, + "maxItems": 1000000, "type": "array", "items": { "type": "string", @@ -6722,15 +6794,16 @@ "type": "object", "properties": { "matches": { + "maxItems": 1000, "type": "array", "items": { "$ref": "#/components/schemas/V2TableRowMatch" }, - "description": "Matching table cells." + "description": "Matching table cells, at most 1000." }, "truncated": { "type": "boolean", - "description": "Whether more matches exist beyond the server cap." + "description": "Whether more than 1000 cells matched, so the list was cut." } }, "required": ["matches", "truncated"], @@ -6762,6 +6835,7 @@ "q": { "type": "string", "minLength": 1, + "maxLength": 200, "description": "Case-insensitive cell substring to find." }, "predicate": { @@ -6856,15 +6930,7 @@ }, "status": { "type": "string", - "enum": [ - "uploading", - "queued", - "processing", - "completed", - "failed", - "canceled", - "expired" - ], + "enum": ["uploading", "processing", "completed", "failed", "canceled", "expired"], "description": "Current import lifecycle state." }, "source": { @@ -7085,15 +7151,7 @@ }, "status": { "type": "string", - "enum": [ - "uploading", - "queued", - "processing", - "completed", - "failed", - "canceled", - "expired" - ], + "enum": ["uploading", "processing", "completed", "failed", "canceled", "expired"], "description": "Current import lifecycle state." }, "source": { @@ -7409,15 +7467,7 @@ }, "status": { "type": "string", - "enum": [ - "uploading", - "queued", - "processing", - "completed", - "failed", - "canceled", - "expired" - ], + "enum": ["uploading", "processing", "completed", "failed", "canceled", "expired"], "description": "Current import lifecycle state." }, "source": { diff --git a/apps/sim/app/api/table/[tableId]/query/route.ts b/apps/sim/app/api/table/[tableId]/query/route.ts index 9d156eeb656..ebfb0507504 100644 --- a/apps/sim/app/api/table/[tableId]/query/route.ts +++ b/apps/sim/app/api/table/[tableId]/query/route.ts @@ -10,7 +10,7 @@ import type { Sort, TableSchema } from '@/lib/table' import { buildIdByName, sortSpecNamesToIds } from '@/lib/table/column-keys' import { TableQueryValidationError } from '@/lib/table/errors' import { validatePredicate, validateSortSpec } from '@/lib/table/query-builder/validate' -import { assertCursorSortBinding, decodeCursor } from '@/lib/table/rows/cursor' +import { assertCursorQueryBinding, decodeCursor } from '@/lib/table/rows/cursor' import { queryRows } from '@/lib/table/rows/service' import { predicateToStorage } from '@/lib/table/select-values' import { createTableRowsResponse } from '@/app/api/table/row-secret-provenance' @@ -84,7 +84,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: RowQu // Cursor↔sort binding: keyset cursors are default-order only; an offset // cursor must be replayed under the exact sort it was minted with. - if (cursor) assertCursorSortBinding(cursor, sort) + if (cursor) assertCursorQueryBinding(cursor, { sort, predicate }) const result = await queryRows( table, diff --git a/apps/sim/app/api/v2/tables/[tableId]/columns/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/columns/route.test.ts index 5fedb51afac..58675d8e48f 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/columns/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/columns/route.test.ts @@ -83,7 +83,7 @@ describe('/api/v2/tables/[tableId]/columns', () => { }) const response = await POST(req, context) - expect(response.status).toBe(200) + expect(response.status).toBe(201) expect((await response.json()).data.columns).toEqual([ { id: 'col-1', name: 'Name', type: 'string', required: false, unique: false }, ]) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/route.test.ts index 3d7ef8d5271..b6169d46d63 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/route.test.ts @@ -143,7 +143,10 @@ describe('/api/v2/tables/[tableId]/rows', () => { it('delegates single and batch creation through one semantic use case', async () => { const single = request('POST', { workspaceId: WORKSPACE_ID, data: { name: 'Ada' } }) - expect((await (await POST(single, CONTEXT)).json()).data.id).toBe('row-1') + const singleResponse = await POST(single, CONTEXT) + // 201 on both arms: every v2 create answers the same status, batch included. + expect(singleResponse.status).toBe(201) + expect((await singleResponse.json()).data.id).toBe('row-1') expect(mocks.createRows).toHaveBeenLastCalledWith({ principal: PRINCIPAL, input: { @@ -157,7 +160,9 @@ describe('/api/v2/tables/[tableId]/rows', () => { mocks.createRows.mockResolvedValue({ kind: 'batch', table: TABLE, rows: [ROW] }) const batch = request('POST', { workspaceId: WORKSPACE_ID, rows: [{ name: 'Ada' }] }) - expect((await (await POST(batch, CONTEXT)).json()).data.insertedCount).toBe(1) + const batchResponse = await POST(batch, CONTEXT) + expect(batchResponse.status).toBe(201) + expect((await batchResponse.json()).data.insertedCount).toBe(1) expect(mocks.createRows).toHaveBeenLastCalledWith({ principal: PRINCIPAL, input: { diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/route.ts b/apps/sim/app/api/v2/tables/imports/[importId]/route.ts index 7092782c602..5aa45c2a899 100644 --- a/apps/sim/app/api/v2/tables/imports/[importId]/route.ts +++ b/apps/sim/app/api/v2/tables/imports/[importId]/route.ts @@ -17,9 +17,10 @@ export const GET = defineV2JsonRoute({ auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, errorPolicy: v2TableErrorPolicies.concealImportAuthorization, - mapInput: ({ params, query }) => ({ + mapInput: ({ params, query, headers }) => ({ importId: params.importId, workspaceId: query.workspaceId, + uploadToken: headers['upload-token'], }), useCase: readTableImportUseCase, present: ({ import: tableImport }) => presentV2TableImport(tableImport), diff --git a/apps/sim/app/api/v2/tables/imports/route.test.ts b/apps/sim/app/api/v2/tables/imports/route.test.ts index 44fe854fa3a..8c25906f209 100644 --- a/apps/sim/app/api/v2/tables/imports/route.test.ts +++ b/apps/sim/app/api/v2/tables/imports/route.test.ts @@ -88,7 +88,7 @@ describe('POST /api/v2/tables/imports', () => { session: { id: 'import-1', workspaceId: WORKSPACE_ID, - status: 'queued', + status: 'processing', source: { type: 'workspace_file', fileId: 'file-1' }, target: { type: 'new', name: 'imported_data' }, tableId: 'table-1', diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index e668a3a1d5a..5b500c7152f 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -29,6 +29,7 @@ import type { import { COLUMN_TYPES, FILTER_OPS, + MAX_RUN_TARGET_ROW_IDS, MAX_SELECT_OPTIONS, NAME_PATTERN, SORT_DIRECTIONS, @@ -1741,7 +1742,12 @@ export const runColumnBodyBaseSchema = z.object({ .enum(['all', 'incomplete']) .default('all') .describe('Whether to run all or only incomplete cells.'), - rowIds: z.array(z.string().min(1)).min(1).optional().describe('Explicit row subset to run.'), + rowIds: z + .array(z.string().min(1)) + .min(1) + .max(MAX_RUN_TARGET_ROW_IDS, `Cannot target more than ${MAX_RUN_TARGET_ROW_IDS} rows`) + .optional() + .describe('Explicit row subset to run.'), /** "Select all under a filter" — run every row matching this filter instead of `rowIds`. The * dispatcher walks only matching rows (paginated), so no id list is materialized. */ filter: bulkFilterSchema.optional(), diff --git a/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts index 8aa700675ad..7a4f1c1c8fe 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts @@ -1,10 +1,13 @@ import { describe, expect, it } from 'vitest' -import type { z } from 'zod' +import { z } from 'zod' +import { runColumnBodyBaseSchema, TABLE_QUERY_MAX_BODY_BYTES } from '@/lib/api/contracts/tables' import { issueCodes, type SchemaLike, strictnessTargets, } from '@/lib/api/contracts/v2/__tests__/schema-introspection' +import { tablesOpenApiDocument } from '@/lib/api/contracts/v2/openapi/tables' +import { V2_SEARCH_MAX_LENGTH } from '@/lib/api/contracts/v2/shared' import * as tableContracts from '@/lib/api/contracts/v2/tables' import { V2_TABLE_IMPORT_OPTIONS_MAX_BYTES, @@ -15,12 +18,16 @@ import { v2CreateTableRowsBodySchema, v2CsvImportCreateColumnsSchema, v2CsvImportMappingSchema, + v2FindRowsBodySchema, + v2FindRowsDataSchema, + v2GetTableImportContract, v2QueryRowsBodySchema, + v2TableImportStatusSchema, v2TableUploadImportSourceSchema, v2UpdateTableColumnBodySchema, } from '@/lib/api/contracts/v2/tables' import { getValidationErrorMessage } from '@/lib/api/server/validation' -import { TABLE_LIMITS } from '@/lib/table/constants' +import { MAX_RUN_TARGET_ROW_IDS, TABLE_LIMITS } from '@/lib/table/constants' import { CSV_DURABLE_MAX_FILE_SIZE_BYTES } from '@/lib/table/import' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' @@ -283,3 +290,111 @@ describe('v2 table import contracts', () => { } }) }) + +/** + * The published error set has to match what a route can actually emit. The v2 + * JSON builder reads every request body under a byte ceiling BEFORE schema + * validation, so `413` is reachable on every body-carrying operation — and the + * two table query reads set a tighter ceiling of their own on top of that. An + * undocumented status is an unhandled branch in a generated client. + * + * One-directional on purpose: several bodyless reads publish `413` for the + * folder-tree materialization ceiling, so the converse is not asserted. + */ +describe('v2 table operation error sets', () => { + const operationsById = new Map( + tablesOpenApiDocument.routes.map((route) => [route.operation.operationId, route.operation]) + ) + + it('publishes 413 on every operation that accepts a request body', () => { + const missing = tablesOpenApiDocument.routes + .filter((route) => route.contract.body && !route.operation.errors.includes('PayloadTooLarge')) + .map((route) => route.operation.operationId) + + expect(missing).toEqual([]) + }) + + it.each(['queryTableRows', 'countTableRows'])( + 'names the tighter query-body ceiling on %s', + (operationId) => { + expect(operationsById.get(operationId)?.errors).toContain('PayloadTooLarge') + expect(operationsById.get(operationId)?.description).toContain('413') + } + ) + + it('keeps the body ceiling the two query operations share declared once', () => { + expect(TABLE_QUERY_MAX_BODY_BYTES).toBe(1024 * 1024) + }) +}) + +/** + * The import status enum is the client's exhaustive switch. A state the reads + * can never return is a dead branch every caller has to write; a phase the read + * cannot reach at all is worse. + */ +describe('v2 table import lifecycle surface', () => { + it('publishes only states an import read can return', () => { + expect(v2TableImportStatusSchema.options).toEqual([ + 'uploading', + 'processing', + 'completed', + 'failed', + 'canceled', + 'expired', + ]) + }) + + it('accepts the upload control token on the read, as the cancel already does', () => { + expect( + v2GetTableImportContract.headers?.safeParse({ 'upload-token': 'signed-token' }) + ).toMatchObject({ success: true, data: { 'upload-token': 'signed-token' } }) + expect(v2GetTableImportContract.headers?.safeParse({}).success).toBe(true) + }) +}) + +/** + * Caller-supplied input that reaches an unindexed scan or a large id list has to + * carry a declared ceiling; an undeclared one is enforced by the domain as a + * surprise, or not at all. + */ +describe('v2 table request bounds', () => { + const findBody = { workspaceId: WORKSPACE_ID, q: 'x' } + + it('caps the Find search term at the shared v2 search length', () => { + expect( + v2FindRowsBodySchema.safeParse({ ...findBody, q: 'a'.repeat(V2_SEARCH_MAX_LENGTH) }).success + ).toBe(true) + expect( + v2FindRowsBodySchema.safeParse({ ...findBody, q: 'a'.repeat(V2_SEARCH_MAX_LENGTH + 1) }) + .success + ).toBe(false) + }) + + it('publishes the Find match cap the truncated flag is derived from', () => { + expect( + v2FindRowsDataSchema.safeParse({ + matches: Array.from({ length: TABLE_LIMITS.MAX_FIND_MATCHES + 1 }, () => ({ + ordinal: 0, + rowId: 'row-1', + column: 'name', + })), + truncated: true, + }).success + ).toBe(false) + expect(JSON.stringify(z.toJSONSchema(v2FindRowsDataSchema))).toContain( + String(TABLE_LIMITS.MAX_FIND_MATCHES) + ) + }) + + it('declares the run row-id ceiling the domain already enforces', () => { + const rowIds = z.toJSONSchema(runColumnBodyBaseSchema.shape.rowIds) as { + anyOf?: Array<{ maxItems?: number; minItems?: number }> + maxItems?: number + minItems?: number + } + const bounds = rowIds.anyOf?.find((entry) => entry.maxItems !== undefined) ?? rowIds + + expect(bounds.maxItems).toBe(MAX_RUN_TARGET_ROW_IDS) + expect(bounds.minItems).toBe(1) + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/openapi/tables.ts b/apps/sim/lib/api/contracts/v2/openapi/tables.ts index c23448e95ce..03d5ee6d2b4 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/tables.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/tables.ts @@ -64,6 +64,7 @@ import { defineOpenApiDocument, defineOpenApiRoute, type OpenApiOperationMetadata, + type OpenApiRouteDefinition, type OpenApiSuccessMetadata, } from '@/lib/api/openapi/types' @@ -93,10 +94,11 @@ const TABLE_MUTATION_ERRORS = [ ] as const satisfies readonly ErrorResponseId[] /** - * The two table query reads declare `maxBodyBytes`, which the route builder - * turns into a real `413`, so their set is the base plus that status. Every - * other table read carries its input in the query string and has no body - * ceiling to exceed. + * The two table query reads declare their own `maxBodyBytes` — 1 MiB, far below + * the 50 MB default every JSON body is held to — so their `413` is a routine + * answer to an oversized predicate rather than an abuse ceiling, and it is named + * here and in their descriptions. Every other table read carries its input in + * the query string and has no body ceiling to exceed. */ const TABLE_QUERY_ERRORS = [ ...RESOURCE_ERRORS, @@ -119,7 +121,7 @@ function tableOperation( } } -const routes = [ +const declaredRoutes = [ defineOpenApiRoute( v2ListTablesContract, tableOperation({ @@ -632,8 +634,8 @@ const routes = [ operationId: 'queryTableRows', summary: 'Query Rows', description: - 'Query rows with a typed predicate, ordered sort specification, and opaque cursor pagination. Bounded pages are capped at 5MB by default and may contain fewer rows than the requested limit; continue until nextCursor is null.', - errors: RESOURCE_ERRORS, + 'Query rows with a typed predicate, ordered sort specification, and opaque cursor pagination. Bounded pages are capped at 5MB by default and may contain fewer rows than the requested limit; continue until nextCursor is null. A predicate larger than the request-body ceiling is a `413`.', + errors: TABLE_QUERY_ERRORS, success: { description: 'A page of matching table rows.' }, }), { @@ -1139,7 +1141,8 @@ const routes = [ tableOperation({ operationId: 'getTableImport', summary: 'Get Table Import', - description: 'Read progress and terminal state for a durable table import.', + description: + 'Read progress and terminal state for a durable table import.\n\nAn upload-backed import has no durable record until its upload completes, so send the signed upload control token to read it during the `uploading` phase; without the token that phase returns `404`.', errors: RESOURCE_ERRORS, success: { description: 'The requested table import.' }, }), @@ -1157,6 +1160,12 @@ const routes = [ 'Get table import query', 'Workspace scope for the import.' ), + headers: documentedSchema( + v2GetTableImportContract.headers, + 'GetTableImportHeaders', + 'Get table import headers', + 'Optional signed upload control token for an upload-backed import.' + ), response: documentedSchema( v2GetTableImportContract.response.schema, 'V2TableImportResponse', @@ -1546,6 +1555,30 @@ const routes = [ ), ] as const +/** + * Publishes `413` on every operation that accepts a request body. + * + * The v2 JSON builder reads the body through `parseJsonBody` under + * `DEFAULT_MAX_JSON_BODY_BYTES` (50 MB) BEFORE schema validation, rendering + * `V2_PARSE_DEFAULTS.payloadTooLargeResponse`. That makes the status reachable + * on every body-carrying operation, not just the two that set a tighter + * `maxBodyBytes` of their own — and a status a caller can receive but the spec + * does not declare is an unhandled branch in every generated client. + * + * Deliberately one-directional: it adds `413` where a body exists and never + * removes it where none does, because several bodyless reads publish `413` for + * the folder-tree materialization ceiling instead. + */ +function withRequestBodyErrors(route: OpenApiRouteDefinition): OpenApiRouteDefinition { + if (!route.contract.body || route.operation.errors.includes('PayloadTooLarge')) return route + return { + ...route, + operation: { ...route.operation, errors: [...route.operation.errors, 'PayloadTooLarge'] }, + } +} + +const routes = declaredRoutes.map(withRequestBodyErrors) + export const tablesOpenApiDocument = defineOpenApiDocument({ output: 'apps/docs/openapi-v2-tables.json', info: { diff --git a/apps/sim/lib/api/contracts/v2/shared.ts b/apps/sim/lib/api/contracts/v2/shared.ts index 17a9e83073e..000485de345 100644 --- a/apps/sim/lib/api/contracts/v2/shared.ts +++ b/apps/sim/lib/api/contracts/v2/shared.ts @@ -304,11 +304,18 @@ export function v2RunWindowBoundSchema(field: 'startDate' | 'endDate') { .meta({ format: 'date-time' }) } +/** + * Longest caller-supplied substring any v2 search accepts. Every one of them + * compiles to an unindexed `ILIKE` scan, so the term itself has to be bounded + * wherever it is accepted — including the searches that are not name searches. + */ +export const V2_SEARCH_MAX_LENGTH = 200 + export const v2SearchSchema = z .string() .trim() .min(1, 'search cannot be empty') - .max(200, 'search is too long') + .max(V2_SEARCH_MAX_LENGTH, 'search is too long') .optional() .describe('Case-insensitive substring search on the resource name.') diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index 391e10b0758..323cd7d512c 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -43,6 +43,7 @@ import { v1ListTablesQuerySchema, } from '@/lib/api/contracts/v1/tables' import { + V2_SEARCH_MAX_LENGTH, v2CreateFolderBodySchema, v2CursorListResponse, v2DataResponse, @@ -618,6 +619,7 @@ export const v2UpdateTableColumnBodySchema = z export type V2UpdateTableColumnBody = z.input +/** `201`, like every other v2 create; the body is the table's full column set. */ export const v2AddTableColumnContract = defineRouteContract({ method: 'POST', path: '/api/v2/tables/[tableId]/columns', @@ -626,6 +628,7 @@ export const v2AddTableColumnContract = defineRouteContract({ response: { mode: 'json', schema: v2DataResponse(v2TableColumnsDataSchema), + status: 201, }, }) @@ -851,6 +854,13 @@ export const v2CreateTableRowsBodySchema = z.union( } ) +/** + * `201` on both arms of the union. The batch arm returns a count rather than one + * created resource and neither arm carries a `Location`, but no v2 create does — + * the status describes what happened to the server, and rows were created. A + * caller that has to read the body to learn whether its POST created anything is + * exactly what a uniform create status prevents. + */ export const v2CreateTableRowsContract = defineRouteContract({ method: 'POST', path: '/api/v2/tables/[tableId]/rows', @@ -859,6 +869,7 @@ export const v2CreateTableRowsContract = defineRouteContract({ response: { mode: 'json', schema: z.union([v2CreateSingleTableRowResponseSchema, v2CreateBatchTableRowsResponseSchema]), + status: 201, }, }) @@ -1083,9 +1094,10 @@ export const v2DeleteTableViewDataSchema = z export type V2DeleteTableViewData = z.output /** - * Every saved view on a table, oldest first. A table carries a small bounded - * set of views, so this is a single full page (`nextCursor` is always `null`); - * the cursor envelope keeps the v2 list surface uniform. + * Every saved view on a table, oldest first. The create path enforces + * `TABLE_LIMITS.MAX_VIEWS_PER_TABLE`, so the set is bounded and this is a single + * full page (`nextCursor` is always `null`); the cursor envelope keeps the v2 + * list surface uniform. */ export const v2ListTableViewsContract = defineRouteContract({ method: 'GET', @@ -1460,6 +1472,7 @@ export const v2FindRowsBodySchema = z q: z .string() .min(1, 'q must be a non-empty search string') + .max(V2_SEARCH_MAX_LENGTH, 'q is too long') .describe('Case-insensitive cell substring to find.'), predicate: predicateSchema.optional(), sort: sortSpecSchema.optional().describe('Ordered table-row sort specification.'), @@ -1487,14 +1500,21 @@ export const v2RowMatchSchema = z export type V2RowMatch = z.output /** - * Match set. `truncated` is `true` when the search hit the server-side cap and - * more cells match than were returned — narrow the predicate rather than - * paging, since matches have no cursor. + * Match set. `truncated` is `true` when the search hit the server-side cap of + * {@link TABLE_LIMITS.MAX_FIND_MATCHES} and more cells match than were returned + * — narrow the predicate rather than paging, since matches have no cursor. */ export const v2FindRowsDataSchema = z .object({ - matches: z.array(v2RowMatchSchema).describe('Matching table cells.'), - truncated: z.boolean().describe('Whether more matches exist beyond the server cap.'), + matches: z + .array(v2RowMatchSchema) + .max(TABLE_LIMITS.MAX_FIND_MATCHES) + .describe(`Matching table cells, at most ${TABLE_LIMITS.MAX_FIND_MATCHES}.`), + truncated: z + .boolean() + .describe( + `Whether more than ${TABLE_LIMITS.MAX_FIND_MATCHES} cells matched, so the list was cut.` + ), }) .meta({ id: 'V2FindRowsData', @@ -1669,9 +1689,17 @@ export const v2CreateTableImportBodySchema = z }) export type V2CreateTableImportBody = z.input +/** + * Every state an import can be read in, and nothing else. + * + * `uploading` and `expired` come from the upload session that backs an + * upload-sourced import; the other four are projections of the durable job's + * status. There is deliberately no `queued`: a job row exists only once its + * runner has started it, so an import is never observable between creation and + * `processing`. + */ export const v2TableImportStatusSchema = z.enum([ 'uploading', - 'queued', 'processing', 'completed', 'failed', @@ -1760,11 +1788,21 @@ export const v2CreateTableImportContract = defineRouteContract({ }, }) +/** + * Reads an import in any of its states, including the upload phase. + * + * The optional upload token is what makes the upload phase readable at all: an + * upload-sourced import has no `table_jobs` row until its upload completes, so + * without the token the id the 201 just handed back would 404 for the whole + * time the caller is uploading parts. `DELETE` takes the same header for the + * same reason. + */ export const v2GetTableImportContract = defineRouteContract({ method: 'GET', path: '/api/v2/tables/imports/[importId]', params: v2TableImportParamsSchema, query: v2TableTransferWorkspaceQuerySchema, + headers: v2TableOptionalUploadTokenHeadersSchema, response: { mode: 'json', schema: v2DataResponse(v2TableImportSchema) }, }) diff --git a/apps/sim/lib/table/application/imports.test.ts b/apps/sim/lib/table/application/imports.test.ts index 79d3fae1691..c5226c24e5d 100644 --- a/apps/sim/lib/table/application/imports.test.ts +++ b/apps/sim/lib/table/application/imports.test.ts @@ -21,6 +21,7 @@ const mocks = vi.hoisted(() => ({ resolveWorkspaceContext: vi.fn(), startUploadedImport: vi.fn(), tableImportBodyFromUpload: vi.fn(), + resourceFromUpload: vi.fn(), })) vi.mock('@sim/platform-authz/workspace', () => ({ @@ -53,6 +54,7 @@ vi.mock('@/lib/table/orchestration/import-resource', () => ({ getTableImportResource: mocks.getResource, startUploadedTableImport: mocks.startUploadedImport, tableImportBodyFromUpload: mocks.tableImportBodyFromUpload, + tableImportResourceFromUpload: mocks.resourceFromUpload, })) vi.mock('@/lib/uploads/upload-session/application', () => ({ @@ -164,6 +166,7 @@ describe('table import application use cases', () => { mocks.startUploadedImport.mockResolvedValue({ ...record, status: 'ready' }) mocks.createResource.mockResolvedValue({ record, upload: null }) mocks.getWorkspaceFile.mockResolvedValue(workspaceFile) + mocks.resourceFromUpload.mockReturnValue(record) }) it('creates an import through the domain resource boundary without presenting a v2 DTO', async () => { @@ -249,6 +252,43 @@ describe('table import application use cases', () => { ) }) + /** + * The 201 that creates an upload-backed import reports `status: "uploading"` + * against an id that has no durable job row yet. Reading that id back is only + * possible through the upload session, so the token has to be honored here the + * same way `DELETE` honors it — otherwise the whole upload phase 404s. + */ + it('reads an upload-phase import through its upload token', async () => { + await expect( + readTableImportUseCase.execute({ + principal: workspaceKey, + input: { importId: 'import-1', workspaceId: 'workspace-1', uploadToken: 'signed-token' }, + }) + ).resolves.toEqual({ import: record }) + + expect(mocks.getUpload).toHaveBeenCalledWith({ + importId: 'import-1', + assertedWorkspaceId: 'workspace-1', + principal: workspaceKey, + uploadToken: 'signed-token', + }) + expect(mocks.getResource).not.toHaveBeenCalled() + expect(mocks.resourceFromUpload).toHaveBeenCalledWith(upload) + }) + + it('prefers the durable job once the upload has started one', async () => { + const running = { ...record, status: 'running' as const } + mocks.findResource.mockResolvedValue(running) + + await expect( + readTableImportUseCase.execute({ + principal: workspaceKey, + input: { importId: 'import-1', workspaceId: 'workspace-1', uploadToken: 'signed-token' }, + }) + ).resolves.toEqual({ import: running }) + expect(mocks.resourceFromUpload).not.toHaveBeenCalled() + }) + it('resolves a workspace-file source canonically inside the authorized import command', async () => { await createTableImportUseCase.execute({ principal: reader, diff --git a/apps/sim/lib/table/application/imports.ts b/apps/sim/lib/table/application/imports.ts index 3f27c72c9df..0b14531c299 100644 --- a/apps/sim/lib/table/application/imports.ts +++ b/apps/sim/lib/table/application/imports.ts @@ -28,6 +28,7 @@ import { startUploadedTableImport, type TableImportResource, tableImportBodyFromUpload, + tableImportResourceFromUpload, } from '@/lib/table/orchestration/import-resource' import { getWorkspaceFile, @@ -60,6 +61,10 @@ export interface CreateTableImportPartsInput extends TableImportUploadInput { partNumbers: number[] } +export interface ReadTableImportInput extends TableImportResourceInput { + uploadToken?: string +} + export interface CancelTableImportInput extends TableImportResourceInput { uploadToken?: string } @@ -196,12 +201,36 @@ export const createTableImportUseCase = defineAuthorizedTableUseCase({ }, }) +/** + * Reads an import, including while its upload is still in flight. + * + * An upload-sourced import has no durable job row until the upload completes, + * so a caller holding the upload token is resolved against the session instead — + * the same branch `cancelTableImportUseCase` takes. The job is still preferred + * once it exists: the upload session lingers in a completed state after the + * runner starts, and reporting `uploading` for an import that is already + * processing would strand a poller. + */ export const readTableImportUseCase = defineAuthorizedTableUseCase({ operation: tableOperations.readImport, - resolveContext: ({ input }: { input: TableImportResourceInput }) => - resolveTableImportContext(input), + async resolveContext({ + principal, + input, + }: { + principal: Principal + input: ReadTableImportInput + }) { + return input.uploadToken + ? resolveTableImportUploadContext(principal, { ...input, uploadToken: input.uploadToken }) + : resolveTableImportContext(input) + }, async execute({ context }): Promise { - return { import: context.record } + if (!('upload' in context)) return { import: context.record } + const started = await findTableImportResource({ + importId: context.upload.id, + assertedWorkspaceId: context.workspaceId, + }) + return { import: started ?? tableImportResourceFromUpload(context.upload) } }, }) diff --git a/apps/sim/lib/table/application/rows.test.ts b/apps/sim/lib/table/application/rows.test.ts index ea728d97881..b358923f778 100644 --- a/apps/sim/lib/table/application/rows.test.ts +++ b/apps/sim/lib/table/application/rows.test.ts @@ -629,6 +629,64 @@ describe('row query and upsert application semantics', () => { expect(result.nextCursor).toBe('native-next-cursor') }) + /** + * An offset cursor names a position in one filtered sequence. Replayed under a + * different predicate that ordinal belongs to a sequence the caller never asked + * for — page 2 of the archived rows, or an empty page the caller reads as "no + * more matches". It must be refused, exactly as a changed sort already is. + */ + it('refuses an offset cursor replayed under a different predicate', async () => { + const cursor = encodeCursor({ + lastRow: { id: 'row-100', orderKey: null }, + keysetValid: false, + nextOffset: 100, + predicate: { all: [{ field: 'column-name', op: 'eq', value: 'Ada' }] }, + }) + + await expect( + queryTableRows.execute({ + principal: PRINCIPAL, + input: { + tableId: TABLE.id, + cursor, + predicate: { all: [{ field: 'name', op: 'eq', value: 'Grace' }] }, + }, + }) + ).rejects.toMatchObject({ details: { code: 'CURSOR_FILTER_CONFLICT' } }) + expect(mockQueryRows).not.toHaveBeenCalled() + }) + + it('resumes the same offset page under the identical predicate', async () => { + const cursor = encodeCursor({ + lastRow: { id: 'row-100', orderKey: null }, + keysetValid: false, + nextOffset: 100, + predicate: { all: [{ field: 'column-name', op: 'eq', value: 'Ada' }] }, + }) + mockQueryRows.mockResolvedValueOnce({ + rows: [], + rowCount: 0, + totalCount: null, + nextCursor: null, + }) + + await expect( + queryTableRows.execute({ + principal: PRINCIPAL, + input: { + tableId: TABLE.id, + cursor, + predicate: { all: [{ field: 'name', op: 'eq', value: 'Ada' }] }, + }, + }) + ).resolves.toMatchObject({ rowCount: 0 }) + expect(mockQueryRows).toHaveBeenCalledWith( + TABLE, + expect.objectContaining({ offset: 100 }), + expect.any(String) + ) + }) + it('loads requested persisted provenance inside the authorized application query', async () => { const row = { id: 'row-1', diff --git a/apps/sim/lib/table/application/rows.ts b/apps/sim/lib/table/application/rows.ts index 4681b39be14..8aaa15651bf 100644 --- a/apps/sim/lib/table/application/rows.ts +++ b/apps/sim/lib/table/application/rows.ts @@ -54,7 +54,7 @@ import { validateSortSpec, validateStoragePredicate, } from '@/lib/table/query-builder/validate' -import { assertCursorSortBinding, decodeCursor } from '@/lib/table/rows/cursor' +import { assertCursorQueryBinding, decodeCursor } from '@/lib/table/rows/cursor' import { createExactEmptyTableRowSecretProvenance, createTableRowSecretProvenanceFromRegistry, @@ -207,7 +207,7 @@ export const listTableRows = defineAuthorizedTableUseCase({ requireIntegerInRange(input.limit, 1, TABLE_LIMITS.MAX_QUERY_LIMIT, 'Limit') try { const cursor = input.cursor ? decodeCursor(input.cursor) : undefined - if (cursor) assertCursorSortBinding(cursor, undefined) + if (cursor) assertCursorQueryBinding(cursor, {}) const result = await queryRows( context.table, { @@ -269,7 +269,7 @@ export const queryTableRows = defineAuthorizedTableUseCase({ ? Object.fromEntries(sortSpec.map((item) => [item.field, item.direction])) : undefined const cursor = input.cursor ? decodeCursor(input.cursor) : undefined - if (cursor) assertCursorSortBinding(cursor, sort) + if (cursor) assertCursorQueryBinding(cursor, { sort, predicate }) const result = await queryRows( context.table, { diff --git a/apps/sim/lib/table/columns/option-locks.test.ts b/apps/sim/lib/table/columns/option-locks.test.ts new file mode 100644 index 00000000000..e8a126b1335 --- /dev/null +++ b/apps/sim/lib/table/columns/option-locks.test.ts @@ -0,0 +1,100 @@ +/** + * @vitest-environment node + * + * `updateColumnOptions` is the one column mutator whose lock gating depends on + * the payload: every call is a schema change, and a call that drops options also + * rewrites cells. Both halves are asserted here because an options-only payload + * is the shape that reaches this mutator from `PATCH .../columns`, and it used + * to be the single column write with no lock assert at all. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableDefinition, TableLocks } from '@/lib/table/types' + +const { mockWithLockedTable } = vi.hoisted(() => ({ mockWithLockedTable: vi.fn() })) + +vi.mock('@/lib/table/service', () => ({ withLockedTable: mockWithLockedTable })) + +import { updateColumnOptions } from '@/lib/table/columns/service' + +const UNLOCKED: TableLocks = { + schemaLocked: false, + insertLocked: false, + updateLocked: false, + deleteLocked: false, +} + +const COLUMN = { + id: 'col_status', + name: 'Status', + type: 'select' as const, + options: [ + { id: 'opt_open', name: 'Open' }, + { id: 'opt_done', name: 'Done' }, + ], +} + +function makeTable(locks: Partial): TableDefinition { + return { + id: 'tbl_1', + name: 'Tasks', + schema: { columns: [COLUMN] }, + rowCount: 3, + maxRows: 100, + workspaceId: 'ws_1', + createdBy: 'user_1', + locks: { ...UNLOCKED, ...locks }, + createdAt: new Date(), + updatedAt: new Date(), + } as unknown as TableDefinition +} + +/** + * Any transaction use is a failure: every assert under test must fire before the + * mutator touches the database, so the stub has no usable surface. + */ +const FORBIDDEN_TRX = new Proxy( + {}, + { + get(_target, prop) { + throw new Error(`Transaction used after a lock assert should have refused: ${String(prop)}`) + }, + } +) + +function runWith(table: TableDefinition, options: Array<{ id: string; name: string }>) { + mockWithLockedTable.mockImplementation( + async (_tableId: string, mutate: (t: TableDefinition, trx: unknown) => Promise) => + mutate(table, FORBIDDEN_TRX) + ) + return updateColumnOptions({ tableId: 'tbl_1', columnName: 'Status', options }, 'req-1') +} + +describe('updateColumnOptions lock gating', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('refuses an options-only edit on a schema-locked table', async () => { + await expect( + runWith(makeTable({ schemaLocked: true }), [ + ...COLUMN.options, + { id: 'opt_new', name: 'New' }, + ]) + ).rejects.toMatchObject({ statusCode: 423, lock: 'schema' }) + }) + + it('refuses an option REMOVAL on a delete-locked table', async () => { + await expect( + runWith(makeTable({ deleteLocked: true }), [{ id: 'opt_open', name: 'Open' }]) + ).rejects.toMatchObject({ statusCode: 423, lock: 'delete' }) + }) + + it('lets a delete-locked table add an option, which clears no cell', async () => { + await expect( + runWith(makeTable({ deleteLocked: true }), [ + ...COLUMN.options, + { id: 'opt_new', name: 'New' }, + ]) + ).rejects.toThrow(/Transaction used/) + }) +}) diff --git a/apps/sim/lib/table/columns/service.ts b/apps/sim/lib/table/columns/service.ts index f7cc0b6803d..ae38ac97609 100644 --- a/apps/sim/lib/table/columns/service.ts +++ b/apps/sim/lib/table/columns/service.ts @@ -1168,9 +1168,16 @@ export async function updateColumnConstraints( /** * Updates the option set (and optional single/multi mode) of a `select` column - * without changing its type. Existing cell values are left untouched — ids that - * no longer match an option render as a neutral fallback pill until reassigned; - * a single↔multi toggle is reconciled lazily on the next row write. + * without changing its type. + * + * Lock gating is split, because the payload decides how destructive the write + * is. Every call changes the schema, so `assertSchemaMutable` always runs. A + * payload that DROPS options additionally rewrites `user_table_rows.data` (see + * {@link clearRemovedSelectOptions}) — exactly the cell destruction the delete + * lock exists to refuse — so that case escalates to `assertColumnDestructive`. + * Adding, reordering, or renaming options and toggling `multiple` never clear a + * cell (a multi→single toggle refuses rather than truncates), so gating those on + * the delete lock would block a non-destructive edit. */ export async function updateColumnOptions( data: UpdateColumnOptionsData, @@ -1180,6 +1187,8 @@ export async function updateColumnOptions( return withLockedTable( data.tableId, async (table, trx) => { + assertSchemaMutable(table) + const schema = table.schema const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) if (columnIndex === -1) { @@ -1220,6 +1229,9 @@ export async function updateColumnOptions( // migrations; the checks in between need to read the target value. const targetRequired = !!(data.required ?? column.required) + // Dropping an option is a row-data rewrite, not a schema-only edit. + if (removedAny) assertColumnDestructive(table) + if (togglingCardinality || removedAny) { const timeoutMs = scaledStatementTimeoutMs(table.rowCount ?? 0, { baseMs: 60_000, diff --git a/apps/sim/lib/table/constants.ts b/apps/sim/lib/table/constants.ts index 7caf148ff2f..8fa6a4432d1 100644 --- a/apps/sim/lib/table/constants.ts +++ b/apps/sim/lib/table/constants.ts @@ -45,6 +45,20 @@ export const TABLE_LIMITS = { EXPORT_ASYNC_THRESHOLD_ROWS: 10000, /** Cap on the exclusion set ("select all, minus these") sent to an async delete job. */ MAX_EXCLUDE_ROW_IDS: 10000, + /** + * Matching cells one Find returns. The scan fetches one extra to decide + * `truncated`; matches carry no cursor, so a caller past the cap narrows its + * predicate instead of paging. Published in the response contract — a cap a + * caller cannot see is a cap it cannot plan around. + */ + MAX_FIND_MATCHES: 1000, + /** + * Saved views per table. The views list is a single unpaginated full-set read + * (`GET /tables/{id}/views` always answers `nextCursor: null`), so the write + * side is what keeps that set small — the same shape as the folder cap, which + * bounds every reader that materializes a workspace's folder tree. + */ + MAX_VIEWS_PER_TABLE: 100, } as const /** @@ -72,6 +86,14 @@ export const DEFAULT_TABLE_PLAN_LIMITS = { }, } as const +/** + * Explicit row ids one column run may target. The largest table any plan allows + * is the ceiling: a longer list necessarily names rows that do not exist, and + * the run command rejects it. Declared on the request contract so the refusal + * is a documented bound rather than a surprise from the domain. + */ +export const MAX_RUN_TARGET_ROW_IDS = DEFAULT_TABLE_PLAN_LIMITS.enterprise.maxRowsPerTable + /** * Byte budget at which a **bounded** page (one with an explicit `limit`) is cut * short. Defaults to the 5MB query-result budget and can be overridden with diff --git a/apps/sim/lib/table/errors.ts b/apps/sim/lib/table/errors.ts index 6b80b6a7260..d54cf0b4a69 100644 --- a/apps/sim/lib/table/errors.ts +++ b/apps/sim/lib/table/errors.ts @@ -6,6 +6,7 @@ export type TableQueryErrorCode = | 'TABLE_QUERY_RESULT_TOO_LARGE' | 'INVALID_CURSOR' | 'CURSOR_SORT_CONFLICT' + | 'CURSOR_FILTER_CONFLICT' | 'INVALID_FILTER' | 'INVALID_ORDER' diff --git a/apps/sim/lib/table/orchestration/import-resource.test.ts b/apps/sim/lib/table/orchestration/import-resource.test.ts index 7270f8d1674..478bc8a2e6b 100644 --- a/apps/sim/lib/table/orchestration/import-resource.test.ts +++ b/apps/sim/lib/table/orchestration/import-resource.test.ts @@ -47,7 +47,11 @@ vi.mock('@/lib/uploads/upload-session/service', () => ({ vi.mock('@/lib/users/queries', () => ({ getUserSettings: mockGetUserSettings })) import { CSV_DURABLE_MAX_FILE_SIZE_BYTES } from '@/lib/table/import' -import { createAuthorizedTableImportResource } from '@/lib/table/orchestration/import-resource' +import { + createAuthorizedTableImportResource, + findTableImportResource, + getTableImportResource, +} from '@/lib/table/orchestration/import-resource' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' const SOURCE = { type: 'workspace_file' as const, fileId: 'file-1' } @@ -179,3 +183,71 @@ describe('createAuthorizedTableImportResource upload size', () => { expect(mockCreateUploadSession).not.toHaveBeenCalled() }) }) + +/** + * `type = 'import'` rows are written by the first-party CSV paths too, without + * the v2 payload. Those ids are reachable from a v2 read — `GET /tables/{id}` + * hands the caller the running job's id — so an unreadable job must answer 404, + * not an unclassified error the v2 policy can only render as a 500. + */ +describe('findTableImportResource on a job that is not a v2 import resource', () => { + const IMPORT_ID = 'job-1' + + function job(overrides: Record) { + return { + id: IMPORT_ID, + tableId: 'table-1', + workspaceId: WORKSPACE_ID, + type: 'import', + status: 'running', + payload: null, + rowsProcessed: 0, + error: null, + startedAt: new Date('2026-08-04T12:00:00.000Z'), + updatedAt: new Date('2026-08-04T12:00:00.000Z'), + completedAt: null, + ...overrides, + } + } + + const PAYLOAD = { + kind: 'table_import', + userId: 'user-1', + source: SOURCE, + target: TARGET, + options: {}, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('reads a first-party import job with a null payload as absent', async () => { + mockDbLimit.mockResolvedValue([job({})]) + + await expect(findTableImportResource({ importId: IMPORT_ID })).resolves.toBeNull() + await expect(getTableImportResource({ importId: IMPORT_ID })).rejects.toMatchObject({ + code: 'not_found', + }) + }) + + it('reads a job in a status the resource cannot represent as absent', async () => { + mockDbLimit.mockResolvedValue([job({ payload: PAYLOAD, status: 'queued' })]) + + await expect(findTableImportResource({ importId: IMPORT_ID })).resolves.toBeNull() + await expect(getTableImportResource({ importId: IMPORT_ID })).rejects.toMatchObject({ + code: 'not_found', + }) + }) + + it('still reads a well-formed v2 import job', async () => { + mockDbLimit.mockResolvedValue([job({ payload: PAYLOAD })]) + + await expect(findTableImportResource({ importId: IMPORT_ID })).resolves.toMatchObject({ + id: IMPORT_ID, + status: 'running', + source: SOURCE, + target: TARGET, + }) + }) +}) diff --git a/apps/sim/lib/table/orchestration/import-resource.ts b/apps/sim/lib/table/orchestration/import-resource.ts index e49817440c3..e01d97a0d44 100644 --- a/apps/sim/lib/table/orchestration/import-resource.ts +++ b/apps/sim/lib/table/orchestration/import-resource.ts @@ -183,6 +183,15 @@ export async function getPrincipalTableImportUpload(params: { return upload } +/** + * The import resource an in-flight upload session stands for, without touching + * it. Callers that also mutate the session (abort, complete) build their + * resource from the post-mutation record instead. + */ +export function tableImportResourceFromUpload(upload: UploadSessionRecord): TableImportResource { + return resourceFromUpload(upload, tableImportBodyFromUpload(upload)) +} + export async function abortAuthorizedTableImportUpload( upload: UploadSessionRecord, principal: Principal @@ -201,6 +210,17 @@ export async function getTableImportResource(params: { return record } +/** + * The `table_jobs` row for an import id, or `null` when there is no import + * resource behind that id. + * + * `type = 'import'` is NOT sufficient to make a job one of these resources: the + * first-party CSV paths write import jobs with a null payload, and a job may + * carry a lifecycle status this resource has no public state for. Neither is a + * server fault — the id simply does not name a readable import — so both read + * back as `null` and surface as the 404 they are, rather than throwing an + * unclassified error that the v2 error policy can only render as a 500. + */ export async function findTableImportResource(params: { importId: string assertedWorkspaceId?: string @@ -220,15 +240,18 @@ export async function findTableImportResource(params: { .limit(1) if (!job) return null const payload = parseImportJobPayload(job.payload) + if (!payload) return null + const status = tableImportStatus(job.status) + if (!status) return null return { id: job.id, workspaceId: job.workspaceId, userId: payload.userId, - source: v2TableImportSourceSchema.parse(payload.source), - target: v2TableImportTargetSchema.parse(payload.target), + source: payload.source, + target: payload.target, options: payload.options, tableId: job.tableId, - status: tableImportStatus(job.status), + status, rowsProcessed: job.rowsProcessed, error: job.error, createdAt: job.startedAt, @@ -469,10 +492,21 @@ function importOptions(body: V2CreateTableImportBody): TableImportJobPayload['op } } -function parseImportJobPayload(payload: unknown): TableImportJobPayload { - if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { - throw new Error('Table import job is missing its payload') - } +interface ParsedTableImportPayload { + userId: string + source: V2TableImportSource + target: V2TableImportTarget + options: TableImportJobPayload['options'] +} + +/** + * Reads a `table_jobs.payload` as an import-resource payload, or `null` when it + * is not one. A null payload is the normal shape for the first-party CSV import + * paths, which write `type = 'import'` jobs without one, so failing to parse is + * an ordinary "not this resource" answer rather than an error condition. + */ +function parseImportJobPayload(payload: unknown): ParsedTableImportPayload | null { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null const candidate = payload as Partial if ( candidate.kind !== 'table_import' || @@ -480,11 +514,17 @@ function parseImportJobPayload(payload: unknown): TableImportJobPayload { !candidate.options || typeof candidate.options !== 'object' ) { - throw new Error('Table import job has an invalid payload') + return null + } + const source = v2TableImportSourceSchema.safeParse(candidate.source) + const target = v2TableImportTargetSchema.safeParse(candidate.target) + if (!source.success || !target.success) return null + return { + userId: candidate.userId, + source: source.data, + target: target.data, + options: candidate.options, } - v2TableImportSourceSchema.parse(candidate.source) - v2TableImportTargetSchema.parse(candidate.target) - return candidate as TableImportJobPayload } async function validateTarget( @@ -582,9 +622,15 @@ function assertCsvFileName(fileName: string): void { } } -function tableImportStatus(status: string): TableImportStatus { +/** + * The public lifecycle state for a job status, or `null` when the job is in a + * state this resource cannot represent. `table_jobs.status` is an unconstrained + * text column shared by every job kind, so a value outside the four documented + * import states means "no readable import here" — a 404 — not a server fault. + */ +function tableImportStatus(status: string): TableImportStatus | null { if (status !== 'running' && status !== 'ready' && status !== 'failed' && status !== 'canceled') { - throw new Error(`Invalid table import job status: ${status}`) + return null } return status } diff --git a/apps/sim/lib/table/rows/cursor.test.ts b/apps/sim/lib/table/rows/cursor.test.ts index bae39a35c66..419be0837c1 100644 --- a/apps/sim/lib/table/rows/cursor.test.ts +++ b/apps/sim/lib/table/rows/cursor.test.ts @@ -1,21 +1,25 @@ /** * @vitest-environment node * - * Opaque cursor encode/decode and the cursor↔sort binding. A cursor encodes a - * position in one specific ordering; replaying it under any other ordering - * silently pages the wrong sequence, so binding violations must throw - * CURSOR_SORT_CONFLICT rather than return wrong rows. + * Opaque cursor encode/decode and the cursor↔query binding. A cursor encodes a + * position in one specific ordering of one specific row set; replaying it under + * any other ordering or filter silently pages the wrong sequence, so binding + * violations must throw rather than return wrong rows. */ import { describe, expect, it } from 'vitest' import { TableQueryValidationError } from '@/lib/table/errors' import { - assertCursorSortBinding, + assertCursorQueryBinding, + canonicalFilterKey, canonicalSortKey, decodeCursor, encodeCursor, } from '@/lib/table/rows/cursor' +import type { TablePredicate } from '@/lib/table/types' const ROW = { id: 'row_1', orderKey: 'a1' } +const ACTIVE: TablePredicate = { all: [{ field: 'status', op: 'eq', value: 'active' }] } +const ARCHIVED: TablePredicate = { all: [{ field: 'status', op: 'eq', value: 'archived' }] } describe('cursor↔sort binding (bugbot round 2)', () => { it('stamps an offset cursor with the sort it was minted under', () => { @@ -32,15 +36,15 @@ describe('cursor↔sort binding (bugbot round 2)', () => { it('accepts replay under the identical sort', () => { const decoded = { offset: 100, sortKey: canonicalSortKey({ col_a: 'desc' }) } - expect(() => assertCursorSortBinding(decoded, { col_a: 'desc' })).not.toThrow() + expect(() => assertCursorQueryBinding(decoded, { sort: { col_a: 'desc' } })).not.toThrow() }) it('rejects replay under a DIFFERENT sort', () => { const decoded = { offset: 100, sortKey: canonicalSortKey({ col_a: 'desc' }) } for (const sort of [{ col_a: 'asc' as const }, { col_b: 'desc' as const }, undefined]) { - expect(() => assertCursorSortBinding(decoded, sort)).toThrow(TableQueryValidationError) + expect(() => assertCursorQueryBinding(decoded, { sort })).toThrow(TableQueryValidationError) try { - assertCursorSortBinding(decoded, sort) + assertCursorQueryBinding(decoded, { sort }) } catch (e) { expect((e as TableQueryValidationError).code).toBe('CURSOR_SORT_CONFLICT') } @@ -55,10 +59,10 @@ describe('cursor↔sort binding (bugbot round 2)', () => { }) const decoded = decodeCursor(token) expect(decoded.sortKey).toBeUndefined() - expect(() => assertCursorSortBinding(decoded, { col_a: 'asc' })).toThrow( + expect(() => assertCursorQueryBinding(decoded, { sort: { col_a: 'asc' } })).toThrow( /different sort|sorted query/ ) - expect(() => assertCursorSortBinding(decoded, undefined)).not.toThrow() + expect(() => assertCursorQueryBinding(decoded, {})).not.toThrow() }) it('keyset cursors stay default-order only and never carry a sort stamp', () => { @@ -66,8 +70,10 @@ describe('cursor↔sort binding (bugbot round 2)', () => { const decoded = decodeCursor(token) expect(decoded.after).toEqual({ orderKey: 'a1', id: 'row_1' }) expect(decoded.sortKey).toBeUndefined() - expect(() => assertCursorSortBinding(decoded, { col_a: 'asc' })).toThrow(/sorted query/) - expect(() => assertCursorSortBinding(decoded, undefined)).not.toThrow() + expect(() => assertCursorQueryBinding(decoded, { sort: { col_a: 'asc' } })).toThrow( + /sorted query/ + ) + expect(() => assertCursorQueryBinding(decoded, {})).not.toThrow() }) it('sort key order is significant (priority is part of the identity)', () => { @@ -76,3 +82,103 @@ describe('cursor↔sort binding (bugbot round 2)', () => { ) }) }) + +describe('cursor↔filter binding', () => { + it('stamps a sorted page with the predicate it was minted under', () => { + const decoded = decodeCursor( + encodeCursor({ + lastRow: { id: 'row_1', orderKey: null }, + keysetValid: false, + nextOffset: 100, + sort: { col_a: 'desc' }, + predicate: ACTIVE, + }) + ) + expect(decoded.offset).toBe(100) + expect(decoded.filterKey).toBe(canonicalFilterKey({ predicate: ACTIVE })) + }) + + it('rejects replaying a page-2 offset against a DIFFERENT predicate', () => { + const decoded = decodeCursor( + encodeCursor({ + lastRow: { id: 'row_1', orderKey: null }, + keysetValid: false, + nextOffset: 100, + sort: { name: 'asc' }, + predicate: ACTIVE, + }) + ) + + expect(() => + assertCursorQueryBinding(decoded, { sort: { name: 'asc' }, predicate: ARCHIVED }) + ).toThrow(TableQueryValidationError) + try { + assertCursorQueryBinding(decoded, { sort: { name: 'asc' }, predicate: ARCHIVED }) + } catch (e) { + expect((e as TableQueryValidationError).code).toBe('CURSOR_FILTER_CONFLICT') + } + expect(() => + assertCursorQueryBinding(decoded, { sort: { name: 'asc' }, predicate: ACTIVE }) + ).not.toThrow() + }) + + it('rejects dropping the predicate from a filtered offset cursor', () => { + const decoded = decodeCursor( + encodeCursor({ + lastRow: { id: 'row_1', orderKey: null }, + keysetValid: false, + nextOffset: 100, + predicate: ACTIVE, + }) + ) + expect(() => assertCursorQueryBinding(decoded, {})).toThrow(/different filter/) + }) + + it('rejects adding a predicate to an unfiltered offset cursor', () => { + const decoded = decodeCursor( + encodeCursor({ + lastRow: { id: 'row_1', orderKey: null }, + keysetValid: false, + nextOffset: 100, + }) + ) + expect(decoded.filterKey).toBeUndefined() + expect(() => assertCursorQueryBinding(decoded, { predicate: ACTIVE })).toThrow( + /different filter/ + ) + }) + + it('binds the compound cursor, whose offset also counts filtered rows', () => { + const decoded = decodeCursor( + encodeCursor({ + lastRow: { id: 'row_9', orderKey: null }, + keysetValid: true, + nextOffset: 40, + seekBase: { anchor: { orderKey: 'a1', id: 'row_1' }, offsetFromAnchor: 12 }, + predicate: ACTIVE, + }) + ) + expect(decoded.after).toEqual({ orderKey: 'a1', id: 'row_1' }) + expect(decoded.offset).toBe(12) + expect(() => assertCursorQueryBinding(decoded, { predicate: ARCHIVED })).toThrow( + /different filter/ + ) + expect(() => assertCursorQueryBinding(decoded, { predicate: ACTIVE })).not.toThrow() + }) + + it('leaves a pure keyset cursor unbound — it names an absolute position', () => { + const decoded = decodeCursor(encodeCursor({ lastRow: ROW, keysetValid: true, nextOffset: 10 })) + expect(decoded.filterKey).toBeUndefined() + expect(() => assertCursorQueryBinding(decoded, { predicate: ACTIVE })).not.toThrow() + }) + + it('fingerprints structurally equal predicates identically, key order aside', () => { + expect( + canonicalFilterKey({ + predicate: { all: [{ op: 'eq', field: 'status', value: 'active' }] } as TablePredicate, + }) + ).toBe(canonicalFilterKey({ predicate: ACTIVE })) + expect(canonicalFilterKey({})).toBeUndefined() + expect(canonicalFilterKey({ filter: {} })).toBeUndefined() + }) +}) diff --git a/apps/sim/lib/table/rows/cursor.ts b/apps/sim/lib/table/rows/cursor.ts index 0d2ab4ab206..192b9f5afdd 100644 --- a/apps/sim/lib/table/rows/cursor.ts +++ b/apps/sim/lib/table/rows/cursor.ts @@ -13,10 +13,15 @@ * the last keyed anchor, then OFFSET past the unkeyed rows consumed after it. * This only resolves correctly because the seek admits `order_key IS NULL` * rows; a bare `(order_key, id) > (…)` excludes them and strands the tail. + * + * Any shape carrying an offset is stamped with the query state that offset + * counts positions within — the sort AND the filters — and refuses to resume + * under a different one. See {@link assertCursorQueryBinding}. */ +import { createHash } from 'node:crypto' import { TableQueryValidationError } from '@/lib/table/errors' -import type { Sort, TableRow, TableRowsCursor } from '@/lib/table/types' +import type { Filter, Sort, TablePredicate, TableRow, TableRowsCursor } from '@/lib/table/types' /** * Cursor payload version. Every encoded token carries `v`; decode rejects any @@ -26,8 +31,22 @@ import type { Sort, TableRow, TableRowsCursor } from '@/lib/table/types' const CURSOR_VERSION = 1 type CursorBody = { k: string; i: string } | { o: number } | { k: string; i: string; o: number } -type SortBinding = { s?: string } -type CursorPayload = CursorBody & SortBinding & { v: number } +type QueryBinding = { s?: string; p?: string } +type CursorPayload = CursorBody & QueryBinding & { v: number } + +/** + * The filters an offset counts positions within. A cursor carrying an offset is + * bound to both this and the sort; a pure keyset cursor is bound to neither, + * because `(order_key, id)` names an absolute position that stays correct under + * any membership change. + */ +export interface CursorQueryScope { + sort?: Sort | null + /** v2 predicate tree, in the same storage form the query runs under. */ + predicate?: TablePredicate | null + /** Legacy `$`-operator filter, for the surfaces that still send one. */ + filter?: Filter | null +} /** * Canonical fingerprint of a sort for cursor binding. Entry order is the sort @@ -40,6 +59,38 @@ export function canonicalSortKey(sort: Sort | null | undefined): string | undefi return entries.length > 0 ? JSON.stringify(entries) : undefined } +/** + * Deterministic JSON for a filter tree: object keys sorted so two structurally + * equal filters serialize identically regardless of the key order the caller's + * JSON happened to arrive in. Array order is preserved — reordering an `in` list + * is treated as a different filter, which only ever costs a restart. + */ +function canonicalJson(value: unknown): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null' + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]` + const entries = Object.entries(value as Record) + .filter(([, entry]) => entry !== undefined) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`).join(',')}}` +} + +/** + * Fingerprint of the filters a page was produced under, or `undefined` for an + * unfiltered read. Hashed rather than embedded: a predicate tree can be up to + * the request-body ceiling, and the cursor has to stay a short opaque token. + * SHA-256 over the canonical form, so a caller cannot cheaply construct a second + * predicate that replays another sequence's offsets. + */ +export function canonicalFilterKey( + scope: Pick +): string | undefined { + const predicate = scope.predicate ?? undefined + const filter = scope.filter && Object.keys(scope.filter).length > 0 ? scope.filter : undefined + if (!predicate && !filter) return undefined + const canonical = canonicalJson(predicate ? { predicate } : { filter }) + return createHash('sha256').update(canonical).digest('base64url').slice(0, 22) +} + /** * A cursor is only valid for the exact query shape it was minted under: * keyset/compound cursors encode a position in the DEFAULT `(order_key, id)` @@ -47,13 +98,21 @@ export function canonicalSortKey(sort: Sort | null | undefined): string | undefi * sort. Replaying either against a different ordering silently pages the wrong * sequence — rows skipped or duplicated with no error. Throws * `CURSOR_SORT_CONFLICT` so callers restart paging without the cursor. + * + * Any offset — the whole-view one and the compound cursor's offset-from-anchor + * alike — counts rows in the FILTERED sequence, so it is bound to the filters as + * well. Replaying an offset under a different predicate lands at that ordinal of + * a sequence the caller never asked for: a narrower filter silently returns an + * empty page the caller reads as "no more matches". That mismatch throws + * `CURSOR_FILTER_CONFLICT`. A pure keyset cursor carries no offset and is left + * unbound — `(order_key, id)` is an absolute position, correct under any filter. */ -export function assertCursorSortBinding( - decoded: { after?: TableRowsCursor; offset?: number; sortKey?: string }, - sort: Sort | null | undefined +export function assertCursorQueryBinding( + decoded: { after?: TableRowsCursor; offset?: number; sortKey?: string; filterKey?: string }, + scope: CursorQueryScope ): void { - const requested = canonicalSortKey(sort) - if (decoded.after && requested !== undefined) { + const requestedSort = canonicalSortKey(scope.sort) + if (decoded.after && requestedSort !== undefined) { throw new TableQueryValidationError( 'Cursor is not valid for a sorted query. Restart paging without the cursor.', 'CURSOR_SORT_CONFLICT' @@ -62,13 +121,19 @@ export function assertCursorSortBinding( if ( decoded.after === undefined && decoded.offset !== undefined && - decoded.sortKey !== requested + decoded.sortKey !== requestedSort ) { throw new TableQueryValidationError( 'Cursor was created under a different sort. Restart paging without the cursor.', 'CURSOR_SORT_CONFLICT' ) } + if (decoded.offset !== undefined && decoded.filterKey !== canonicalFilterKey(scope)) { + throw new TableQueryValidationError( + 'Cursor was created under a different filter. Restart paging without the cursor.', + 'CURSOR_FILTER_CONFLICT' + ) + } } function invalidCursor(): never { @@ -102,6 +167,10 @@ export function encodeCursor(args: { seekBase?: { anchor: TableRowsCursor; offsetFromAnchor: number } /** The sort the page was produced under — stamps offset cursors so they can't be replayed against a different ordering. */ sort?: Sort | null + /** The predicate the page was produced under — stamps any offset so it can't be replayed against a different row set. */ + predicate?: TablePredicate | null + /** The legacy filter the page was produced under, for surfaces that send one instead of a predicate. */ + filter?: Filter | null }): string { let body: CursorBody if (args.keysetValid && args.lastRow.orderKey) { @@ -120,11 +189,15 @@ export function encodeCursor(args: { body = { o: args.nextOffset } } const sortKey = canonicalSortKey(args.sort) + const filterKey = canonicalFilterKey(args) const payload: CursorPayload = { ...body, // Only the pure-offset shape can exist under a custom sort; keyset and // compound shapes are default-order by construction and carry no binding. ...('k' in body || sortKey === undefined ? {} : { s: sortKey }), + // Every offset — whole-view or offset-from-anchor — counts filtered rows, so + // both the pure-offset and compound shapes carry the filter stamp. + ...('o' in body && filterKey !== undefined ? { p: filterKey } : {}), v: CURSOR_VERSION, } return toBase64Url(JSON.stringify(payload)) @@ -136,6 +209,8 @@ export function decodeCursor(token: string): { offset?: number /** Sort fingerprint an offset cursor was minted under; absent = default order. */ sortKey?: string + /** Filter fingerprint an offset cursor was minted under; absent = unfiltered. */ + filterKey?: string } { let payload: unknown try { @@ -152,10 +227,13 @@ export function decodeCursor(token: string): { const hasKeyset = typeof record.k === 'string' && typeof record.i === 'string' const hasOffset = typeof record.o === 'number' && Number.isInteger(record.o) && record.o >= 0 + const filterBinding = typeof record.p === 'string' ? { filterKey: record.p } : {} + if (hasKeyset && hasOffset) { return { after: { orderKey: record.k as string, id: record.i as string }, offset: record.o as number, + ...filterBinding, } } if (hasKeyset) { @@ -165,6 +243,7 @@ export function decodeCursor(token: string): { return { offset: record.o as number, ...(typeof record.s === 'string' ? { sortKey: record.s } : {}), + ...filterBinding, } } invalidCursor() diff --git a/apps/sim/lib/table/rows/service.ts b/apps/sim/lib/table/rows/service.ts index 7a65cfd10b3..d9ab9aa15f8 100644 --- a/apps/sim/lib/table/rows/service.ts +++ b/apps/sim/lib/table/rows/service.ts @@ -899,9 +899,6 @@ export interface FindRowMatch { column: string } -/** Max matching cells returned by {@link findRowMatches}; one extra is fetched to detect truncation. */ -const FIND_MATCH_LIMIT = 1000 - /** * Builds a SQL text expression that resolves a scanned select cell (`kv.value`, * keyed by `kv.key`) to its option **name(s)** — the label the user searches by, @@ -1020,13 +1017,13 @@ export async function findRowMatches( WHERE (kv.value ILIKE ${pattern}${nameMatchClause}) AND ${inArray(sql`kv.key`, columnIds)} ORDER BY o.ordinal - LIMIT ${FIND_MATCH_LIMIT + 1} + LIMIT ${TABLE_LIMITS.MAX_FIND_MATCHES + 1} `) }) const all = Array.from(result) - const truncated = all.length > FIND_MATCH_LIMIT - const sliced = truncated ? all.slice(0, FIND_MATCH_LIMIT) : all + const truncated = all.length > TABLE_LIMITS.MAX_FIND_MATCHES + const sliced = truncated ? all.slice(0, TABLE_LIMITS.MAX_FIND_MATCHES) : all const matches: FindRowMatch[] = sliced.map((r) => ({ ordinal: Number(r.ordinal), rowId: r.id, @@ -1243,6 +1240,8 @@ export async function queryRows( ? { anchor: fetched.anchor, offsetFromAnchor: fetched.anchorOffset } : undefined, sort, + predicate, + filter, }) : null diff --git a/apps/sim/lib/table/views/service.test.ts b/apps/sim/lib/table/views/service.test.ts index f33affcef5d..8dd7694cca3 100644 --- a/apps/sim/lib/table/views/service.test.ts +++ b/apps/sim/lib/table/views/service.test.ts @@ -1,18 +1,24 @@ /** * @vitest-environment node */ +import { db } from '@sim/db' import { tableViews } from '@sim/db/schema' import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ColumnDefinition, TableViewConfig } from '@/lib/table/types' -const { mockSignalTableViewsChanged } = vi.hoisted(() => ({ +const { mockSignalTableViewsChanged, mockWithLockedTable } = vi.hoisted(() => ({ mockSignalTableViewsChanged: vi.fn(), + mockWithLockedTable: vi.fn(), })) vi.mock('@/lib/table/events', () => ({ signalTableViewsChanged: mockSignalTableViewsChanged, })) +vi.mock('@/lib/table/service', () => ({ + withLockedTable: mockWithLockedTable, +})) +import { TABLE_LIMITS } from '@/lib/table/constants' import { createTableView, deleteTableView, @@ -137,9 +143,14 @@ describe('table-view mutations signal collaborators', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() + mockWithLockedTable.mockImplementation( + async (_tableId: string, mutate: (table: unknown, trx: unknown) => unknown) => + mutate({ id: 'table-1' }, db) + ) }) it('createTableView signals the table after inserting', async () => { + queueTableRows(tableViews, [{ total: 0 }]) // the in-lock view-count check dbChainMockFns.returning.mockResolvedValueOnce([viewRow]) await createTableView({ @@ -255,3 +266,57 @@ describe('getTableView', () => { expect(await getTableView('view-elsewhere', 'table-1', columns)).toBeNull() }) }) + +/** + * `GET /tables/{id}/views` returns every view in one unpaginated page and + * declares the set bounded. Nothing made that true, so the ceiling is asserted + * on the write that could cross it. + */ +describe('saved-view ceiling', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockWithLockedTable.mockImplementation( + async (_tableId: string, mutate: (table: unknown, trx: unknown) => unknown) => + mutate({ id: 'table-1' }, db) + ) + }) + + function create() { + return createTableView({ + tableId: 'table-1', + workspaceId: 'ws-1', + name: 'Another View', + config: {}, + userId: 'user-1', + columns: [], + }) + } + + it('refuses a create that would cross MAX_VIEWS_PER_TABLE', async () => { + queueTableRows(tableViews, [{ total: TABLE_LIMITS.MAX_VIEWS_PER_TABLE }]) + + await expect(create()).rejects.toMatchObject({ name: 'TableViewValidationError' }) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + expect(mockSignalTableViewsChanged).not.toHaveBeenCalled() + }) + + it('allows the create that lands exactly on the ceiling', async () => { + queueTableRows(tableViews, [{ total: TABLE_LIMITS.MAX_VIEWS_PER_TABLE - 1 }]) + dbChainMockFns.returning.mockResolvedValueOnce([ + { + id: 'view-100', + tableId: 'table-1', + workspaceId: 'ws-1', + name: 'Another View', + config: {}, + isDefault: false, + createdBy: 'user-1', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + }, + ]) + + await expect(create()).resolves.toMatchObject({ id: 'view-100' }) + }) +}) diff --git a/apps/sim/lib/table/views/service.ts b/apps/sim/lib/table/views/service.ts index 6f0a6c9b690..5896c31e961 100644 --- a/apps/sim/lib/table/views/service.ts +++ b/apps/sim/lib/table/views/service.ts @@ -13,11 +13,12 @@ import { db } from '@sim/db' import { tableViews } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { and, asc, eq, ne, sql } from 'drizzle-orm' +import { and, asc, count, eq, ne, sql } from 'drizzle-orm' import { getColumnId } from '@/lib/table/column-keys' -import { NAME_PATTERN } from '@/lib/table/constants' +import { NAME_PATTERN, TABLE_LIMITS } from '@/lib/table/constants' import { signalTableViewsChanged } from '@/lib/table/events' import { filterRulesToPredicate, filterToRules } from '@/lib/table/query-builder/converters' +import { withLockedTable } from '@/lib/table/service' import type { ColumnDefinition, Filter, @@ -226,20 +227,46 @@ export interface CreateTableViewData { columns: ColumnDefinition[] } +/** + * Creates a saved view, refusing one that would push the table past + * {@link TABLE_LIMITS.MAX_VIEWS_PER_TABLE}. + * + * The list read returns every view in one unpaginated page, so that promise only + * holds if the write side enforces it. The count and the insert share the + * table's advisory lock — the same device the column mutators use — which is + * what makes the count authoritative against a concurrent create rather than a + * check two racing writers can both pass. + */ export async function createTableView(data: CreateTableViewData): Promise { const name = normalizeName(data.name) - const [row] = await db - .insert(tableViews) - .values({ - id: generateId(), - tableId: data.tableId, - workspaceId: data.workspaceId, - name, - config: data.config, - createdBy: data.userId, - }) - .returning() + const row = await withLockedTable(data.tableId, async (_table, trx) => { + const [existing] = await trx + .select({ total: count() }) + .from(tableViews) + .where( + and(eq(tableViews.tableId, data.tableId), eq(tableViews.workspaceId, data.workspaceId)) + ) + + if (Number(existing?.total ?? 0) >= TABLE_LIMITS.MAX_VIEWS_PER_TABLE) { + throw new TableViewValidationError( + `A table cannot have more than ${TABLE_LIMITS.MAX_VIEWS_PER_TABLE} saved views` + ) + } + + const [created] = await trx + .insert(tableViews) + .values({ + id: generateId(), + tableId: data.tableId, + workspaceId: data.workspaceId, + name, + config: data.config, + createdBy: data.userId, + }) + .returning() + return created + }) logger.info('Created table view', { tableId: data.tableId, viewId: row.id }) // Views are table-wide shared state, so every open reader refetches the list live. From fd68bf8f8d5701491de641bb72fc2c3166dd0ef9 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 16:16:36 -0700 Subject: [PATCH 07/56] docs(v2): record why the two migrate-on-read GETs stay head-safe An enumeration of side-effecting v2 GETs flagged these two for issuing a workflow_blocks update. The write is convergent and would be issued by the next ordinary read, and headSafe: false answers 200 unconditionally, so declaring it would cost HEAD its existence check to prevent nothing. --- .../app/api/v2/workflows/[id]/deployment/route.ts | 14 ++++++++++++++ apps/sim/app/api/v2/workflows/[id]/route.ts | 14 ++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/apps/sim/app/api/v2/workflows/[id]/deployment/route.ts b/apps/sim/app/api/v2/workflows/[id]/deployment/route.ts index 52224636031..1019dc6dd77 100644 --- a/apps/sim/app/api/v2/workflows/[id]/deployment/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/deployment/route.ts @@ -22,6 +22,20 @@ export const revalidate = 0 * undeployed, so reading it would report a deploy time alongside * `isDeployed: false`. */ +/** + * Deliberately head-safe despite issuing a write. + * + * Reading a workflow can trigger a migrate-on-read `workflow_blocks` update when + * `applyBlockMigrations` upgrades a stored block. That write is convergent: it is + * conditional on a migration actually applying, idempotent, and would be issued by + * the next ordinary read regardless, so a `HEAD` only brings it forward. + * + * `headSafe: false` is reserved for effects a probe would otherwise *fabricate* — + * an audit row recording an export or download that never happened — or that reach + * a third party. Declaring it here would also cost real capability, because + * {@link v2HeadNoEffect} answers `200` unconditionally, so a `HEAD` could no longer + * distinguish a workflow that exists from one that does not. + */ export const GET = defineV2JsonRoute({ contract: v2GetWorkflowDeploymentContract, auth: v2ApiKeyAuth, diff --git a/apps/sim/app/api/v2/workflows/[id]/route.ts b/apps/sim/app/api/v2/workflows/[id]/route.ts index 43c40be983f..a0c4aa152b8 100644 --- a/apps/sim/app/api/v2/workflows/[id]/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/route.ts @@ -12,6 +12,20 @@ import { updateWorkflow } from '@/lib/workflows/application/update-workflow' export const revalidate = 0 +/** + * Deliberately head-safe despite issuing a write. + * + * Reading a workflow can trigger a migrate-on-read `workflow_blocks` update when + * `applyBlockMigrations` upgrades a stored block. That write is convergent: it is + * conditional on a migration actually applying, idempotent, and would be issued by + * the next ordinary read regardless, so a `HEAD` only brings it forward. + * + * `headSafe: false` is reserved for effects a probe would otherwise *fabricate* — + * an audit row recording an export or download that never happened — or that reach + * a third party. Declaring it here would also cost real capability, because + * {@link v2HeadNoEffect} answers `200` unconditionally, so a `HEAD` could no longer + * distinguish a workflow that exists from one that does not. + */ export const GET = defineV2JsonRoute({ contract: v2GetWorkflowContract, auth: v2ApiKeyAuth, From a7c03d98b64339f03cb65c0ad12d6fe1d5e1c481 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 16:55:25 -0700 Subject: [PATCH 08/56] fix(api): classify the caller input that reached the driver unvalidated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four families of caller-reachable 500s share one shape: a value the contract admits, the application forwards, and the database rejects. An unclassified driver throw renders as INTERNAL_ERROR, so a bad request came back as a server fault — on pure reads as well as writes. NUL bytes are rejected at the contract boundary, in parseRequest, not per field. A shared string primitive only protects the fields somebody remembers to build on it, and it cannot protect the values that have no string schema at all: a table cell and a predicate value are z.unknown() because their type belongs to the column, not the wire, and those are exactly the values found reaching the driver. One scan over the already validated params/query/body covers every field including the ones nobody has enumerated. Only U+0000 is rejected; every other control character is ordinary content that Postgres stores verbatim. Date bounds on a filter are now parsed, not merely type-checked, with the same normalizer the date column type uses to store cells — so the filter grammar and the storage grammar agree, and gt/gte/lt/lte on both JSONB date columns and the createdAt/updatedAt system columns answer an unparseable bound with 400 instead of an invalid-input-syntax 500. An afterRowId/beforeRowId anchor that does not exist is a classified not-found rather than a bare Error, and a zero-byte knowledge document is refused at admission: every parser rejects an empty buffer outright, so the upload could only ever consume storage and quota on its way to processingStatus failed. --- .../v2/tables/[tableId]/rows/route.test.ts | 34 ++++++ apps/sim/app/api/v2/workflows/route.test.ts | 54 ++++++++ .../lib/api/server/nul-byte-boundary.test.ts | Bin 0 -> 4102 bytes apps/sim/lib/api/server/nul-bytes.ts | 115 ++++++++++++++++++ apps/sim/lib/api/server/validation.ts | 26 ++++ .../application/add-workspace-files.ts | 8 +- .../knowledge/application/documents.test.ts | 24 ++++ .../lib/knowledge/application/documents.ts | 8 +- apps/sim/lib/table/__tests__/sql.test.ts | 70 +++++++++++ .../rows/__tests__/ordering-anchor.test.ts | 56 +++++++++ apps/sim/lib/table/rows/errors.ts | 11 +- apps/sim/lib/table/rows/ordering.ts | 7 +- apps/sim/lib/table/sql.ts | 39 +++++- apps/sim/lib/uploads/shared/types.ts | 12 ++ 14 files changed, 454 insertions(+), 10 deletions(-) create mode 100644 apps/sim/lib/api/server/nul-byte-boundary.test.ts create mode 100644 apps/sim/lib/api/server/nul-bytes.ts create mode 100644 apps/sim/lib/table/rows/__tests__/ordering-anchor.test.ts diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/route.test.ts index b6169d46d63..dc1036483b6 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/route.test.ts @@ -205,4 +205,38 @@ describe('/api/v2/tables/[tableId]/rows', () => { }, }) }) + /** + * A table cell is `z.unknown()` on the wire — its type is decided by the + * column, not the contract — so no string schema guards it. A `U+0000` in a + * cell value or a predicate value therefore travelled all the way to the + * driver and came back as `500 INTERNAL_ERROR`. + */ + describe('NUL bytes in table values', () => { + const NUL = '\u0000' + + it('rejects a NUL in a cell value before the row use case runs', async () => { + const response = await POST( + request('POST', { workspaceId: WORKSPACE_ID, data: { name: `a${NUL}b` } }), + CONTEXT + ) + + expect(response.status).toBe(400) + expect((await response.json()).error.code).toBe('BAD_REQUEST') + expect(mocks.createRows).not.toHaveBeenCalled() + }) + + it('rejects a NUL in a predicate value on the update-by-filter path', async () => { + const response = await PATCH( + request('PATCH', { + workspaceId: WORKSPACE_ID, + filter: { all: [{ field: 'name', op: 'contains', value: `a${NUL}b` }] }, + data: { name: 'Grace' }, + }), + CONTEXT + ) + + expect(response.status).toBe(400) + expect(mocks.updateRows).not.toHaveBeenCalled() + }) + }) }) diff --git a/apps/sim/app/api/v2/workflows/route.test.ts b/apps/sim/app/api/v2/workflows/route.test.ts index c93ae286fa8..71f70b73f51 100644 --- a/apps/sim/app/api/v2/workflows/route.test.ts +++ b/apps/sim/app/api/v2/workflows/route.test.ts @@ -173,4 +173,58 @@ describe('/api/v2/workflows', () => { expect(response.status).toBe(401) expect((await response.json()).error.code).toBe('UNAUTHORIZED') }) + + /** + * A `U+0000` in caller text is a driver-level throw on the way to a `text` + * column, and an unclassified throw is a `500 INTERNAL_ERROR`. The read case + * needed no write at all — a search term was enough — so it is asserted here + * against the real route, not only against the parser. + */ + describe('NUL bytes in caller text', () => { + const NUL = '\u0000' + + it('rejects a NUL search term with the v2 validation envelope, not a 500', async () => { + const response = await GET( + new NextRequest( + `http://localhost/api/v2/workflows?workspaceId=${WORKSPACE_ID}&search=${encodeURIComponent(`a${NUL}b`)}`, + { headers: { 'x-api-key': 'secret' } } + ) + ) + + expect(response.status).toBe(400) + expect((await response.json()).error.code).toBe('BAD_REQUEST') + expect(mocks.listWorkflows).not.toHaveBeenCalled() + }) + + it('rejects a NUL workflow name before the create use case runs', async () => { + const response = await POST( + new NextRequest('http://localhost/api/v2/workflows', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, + body: JSON.stringify({ name: `a${NUL}b`, workspaceId: WORKSPACE_ID }), + }) + ) + + expect(response.status).toBe(400) + expect((await response.json()).error.code).toBe('BAD_REQUEST') + expect(mocks.createWorkflow).not.toHaveBeenCalled() + }) + + it('rejects a NUL description on the same body', async () => { + const response = await POST( + new NextRequest('http://localhost/api/v2/workflows', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, + body: JSON.stringify({ + name: 'Daily digest', + description: `notes${NUL}`, + workspaceId: WORKSPACE_ID, + }), + }) + ) + + expect(response.status).toBe(400) + expect(mocks.createWorkflow).not.toHaveBeenCalled() + }) + }) }) diff --git a/apps/sim/lib/api/server/nul-byte-boundary.test.ts b/apps/sim/lib/api/server/nul-byte-boundary.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f6967f9bf583e531eadfa7c218e57f640b32f7cc GIT binary patch literal 4102 zcmd5<+iu%N5baw5eZ@QkAeG9JgS0OO+~$Iy2#_0;_{lcP6*-DFCb{hH(uyny=tuMm z`z1ZIOI;i*fzu>t{9=*ZnVmU%=FIHq?%f{Ur3aVVv-1aBUutXWifd1`DHz{IJzX`% zdRkI$S$TeL@{2M5*Oy&K>ZI3$Gi3MT1H5UJpW!(K zDlmnY;_Zm8hGy~(*aSt^qKJZ%Gx{fsEcc;58tZ z&$v?80y7u#I5DPV1&;`ZR)gNEzXlfsvY!R!?@vB~W$<=wf5*z^0DTKc2PGmAG{0&x zpqQ%M=TPHg*^HL1Y~QXr)`-{ZS>fY1&q=vOozt|fb0H7Ni~4y4vZj%-9ZUKoT(|67 z+pXzpEG%1NF}SH3X;&8{?)n2-f)uM>Zxp5IA!R2&9pWz|U6ZO8l&i92d*IrpDK!@q zOM|o{MKfg!dS#q{i}NJ5)>uNwe#SIc3z0T&uq^ZZ-e;7XvaRX?ycS_eVKquWad@z1 zj*YCwTxF<4TLvR@7l>FJ9% zPsT4EKKtXzc>Hplk%bVj#eNF8iL;*%4+j!4d=yB?(Hs>T{#%jcVNl?;j;4u7(q!Qo z>qRu=c$5#hK(eAVPE1=D$}UirD7)id!!c=}CecJt6E6ZTz-bOj-!fy_30J1>Ea81M$xvp!AX-uS(`Y($ZJx7p{h>FHIGw7}aX&26 zQ~GdCN3K0y*2N2%RK8t%1D4TOx8+Wd#c*RHk|he~&2#1R*~?w^C1|==S|svx*{Swv zBAT3>)9O5`FcPCKyPL2^i3Yk(gETnhsP2Jsa^@n^UjlGqE?~ZH1j~*7pC-~I@hO|A zBA!8pkiX3-d^KQ0KSR(-Y-9L!@0K_RZJ@U1x~&*hh4PzP=7XuR6?h}fEn)+o|C!AZ z?5<}nMT{N?1v#Q!o`2sMhv)_3P4u^=a)h|!?*%VFJL92M}Ht<^$bIYh#>UAL)Ar$scEgKvnoD8Jll=W$9tM%P&=Yz zJs`;32SE=ra1jNF&@$J*vYGeI(P&f}bee}_{7mi(p~SMYg7Z1HdEx@$w^otBU8tcXmLcZa0}CprOBD{lG7#06nR~yx*Nd% zc0$`yQWyamFH@JyRv24MNjWAtTyyf7v6F*Y;7ha<}$p8QV literal 0 HcmV?d00001 diff --git a/apps/sim/lib/api/server/nul-bytes.ts b/apps/sim/lib/api/server/nul-bytes.ts new file mode 100644 index 00000000000..ff36df53075 --- /dev/null +++ b/apps/sim/lib/api/server/nul-bytes.ts @@ -0,0 +1,115 @@ +import { isPlainRecord } from '@sim/utils/object' +import { ZodError } from 'zod' + +/** + * `U+0000` is the one code point a Postgres `text`/`jsonb` value cannot carry: + * the wire protocol terminates strings on it, so the driver throws before the + * statement is ever planned. That throw carries no SQLSTATE the route layer can + * classify, so it lands in `unhandledErrorResponse` and reaches the caller as + * `500 INTERNAL_ERROR` — on pure reads (`?search=`) just as readily as on + * writes. + * + * Every other control character is rejected by nothing and stored by Postgres + * verbatim. `\n`, `\t`, and `\r` are ordinary content in a workflow description, + * a table cell, or a file name, so widening this to the whole C0 range would + * break real payloads to fix nothing. Lone surrogates are also left alone: the + * driver's UTF-8 encoder substitutes `U+FFFD` rather than throwing, so they are + * a data-fidelity question, not an availability one. NUL is the only value in + * this class, and it is rejected on its own. + */ +const NUL = '\u0000' + +/** + * Cheap existence scan used on every request. Descends only into arrays and + * plain records, so a `Buffer`, `Uint8Array`, or `Date` in a parsed payload is + * treated as a leaf — a zero *byte* in binary content is legitimate and must + * not be confused with a NUL *character* in text. + */ +function containsNulByte(root: unknown): boolean { + const stack: unknown[] = [root] + while (stack.length > 0) { + const value = stack.pop() + if (typeof value === 'string') { + if (value.includes(NUL)) return true + continue + } + if (Array.isArray(value)) { + for (const entry of value) stack.push(entry) + continue + } + if (isPlainRecord(value)) { + for (const [key, entry] of Object.entries(value)) { + if (key.includes(NUL)) return true + stack.push(entry) + } + } + } + return false +} + +/** + * Second pass, run only once a NUL is known to be present, so the common case + * never pays for path bookkeeping. Returns the path of the first offending + * string, matching the shape Zod reports for a failed field. + */ +function findNulBytePath(root: unknown): PropertyKey[] { + const stack: { value: unknown; path: PropertyKey[] }[] = [{ value: root, path: [] }] + while (stack.length > 0) { + const frame = stack.pop() + if (!frame) break + const { value, path } = frame + if (typeof value === 'string') { + if (value.includes(NUL)) return path + continue + } + if (Array.isArray(value)) { + for (let index = value.length - 1; index >= 0; index -= 1) { + stack.push({ value: value[index], path: [...path, index] }) + } + continue + } + if (isPlainRecord(value)) { + const entries = Object.entries(value) + for (let index = entries.length - 1; index >= 0; index -= 1) { + const [key, entry] = entries[index] + if (key.includes(NUL)) return [...path, key] + stack.push({ value: entry, path: [...path, key] }) + } + } + } + return [] +} + +/** + * Rejects any `U+0000` reaching the application from a request, as a `ZodError` + * so it renders through each surface's existing validation-error projection + * (the v2 `{ error: { code: 'BAD_REQUEST' } }` envelope, the internal + * `{ error, details }` body) with no per-route wiring. + * + * This is deliberately a *boundary* rejection rather than a shared string + * primitive that every text field opts into. A primitive only ever protects the + * fields somebody remembered to build on it, and it cannot protect the fields + * that have no string schema at all — a table cell and a predicate `value` are + * `z.unknown()` by contract, because their type is decided by the column, not + * the wire. Those are exactly the values the reproduction found reaching the + * driver. One scan over the already-validated payload covers every field, + * including the ones nobody has enumerated yet. + * + * It runs on the *parsed* value, not the raw one, so a NUL in a property the + * contract strips is not a spurious 400 — only values that actually flow into + * an application use case are checked. + * + * Headers are not scanned: HTTP forbids NUL in a field value and the server's + * own parser rejects it long before a contract sees it. + */ +export function nulByteValidationError(value: unknown): ZodError | null { + if (!containsNulByte(value)) return null + return new ZodError([ + { + code: 'custom', + path: findNulBytePath(value), + message: 'Value cannot contain a NUL character (U+0000)', + input: undefined, + }, + ]) +} diff --git a/apps/sim/lib/api/server/validation.ts b/apps/sim/lib/api/server/validation.ts index cc9f327d7bb..4a0f0b0b594 100644 --- a/apps/sim/lib/api/server/validation.ts +++ b/apps/sim/lib/api/server/validation.ts @@ -8,6 +8,7 @@ import type { ContractParams, ContractQuery, } from '@/lib/api/contracts' +import { nulByteValidationError } from '@/lib/api/server/nul-bytes' import { env } from '@/lib/core/config/env' import { assertContentLengthWithinLimit, @@ -294,6 +295,12 @@ export async function parseRequest( const parsedBody = contract.body ? validateRequestSchema(contract.body, body, options) : undefined if (parsedBody && !parsedBody.success) return parsedBody + const nulBytes = + rejectNulBytes(params?.data, options) ?? + rejectNulBytes(query?.data, options) ?? + rejectNulBytes(parsedBody?.data, options) + if (nulBytes) return nulBytes + return { success: true, data: { @@ -305,6 +312,25 @@ export async function parseRequest( } } +/** + * Applies {@link nulByteValidationError} to one validated request slice and + * projects a hit through the same error renderer the schema failures use, so a + * NUL is a 400 in every surface's own envelope instead of a driver-level 500. + */ +function rejectNulBytes( + data: unknown, + options?: ParseRequestOptions +): { success: false; response: NextResponse } | null { + const error = nulByteValidationError(data) + if (!error) return null + return { + success: false, + response: options?.validationErrorResponse + ? options.validationErrorResponse(error) + : validationErrorResponse(error), + } +} + function validateRequestSchema( schema: S, data: unknown, diff --git a/apps/sim/lib/knowledge/application/add-workspace-files.ts b/apps/sim/lib/knowledge/application/add-workspace-files.ts index c0400c14647..e4af11f53db 100644 --- a/apps/sim/lib/knowledge/application/add-workspace-files.ts +++ b/apps/sim/lib/knowledge/application/add-workspace-files.ts @@ -35,7 +35,10 @@ import { type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getBoundWorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' -import { MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE } from '@/lib/uploads/shared/types' +import { + EMPTY_KNOWLEDGE_DOCUMENT_MESSAGE, + MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE, +} from '@/lib/uploads/shared/types' import { validateFileType } from '@/lib/uploads/utils/validation' const logger = createLogger('AddWorkspaceFilesToKnowledgeBase') @@ -94,6 +97,9 @@ async function prepareWorkspaceFile( if (file.size < 0 || file.size > MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE) { throw new OrchestrationError('payload_too_large', 'Knowledge document exceeds the 100MB limit') } + if (file.size === 0) { + throw new OrchestrationError('validation', EMPTY_KNOWLEDGE_DOCUMENT_MESSAGE) + } const fileTypeError = validateFileType(file.name, file.type) if (fileTypeError) throw new OrchestrationError('validation', fileTypeError.message) diff --git a/apps/sim/lib/knowledge/application/documents.test.ts b/apps/sim/lib/knowledge/application/documents.test.ts index 8df17daf2ee..122f4788957 100644 --- a/apps/sim/lib/knowledge/application/documents.test.ts +++ b/apps/sim/lib/knowledge/application/documents.test.ts @@ -370,6 +370,30 @@ describe('knowledge document application use cases', () => { expect(mocks.createDocument).toHaveBeenCalledOnce() }) + /** + * The size guard was upper-bound only, so a zero-byte file was admitted, put + * in storage, and registered — even though every parser refuses an empty + * buffer outright, so the document could only ever end up `failed`. An input + * the system provably cannot process belongs to the caller, not to storage. + */ + it('rejects a zero-byte upload before it reaches storage', async () => { + await expect( + uploadKnowledgeDocument.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-1', + file: { ...uploadFile, buffer: Buffer.alloc(0), fileSize: 0 }, + usageAdmission: 'pre_admitted', + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(mocks.uploadStoredFile).not.toHaveBeenCalled() + expect(mocks.recordKnowledgeBaseFileOwnership).not.toHaveBeenCalled() + expect(mocks.createDocument).not.toHaveBeenCalled() + }) + it('leaves only a sweepable knowledge-base binding when final authorization fails', async () => { mocks.resolvePermission.mockResolvedValueOnce('write').mockResolvedValueOnce(null) diff --git a/apps/sim/lib/knowledge/application/documents.ts b/apps/sim/lib/knowledge/application/documents.ts index 50cf0ce86c1..1ee20719531 100644 --- a/apps/sim/lib/knowledge/application/documents.ts +++ b/apps/sim/lib/knowledge/application/documents.ts @@ -65,7 +65,10 @@ import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service' import { StorageService } from '@/lib/uploads' import { generateKnowledgeBaseFileKey } from '@/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager' import { recordKnowledgeBaseFileOwnership } from '@/lib/uploads/server/metadata' -import { MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE } from '@/lib/uploads/shared/types' +import { + EMPTY_KNOWLEDGE_DOCUMENT_MESSAGE, + MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE, +} from '@/lib/uploads/shared/types' import { validateFileType } from '@/lib/uploads/utils/validation' const logger = createLogger('KnowledgeDocumentApplication') @@ -311,6 +314,9 @@ export const uploadKnowledgeDocument = defineAuthorizedKnowledgeUseCase({ if (input.file.fileSize !== input.file.buffer.byteLength) { throw new Error('Knowledge document upload size does not match its buffered bytes') } + if (input.file.fileSize === 0) { + throw new OrchestrationError('validation', EMPTY_KNOWLEDGE_DOCUMENT_MESSAGE) + } const fileTypeError = validateFileType(input.file.filename, input.file.mimeType) if (fileTypeError) throw new OrchestrationError('validation', fileTypeError.message) if (input.usageAdmission !== 'pre_admitted') { diff --git a/apps/sim/lib/table/__tests__/sql.test.ts b/apps/sim/lib/table/__tests__/sql.test.ts index dcca281da94..9b597e4aacf 100644 --- a/apps/sim/lib/table/__tests__/sql.test.ts +++ b/apps/sim/lib/table/__tests__/sql.test.ts @@ -383,6 +383,76 @@ describe('SQL Builder', () => { }) }) + /** + * The value's JS *type* was checked, but never its content: any string was + * bound straight into `::timestamptz`, so Postgres raised + * `invalid input syntax for type timestamp with time zone` — an unclassified + * driver throw the route layer rendered as `500 INTERNAL_ERROR`. + */ + describe('buildFilterClause > date bound must actually parse', () => { + const dateCols: ColumnDefinition[] = [{ name: 'birthDate', type: 'date' }] + + it.each(['not-a-date', '', 'abc', '2020-13-45', ' '])( + 'rejects %j as a range bound on a date column', + (bound) => { + expect(() => + buildFilterClause({ birthDate: { $gt: bound } } as Filter, TABLE, dateCols) + ).toThrow(/column "birthDate" \(date\) requires a parseable date string/) + } + ) + + it.each(['$gt', '$gte', '$lt', '$lte'])('rejects an unparseable bound for %s', (operator) => { + expect(() => + buildFilterClause({ birthDate: { [operator]: 'not-a-date' } } as Filter, TABLE, dateCols) + ).toThrow(/requires a parseable date string/) + }) + + it('still accepts the date shapes the column itself stores', () => { + for (const bound of ['2024-01-01', '2024-01-31T10:00:00Z', '2024-01-31T10:00:00+02:00']) { + expect(() => + buildFilterClause({ birthDate: { $lte: bound } }, TABLE, dateCols) + ).not.toThrow() + } + }) + }) + + describe('buildPredicateClause > system timestamp columns reject unparseable bounds', () => { + it.each(['gt', 'gte', 'lt', 'lte', 'eq', 'ne'])( + 'rejects an unparseable createdAt bound for %s', + (op) => { + expect(() => + buildPredicateClause( + { all: [{ field: 'createdAt', op, value: 'not-a-date' }] } as TablePredicate, + TABLE, + NO_COLUMNS + ) + ).toThrow(/column "createdAt" requires a parseable date string/) + } + ) + + it('rejects an unparseable member of an `in` list', () => { + expect(() => + buildPredicateClause( + { + all: [{ field: 'updatedAt', op: 'in', value: ['2024-01-01', 'not-a-date'] }], + } as TablePredicate, + TABLE, + NO_COLUMNS + ) + ).toThrow(/column "updatedAt" requires a parseable date string/) + }) + + it('still accepts a real timestamp bound', () => { + expect(() => + buildPredicateClause( + { all: [{ field: 'createdAt', op: 'gte', value: '2024-01-01T00:00:00Z' }] }, + TABLE, + NO_COLUMNS + ) + ).not.toThrow() + }) + }) + describe('buildSortClause', () => { it('returns undefined for empty sort', () => { expect(buildSortClause({}, TABLE, NO_COLUMNS)).toBeUndefined() diff --git a/apps/sim/lib/table/rows/__tests__/ordering-anchor.test.ts b/apps/sim/lib/table/rows/__tests__/ordering-anchor.test.ts new file mode 100644 index 00000000000..0818d09a9d3 --- /dev/null +++ b/apps/sim/lib/table/rows/__tests__/ordering-anchor.test.ts @@ -0,0 +1,56 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbTransaction } from '@/lib/table/planner' +import { TableRowNotFoundError } from '@/lib/table/rows/errors' +import { resolveInsertByNeighbor } from '@/lib/table/rows/ordering' + +/** + * A transaction whose anchor lookup finds nothing — the shape a caller produces + * by naming a row id that does not exist in the table. + */ +function trxWithNoAnchor(): DbTransaction { + const chain = { + select: () => chain, + from: () => chain, + where: () => chain, + orderBy: () => chain, + limit: async () => [], + } + return chain as unknown as DbTransaction +} + +/** + * `afterRowId`/`beforeRowId` name a neighbor the caller can get wrong — a stale + * view, a concurrent delete, or a typo. The anchor lookup answered a miss with a + * bare `Error`, which no error policy classifies, so `POST /tables/{id}/rows` + * returned `500 INTERNAL_ERROR` for what is plainly a bad request. + */ +describe('resolveInsertByNeighbor > unknown anchor row', () => { + it('classifies a missing afterRowId as not found, not an internal fault', async () => { + const error = await resolveInsertByNeighbor( + trxWithNoAnchor(), + 'table-1', + 'row_doesnotexist' + ).catch((thrown: unknown) => thrown) + + expect(error).toBeInstanceOf(TableRowNotFoundError) + expect(error).toBeInstanceOf(OrchestrationError) + expect((error as OrchestrationError).code).toBe('not_found') + expect((error as Error).message).toContain('row_doesnotexist') + }) + + it('classifies a missing beforeRowId the same way', async () => { + const error = await resolveInsertByNeighbor( + trxWithNoAnchor(), + 'table-1', + undefined, + 'row_alsomissing' + ).catch((thrown: unknown) => thrown) + + expect(error).toBeInstanceOf(TableRowNotFoundError) + expect((error as OrchestrationError).code).toBe('not_found') + }) +}) diff --git a/apps/sim/lib/table/rows/errors.ts b/apps/sim/lib/table/rows/errors.ts index 480a42db148..c02e48fdb61 100644 --- a/apps/sim/lib/table/rows/errors.ts +++ b/apps/sim/lib/table/rows/errors.ts @@ -1,9 +1,14 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' -/** Raised when a row disappears before an operation can mutate it. */ +/** + * Raised when a row an operation names is not in the table — either the target + * row disappeared before the mutation, or the caller named an `afterRowId` / + * `beforeRowId` anchor that does not exist. `rowId` is the caller's own input, + * so echoing it is the difference between a fixable error and a guess. + */ export class TableRowNotFoundError extends OrchestrationError { - constructor() { - super('not_found', 'Row not found') + constructor(rowId?: string) { + super('not_found', rowId ? `Row not found: ${rowId}` : 'Row not found') this.name = 'TableRowNotFoundError' } } diff --git a/apps/sim/lib/table/rows/ordering.ts b/apps/sim/lib/table/rows/ordering.ts index 25a429809ee..c776cc6e075 100644 --- a/apps/sim/lib/table/rows/ordering.ts +++ b/apps/sim/lib/table/rows/ordering.ts @@ -14,6 +14,7 @@ import { TABLE_LIMITS } from '@/lib/table/constants' import type { MutationProof } from '@/lib/table/mutation-locks' import { keyBetween, nKeysBetween } from '@/lib/table/order-key' import { type DbExecutor, type DbTransaction, withSeqscanOff } from '@/lib/table/planner' +import { TableRowNotFoundError } from '@/lib/table/rows/errors' import { mutateTableRowsWithSecretProvenance } from '@/lib/table/rows/secret-provenance' import { setTableTxTimeouts } from '@/lib/table/tx' import type { RowData, TableDefinition, TableRowSecretProvenanceWrite } from '@/lib/table/types' @@ -131,8 +132,10 @@ export async function resolveInsertByNeighbor( .where(and(eq(userTableRows.tableId, tableId), eq(userTableRows.id, anchorId))) .limit(1) // The client targets a specific neighbor; a missing one (concurrent delete / - // stale view) is an error, not a silent insert at the front. - if (!anchor) throw new Error(`Row not found: ${anchorId}`) + // stale view / an id the caller made up) is an error, not a silent insert at + // the front. It is caller-fixable, so it is classified: a bare `Error` here + // is unclassifiable by every layer above and surfaced as a 500 for a 404. + if (!anchor) throw new TableRowNotFoundError(anchorId) const anchorKey = anchor.orderKey ?? null // A null key on the anchor means the table isn't backfilled. order_key is // authoritative, so the adjacent-key lookup below can't work — fail loudly diff --git a/apps/sim/lib/table/sql.ts b/apps/sim/lib/table/sql.ts index 97dff495b3b..6a90b5280db 100644 --- a/apps/sim/lib/table/sql.ts +++ b/apps/sim/lib/table/sql.ts @@ -6,6 +6,7 @@ */ import { isRecordLike } from '@sim/utils/object' +import { truncate } from '@sim/utils/string' import type { SQL } from 'drizzle-orm' import { sql } from 'drizzle-orm' import { getColumnId } from '@/lib/table/column-keys' @@ -16,6 +17,7 @@ import { SINGLE_SELECT_OPERATORS, } from '@/lib/table/column-types' import { NAME_PATTERN } from '@/lib/table/constants' +import { normalizeDateCellValue } from '@/lib/table/dates' import { TableQueryValidationError } from '@/lib/table/errors' import type { ColumnDefinition, @@ -366,9 +368,37 @@ function validateComparisonValue( `Range operator on column "${field}" (${label}) requires a number, got ${typeof value}` ) } - if (cast === 'timestamptz' && typeof value !== 'string') { + if (cast === 'timestamptz') { + if (typeof value !== 'string') { + throw new TableQueryValidationError( + `Range operator on column "${field}" (date) requires a date string, got ${typeof value}` + ) + } + if (normalizeDateCellValue(value) === null) { + throw new TableQueryValidationError( + `Range operator on column "${field}" (date) requires a parseable date string, got "${truncate(value, 64)}"` + ) + } + } +} + +/** + * Guards a bound that is about to be bound into a `::timestamptz` cast on a + * system timestamp column (`createdAt`/`updatedAt`). + * + * The type check alone was not enough: any string went straight into the cast, + * so `not-a-date` raised `invalid input syntax for type timestamp with time + * zone` inside the driver. That throw carries no classification the route layer + * recognizes, so a malformed filter — caller input — surfaced as a 500. Parsing + * with the same normalizer the `date` column type uses to store cells keeps the + * filter grammar and the storage grammar in agreement. + */ +function assertParseableTimestampBound(field: string, value: JsonValue | undefined): void { + if (typeof value !== 'string' || normalizeDateCellValue(value) === null) { throw new TableQueryValidationError( - `Range operator on column "${field}" (date) requires a date string, got ${typeof value}` + `Operator on column "${field}" requires a parseable date string, got ${ + typeof value === 'string' ? `"${truncate(value, 64)}"` : typeof value + }` ) } } @@ -657,7 +687,10 @@ function buildSystemColumnClause( // `TimeZone` GUC, so identical queries return different rows per environment and // day-boundary ranges land off by the offset. Normalizing the bound to UTC wall // clock is session-independent and still honors an explicit offset in the input. - const ts = (v: JsonValue | undefined) => sql`${String(v)}::timestamptz AT TIME ZONE 'UTC'` + const ts = (v: JsonValue | undefined) => { + assertParseableTimestampBound(field, v) + return sql`${String(v)}::timestamptz AT TIME ZONE 'UTC'` + } const bind = spec.kind === 'timestamp' ? ts : (v: JsonValue | undefined) => sql`${String(v)}` /** * Mirrors the JSONB pattern builders: `*` is the caller's only wildcard, an diff --git a/apps/sim/lib/uploads/shared/types.ts b/apps/sim/lib/uploads/shared/types.ts index af9625d9b7e..37ff23c1bd1 100644 --- a/apps/sim/lib/uploads/shared/types.ts +++ b/apps/sim/lib/uploads/shared/types.ts @@ -25,6 +25,18 @@ export const MAX_WORKSPACE_FORMDATA_FILE_SIZE = 100 * 1024 * 1024 /** Maximum size accepted by the knowledge-document parsing pipeline. */ export const MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE = 100 * 1024 * 1024 +/** + * Rejection wording shared by every surface that admits a knowledge document. + * + * The size guards were upper-bound only, so a zero-byte file passed admission + * and was stored and registered — but the parsing pipeline refuses an empty + * buffer outright (`parseBuffer` throws before dispatching to a parser), so the + * document could never reach anything but `failed`. A file the pipeline is + * guaranteed to reject is a bad request, and admission is the only place a + * caller can be told so. + */ +export const EMPTY_KNOWLEDGE_DOCUMENT_MESSAGE = 'Knowledge document cannot be empty' + export type StorageContext = | 'knowledge-base' | 'chat' From 1bf324dbe7395c51019a0fd7278f545815e7735e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 16:57:48 -0700 Subject: [PATCH 09/56] fix(v2): stop six endpoints from returning a confident untruth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six defects that share a shape: a 200 that misrepresents what happened, which is the one class a caller cannot detect from the response. Knowledge search silently degraded. Reranking is implemented and does run, but a deployment with no Cohere credential, a provider error, or a timeout was swallowed into a warning log and answered 200 with plain vector ordering and no `rerankerScore` anywhere — indistinguishable from a reranker that ran and agreed with the vector order. The fallback stays (an outage should not take search down) and is now reported: `rerankerStatus` is required on every search response. v2 also omitted the `rerankerModel` default the internal contract supplies, so `rerankerEnabled: true` alone failed the use case's model guard and returned unreranked results after paying for the widened candidate retrieval; it now defaults like its sibling. `GET /billing/logs` accepted `startDate`/`endDate` with any relative period and dropped them, answering over the default 30-day window — a caller reconciling charges got real rows that were not the rows it asked for. Both bounds are now rejected outside `period=custom`, take the same strict UTC form as `GET /logs` via the shared `v2RunWindowBoundSchema`, and reject an inverted window instead of returning an empty page. MCP registration stamped `connectionStatus: 'connected'` and `lastConnected: now` at insert without contacting the endpoint, and did the same on any non-OAuth re-registration while leaving `lastError` stale. `tool-validation` gates tool availability on that column, so an unreachable server read as healthy. Both paths now leave the columns at their honest defaults for `mcpService.updateServerStatus` to move after a real discovery; the client-side optimistic copy matches. `skills.create` allowed a workspace API key while every other skill write denies one, so a key could only ever accumulate skills it could never remove — and the row it left was attributed to the workspace's billing owner, minting an editor grant for a human who did not act. Creation now denies a workspace key, making the lifecycle symmetric on the per-skill editor model that authorizes the rest of it. `runCount` counts successful non-paused runs and is never decremented by retention, so it disagrees with the runs list in both directions; the description now says so rather than claiming "total recorded runs". Run retention itself was undocumented — free-plan runs are hard-deleted after 30 days, which is why a workflow reports runs beside an empty list — and is now stated on both reads over the execution-log table. --- apps/docs/openapi-v2-billing.json | 20 ++--- apps/docs/openapi-v2-knowledge.json | 22 +++++- apps/docs/openapi-v2-logs.json | 2 +- apps/docs/openapi-v2-resources.json | 18 ++--- apps/docs/openapi-v2-workflows.json | 10 +-- .../sim/app/api/v2/billing/logs/route.test.ts | 77 +++++++++++++++++++ .../app/api/v2/knowledge/search/route.test.ts | 75 +++++++++++++++++- apps/sim/app/api/v2/knowledge/search/route.ts | 1 + apps/sim/app/api/v2/skills/route.test.ts | 12 ++- apps/sim/hooks/queries/mcp.ts | 7 +- .../v2/__tests__/run-accounting.test.ts | 58 ++++++++++++++ apps/sim/lib/api/contracts/v2/billing.ts | 66 ++++++++++++---- apps/sim/lib/api/contracts/v2/knowledge.ts | 33 +++++++- apps/sim/lib/api/contracts/v2/mcp-servers.ts | 13 +++- .../lib/api/contracts/v2/openapi/billing.ts | 2 +- .../lib/api/contracts/v2/openapi/knowledge.ts | 2 +- apps/sim/lib/api/contracts/v2/openapi/logs.ts | 4 +- .../lib/api/contracts/v2/openapi/resources.ts | 19 +++-- .../lib/api/contracts/v2/openapi/shared.ts | 24 ++++++ .../lib/api/contracts/v2/openapi/workflows.ts | 4 +- apps/sim/lib/api/contracts/v2/workflows.ts | 24 +++++- .../lib/knowledge/application/search.test.ts | 74 ++++++++++++++++++ apps/sim/lib/knowledge/application/search.ts | 23 ++++++ apps/sim/lib/knowledge/reranker-models.ts | 22 ++++++ .../orchestration/server-lifecycle.test.ts | 69 +++++++++++++++++ .../lib/mcp/orchestration/server-lifecycle.ts | 46 +++++++---- .../lib/skills/application/operations.test.ts | 22 +++++- apps/sim/lib/skills/application/operations.ts | 37 +++++---- .../workflows/executor/execution-core.test.ts | 27 +++++++ 29 files changed, 714 insertions(+), 99 deletions(-) create mode 100644 apps/sim/lib/api/contracts/v2/__tests__/run-accounting.test.ts diff --git a/apps/docs/openapi-v2-billing.json b/apps/docs/openapi-v2-billing.json index dd5eac4314f..f264de318a6 100644 --- a/apps/docs/openapi-v2-billing.json +++ b/apps/docs/openapi-v2-billing.json @@ -101,7 +101,7 @@ "get": { "operationId": "listBillingLogs", "summary": "List Billing Logs", - "description": "List the credit-denominated billing ledger with source filtering and opaque cursor pagination. `period` defaults to `30d`, so an unqualified request covers only the last 30 days: paginating to `nextCursor: null` exhausts that window, not the whole ledger. Pass `period=all` for full history, or `period=custom` with `startDate` and `endDate` for a specific range.", + "description": "List the credit-denominated billing ledger with source filtering and opaque cursor pagination. `period` defaults to `30d`, so an unqualified request covers only the last 30 days: paginating to `nextCursor: null` exhausts that window, not the whole ledger. Pass `period=all` for full history, or `period=custom` with `startDate` and `endDate` for a specific range. Both bounds are accepted only with `period=custom` — sending either alongside a relative period is a 400, never a page silently answered over a different window — and both take the same strict UTC ISO 8601 form as `GET /api/v2/logs`. An inverted window is a 400 rather than an empty page.", "tags": ["Billing"], "parameters": [ { @@ -140,10 +140,10 @@ "name": "period", "in": "query", "required": false, - "description": "Relative window, all history, or a custom date range.", + "description": "Relative window, all history, or a custom date range. `startDate` and `endDate` are accepted only with `custom`; every other value computes its own window.", "schema": { "default": "30d", - "description": "Relative window, all history, or a custom date range.", + "description": "Relative window, all history, or a custom date range. `startDate` and `endDate` are accepted only with `custom`; every other value computes its own window.", "type": "string", "enum": ["1d", "7d", "30d", "all", "custom"] } @@ -152,22 +152,24 @@ "name": "startDate", "in": "query", "required": false, - "description": "Start of a custom window as a Date-parseable string.", + "description": "Only include usage events recorded at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. Requires `period=custom`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected.", "schema": { - "description": "Start of a custom window as a Date-parseable string.", "type": "string", - "minLength": 1 + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Only include usage events recorded at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. Requires `period=custom`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected." } }, { "name": "endDate", "in": "query", "required": false, - "description": "End of a custom window as a Date-parseable string; defaults to now.", + "description": "Only include usage events recorded at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. Requires `period=custom`, and defaults to now when omitted. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected.", "schema": { - "description": "End of a custom window as a Date-parseable string; defaults to now.", "type": "string", - "minLength": 1 + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Only include usage events recorded at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. Requires `period=custom`, and defaults to now when omitted. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected." } }, { diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index 6a2769f45a5..c305e10b93e 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -474,7 +474,7 @@ "post": { "operationId": "searchKnowledge", "summary": "Search Knowledge", - "description": "Search one or more knowledge bases with semantic vector retrieval, optional hybrid full-text retrieval, and structured tag filters. Set `rerankerEnabled` with a `rerankerModel` to re-order the retrieved chunks with a reranking model before truncating to `topK`; reranked results carry a `rerankerScore` and are ordered by it, and reranking is billed as an additional search unit. Every result names the `knowledgeBaseId` it came from. The request body is capped at 2 MiB; a larger body is a 413.", + "description": "Search one or more knowledge bases with semantic vector retrieval, optional hybrid full-text retrieval, and structured tag filters. Set `rerankerEnabled` to re-order the retrieved chunks with a reranking model before truncating to `topK`; `rerankerModel` selects the model and defaults when omitted. Reranked results carry a `rerankerScore` and are ordered by it, and reranking is billed as an additional search unit. Reranking is best-effort: a reranker that cannot run — a provider failure, a timeout, or a deployment with no reranking credential — falls back to vector ordering rather than failing the search, so read `rerankerStatus` on the response to tell an ordering the reranker produced from one it never touched. Every result names the `knowledgeBaseId` it came from. The request body is capped at 2 MiB; a larger body is a 413.", "tags": ["Knowledge Bases"], "requestBody": { "required": true, @@ -2673,9 +2673,22 @@ "maximum": 9007199254740991, "description": "Number of results returned.", "examples": [4] + }, + "rerankerStatus": { + "type": "string", + "enum": ["not_requested", "skipped", "unavailable", "applied"], + "description": "What the reranker did on this search. `applied` means it ordered the results, which carry `rerankerScore`. `unavailable` means reranking was requested and attempted but could not complete, so results are in vector order and carry no `rerankerScore` — the search still succeeded, and the request is worth retrying. `skipped` means there was nothing to rank: a tag-only search, or no matching chunks. `not_requested` means `rerankerEnabled` was absent or false.", + "examples": ["applied"] } }, - "required": ["results", "query", "knowledgeBaseIds", "topK", "totalResults"], + "required": [ + "results", + "query", + "knowledgeBaseIds", + "topK", + "totalResults", + "rerankerStatus" + ], "additionalProperties": false, "title": "Knowledge search data", "description": "Results and execution context for a knowledge search." @@ -2803,11 +2816,12 @@ ] }, "rerankerEnabled": { - "description": "Re-order retrieved chunks with a reranking model before truncating to `topK`. Ignored for a tag-only search, which has no query to rank against. Reranking is billed as an additional search unit.", + "description": "Re-order retrieved chunks with a reranking model before truncating to `topK`. Ignored for a tag-only search, which has no query to rank against. Reranking is billed as an additional search unit. Whether it actually ran is reported by `rerankerStatus` on the response: reranking is best-effort, and a provider failure falls back to vector ordering rather than failing the search.", "type": "boolean" }, "rerankerModel": { - "description": "Reranking model to use; required for reranking to run.", + "default": "rerank-v4.0-fast", + "description": "Reranking model to use when `rerankerEnabled` is true. Defaults to `rerank-v4.0-fast`.", "type": "string", "enum": ["rerank-v4.0-pro", "rerank-v4.0-fast", "rerank-v3.5"] }, diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index 5c4291c7fc6..f816b16d244 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -36,7 +36,7 @@ "get": { "operationId": "listLogs", "summary": "List Logs", - "description": "List workflow execution logs for a workspace with filters, selectable detail, and opaque cursor pagination. This list predates the shared sort convention: it has no `sortBy` (the sort column is fixed to execution start time) and spells the direction `order` rather than `sortOrder`. Trace spans are stored separately from the log row and are pruned on their own retention schedule: `includeTraceSpans=true` on a run whose stored spans have aged out returns `traceSpans: []` rather than an error, so an empty array does not mean the run recorded no spans.", + "description": "List workflow execution logs for a workspace with filters, selectable detail, and opaque cursor pagination. This list predates the shared sort convention: it has no `sortBy` (the sort column is fixed to execution start time) and spells the direction `order` rather than `sortOrder`. Runs are hard-deleted once they pass the payer's log retention window, so an older run is absent from this list rather than reported as removed. The window is 30 days from run start on the free plan; Pro and Team have none configured and keep runs indefinitely; Enterprise sets its own per organization, with an optional per-workspace override, and is also unbounded until configured. A workflow's `runCount` is never reduced by this deletion, so a workflow can report runs while this list is empty. Trace spans are stored separately from the log row and are pruned on their own retention schedule: `includeTraceSpans=true` on a run whose stored spans have aged out returns `traceSpans: []` rather than an error, so an empty array does not mean the run recorded no spans.", "tags": ["Logs"], "parameters": [ { diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 04109520c3f..9d8e25eeb04 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -210,7 +210,7 @@ "get": { "operationId": "listMcpServers", "summary": "List MCP Servers", - "description": "List MCP servers registered in a workspace. Request-header values and OAuth client secrets are never returned. Nothing caps how many servers a workspace registers, so this list is paginated: paginate with `limit` and `cursor`, stopping when `nextCursor` is null. `connectionStatus`, `toolCount`, `lastError`, and `lastToolsRefresh` describe the most recent tool discovery and stay at their registration defaults until one runs — call `GET /api/v2/mcp-servers/{id}/tools` to run it.", + "description": "List MCP servers registered in a workspace. Request-header values and OAuth client secrets are never returned. Nothing caps how many servers a workspace registers, so this list is paginated: paginate with `limit` and `cursor`, stopping when `nextCursor` is null. `connectionStatus`, `lastConnected`, `toolCount`, `lastError`, and `lastToolsRefresh` describe the most recent tool discovery and stay at their registration defaults — `disconnected`, with `lastConnected` absent — until one runs. Call `GET /api/v2/mcp-servers/{id}/tools` to run it.", "tags": ["MCP Servers"], "parameters": [ { @@ -333,7 +333,7 @@ "post": { "operationId": "createMcpServer", "summary": "Create MCP Server", - "description": "Register an MCP server in a workspace. The endpoint URL determines server identity, must be absolute HTTP or HTTPS, and cannot contain environment-variable references. Header values and OAuth client secrets are write-only. `transport`, `timeout`, `retries`, and `enabled` are applied server-side when omitted; the effective values are in the response.", + "description": "Register an MCP server in a workspace. The endpoint URL determines server identity, must be absolute HTTP or HTTPS, and cannot contain environment-variable references. Header values and OAuth client secrets are write-only. `transport`, `timeout`, `retries`, and `enabled` are applied server-side when omitted; the effective values are in the response. Registration stores the configuration and does not connect to the endpoint, so a 201 is not evidence the server is reachable: the response carries `connectionStatus: \"disconnected\"` and omits `lastConnected`, and the workspace tool registry treats the server as unavailable until a discovery succeeds. Call `GET /api/v2/mcp-servers/{id}/tools` to attempt one and see the outcome. Re-registering an existing URL rewrites the configuration and returns the server to the same unverified state.", "tags": ["MCP Servers"], "requestBody": { "required": true, @@ -841,7 +841,7 @@ "post": { "operationId": "createSkill", "summary": "Create Skill", - "description": "Create one skill in a workspace. Its kebab-case name must be unique and cannot be reserved by a built-in skill. Note that a workspace API key may create a skill but may not later update or delete it.", + "description": "Create one skill in a workspace. Its kebab-case name must be unique and cannot be reserved by a built-in skill. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.", "tags": ["Skills"], "requestBody": { "required": true, @@ -2492,12 +2492,12 @@ "description": "Whether the server tools are available to workflows." }, "connectionStatus": { - "description": "Result of the most recent connection attempt.", + "description": "Result of the most recent connection attempt. Registration and re-registration store a configuration without contacting the endpoint, so a server begins — and returns to — `disconnected` until a tool discovery runs.", "type": "string", "enum": ["connected", "disconnected", "error"] }, "lastError": { - "description": "Message from the most recent failed connection, or null when absent.", + "description": "Message from the most recent failed connection, or null when absent. A re-registration clears it, since the configuration it described no longer applies.", "anyOf": [ { "type": "string" @@ -2518,7 +2518,7 @@ "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" }, "lastConnected": { - "description": "ISO 8601 timestamp of the most recent successful connection.", + "description": "ISO 8601 timestamp of the most recent successful connection. Absent until the server completes one; registering a server does not set it.", "type": "string", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" @@ -2650,11 +2650,9 @@ "timeout": 30000, "retries": 3, "enabled": true, - "connectionStatus": "connected", + "connectionStatus": "disconnected", "lastError": null, - "toolCount": 7, - "lastToolsRefresh": "2026-06-20T14:02:11.000Z", - "lastConnected": "2026-06-20T14:02:11.000Z", + "toolCount": 0, "createdAt": "2026-06-01T09:14:00.000Z", "updatedAt": "2026-06-20T14:02:11.000Z", "hasHeaders": true, diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index ff7ca7c2401..096757634d0 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -1243,7 +1243,7 @@ "get": { "operationId": "listWorkflowRunsV2", "summary": "List Workflow Runs", - "description": "List recorded runs of a workflow with filtering and opaque cursor pagination. Ordering deviates from the v2 `sortBy` + `sortOrder` convention: runs are sortable only by start time, so direction is carried by the single `order` param.", + "description": "List recorded runs of a workflow with filtering and opaque cursor pagination. Ordering deviates from the v2 `sortBy` + `sortOrder` convention: runs are sortable only by start time, so direction is carried by the single `order` param. Runs are hard-deleted once they pass the payer's log retention window, so an older run is absent from this list rather than reported as removed. The window is 30 days from run start on the free plan; Pro and Team have none configured and keep runs indefinitely; Enterprise sets its own per organization, with an optional per-workspace override, and is also unbounded until configured. A workflow's `runCount` is never reduced by this deletion, so a workflow can report runs while this list is empty.", "tags": ["Workflow Runs"], "parameters": [ { @@ -2352,7 +2352,7 @@ "type": "integer", "minimum": 0, "maximum": 9007199254740991, - "description": "Total recorded workflow runs." + "description": "Runs that finished successfully. A run that failed, was cancelled, or is still paused is not counted, and the counter is never reduced when a run ages out of log retention — so this is not the number of runs `GET /api/v2/workflows/{id}/runs` returns, in either direction." }, "lastRunAt": { "anyOf": [ @@ -2363,7 +2363,7 @@ "type": "null" } ], - "description": "ISO 8601 timestamp of the latest run, or null when never run.", + "description": "ISO 8601 timestamp of the latest run counted by `runCount`, or null when none has been. Stamped by the same successful-run path, so a workflow whose only runs failed reports null here.", "format": "date-time" }, "createdAt": { @@ -2581,7 +2581,7 @@ "type": "integer", "minimum": 0, "maximum": 9007199254740991, - "description": "Total recorded workflow runs." + "description": "Runs that finished successfully. A run that failed, was cancelled, or is still paused is not counted, and the counter is never reduced when a run ages out of log retention — so this is not the number of runs `GET /api/v2/workflows/{id}/runs` returns, in either direction." }, "lastRunAt": { "anyOf": [ @@ -2592,7 +2592,7 @@ "type": "null" } ], - "description": "ISO 8601 timestamp of the latest run, or null when never run.", + "description": "ISO 8601 timestamp of the latest run counted by `runCount`, or null when none has been. Stamped by the same successful-run path, so a workflow whose only runs failed reports null here.", "format": "date-time" }, "createdAt": { diff --git a/apps/sim/app/api/v2/billing/logs/route.test.ts b/apps/sim/app/api/v2/billing/logs/route.test.ts index b323e834a83..e1794a90108 100644 --- a/apps/sim/app/api/v2/billing/logs/route.test.ts +++ b/apps/sim/app/api/v2/billing/logs/route.test.ts @@ -121,4 +121,81 @@ describe('GET /api/v2/billing/logs', () => { expect(v2RouteMocks.authenticate).toHaveBeenCalled() expect(mocks.execute).not.toHaveBeenCalled() }) + + it('rejects a window bound the effective period would discard', async () => { + const response = await GET( + new NextRequest( + 'http://localhost:3000/api/v2/billing/logs?startDate=2030-01-01T00:00:00Z&limit=100' + ) + ) + + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ + error: { + code: 'BAD_REQUEST', + message: expect.stringContaining('period=custom'), + }, + }) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('rejects an endDate paired with an explicit relative period', async () => { + const response = await GET( + new NextRequest( + 'http://localhost:3000/api/v2/billing/logs?period=7d&endDate=2026-07-01T00:00:00Z' + ) + ) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('rejects a window bound that is not a UTC ISO 8601 timestamp', async () => { + const response = await GET( + new NextRequest( + 'http://localhost:3000/api/v2/billing/logs?period=custom&startDate=2026-08-01' + ) + ) + + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ + error: { code: 'BAD_REQUEST', message: expect.stringContaining('UTC ISO 8601') }, + }) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('rejects an inverted custom range instead of answering with an empty page', async () => { + const response = await GET( + new NextRequest( + 'http://localhost:3000/api/v2/billing/logs?period=custom&startDate=2026-08-06T00:00:00Z&endDate=2026-08-05T00:00:00Z' + ) + ) + + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ + error: { + code: 'BAD_REQUEST', + message: expect.stringContaining('startDate must be before or equal to endDate'), + }, + }) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('forwards a valid custom range to the ledger read', async () => { + const response = await GET( + new NextRequest( + 'http://localhost:3000/api/v2/billing/logs?period=custom&startDate=2026-07-01T00:00:00Z&endDate=2026-07-31T00:00:00Z' + ) + ) + + expect(response.status).toBe(200) + expect(mocks.execute).toHaveBeenCalledWith({ + principal: auth.principal, + input: expect.objectContaining({ + startDate: new Date('2026-07-01T00:00:00Z'), + endDate: new Date('2026-07-31T00:00:00Z'), + }), + request: expect.anything(), + }) + }) }) diff --git a/apps/sim/app/api/v2/knowledge/search/route.test.ts b/apps/sim/app/api/v2/knowledge/search/route.test.ts index 52caea4ff99..e0038631a75 100644 --- a/apps/sim/app/api/v2/knowledge/search/route.test.ts +++ b/apps/sim/app/api/v2/knowledge/search/route.test.ts @@ -25,6 +25,7 @@ vi.mock('@/lib/knowledge/application/search', () => ({ })) import { KnowledgeUsageLimitExceededError } from '@/lib/knowledge/application/billing' +import { DEFAULT_RERANKER_MODEL } from '@/lib/knowledge/reranker-models' import { POST, V2_KNOWLEDGE_SEARCH_MAX_BODY_BYTES } from '@/app/api/v2/knowledge/search/route' const WORKSPACE_ID = 'workspace-1' @@ -69,6 +70,7 @@ describe('POST /api/v2/knowledge/search', () => { knowledgeBaseIds: ['kb-1'], topK: 10, totalResults: 1, + rerankerStatus: 'applied', }) }) @@ -96,7 +98,7 @@ describe('POST /api/v2/knowledge/search', () => { tagFilters: undefined, searchMode: 'hybrid', rerankerEnabled: undefined, - rerankerModel: undefined, + rerankerModel: DEFAULT_RERANKER_MODEL, rerankerInputCount: undefined, }, request, @@ -165,6 +167,77 @@ describe('POST /api/v2/knowledge/search', () => { expect(input).not.toHaveProperty('skipUsageBilling') }) + /** + * `rerankerEnabled` on its own used to satisfy the schema, fail the use case's + * model guard, and answer 200 in plain vector order — after paying for the + * widened candidate retrieval. The default closes that, matching the internal + * search contract. + */ + it('defaults the reranker model so enabling reranking is enough to run it', async () => { + const response = await POST( + buildRequest( + JSON.stringify({ + workspaceId: WORKSPACE_ID, + knowledgeBaseIds: ['kb-1'], + query: 'hello', + topK: 5, + rerankerEnabled: true, + }) + ) + ) + + expect(response.status).toBe(200) + expect(mockSearch).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + rerankerEnabled: true, + rerankerModel: DEFAULT_RERANKER_MODEL, + }), + }) + ) + }) + + it('reports on the wire that a requested reranker did not run', async () => { + mockSearch.mockResolvedValueOnce({ + results: [ + { + embeddingId: 'embedding-1', + knowledgeBaseId: 'kb-1', + documentId: 'doc-1', + documentName: 'support.txt', + sourceUrl: null, + content: 'hello', + chunkIndex: 0, + metadata: {}, + similarity: 0.9, + }, + ], + query: 'hello', + knowledgeBaseIds: ['kb-1'], + topK: 5, + totalResults: 1, + rerankerStatus: 'unavailable', + }) + + const response = await POST( + buildRequest( + JSON.stringify({ + workspaceId: WORKSPACE_ID, + knowledgeBaseIds: ['kb-1'], + query: 'hello', + topK: 5, + rerankerEnabled: true, + rerankerModel: 'rerank-v4.0-pro', + }) + ) + ) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.data.rerankerStatus).toBe('unavailable') + expect(body.data.results[0]).not.toHaveProperty('rerankerScore') + }) + it('rejects an unsupported reranker model and an out-of-range candidate pool', async () => { const unsupportedModel = await POST( buildRequest( diff --git a/apps/sim/app/api/v2/knowledge/search/route.ts b/apps/sim/app/api/v2/knowledge/search/route.ts index d22a4be4f8c..9ee4f6c9244 100644 --- a/apps/sim/app/api/v2/knowledge/search/route.ts +++ b/apps/sim/app/api/v2/knowledge/search/route.ts @@ -52,6 +52,7 @@ export const POST = defineV2JsonRoute({ knowledgeBaseIds: result.knowledgeBaseIds, topK: result.topK, totalResults: result.totalResults, + rerankerStatus: result.rerankerStatus, }, }), }) diff --git a/apps/sim/app/api/v2/skills/route.test.ts b/apps/sim/app/api/v2/skills/route.test.ts index c4b30a766f8..21aaec9f991 100644 --- a/apps/sim/app/api/v2/skills/route.test.ts +++ b/apps/sim/app/api/v2/skills/route.test.ts @@ -243,7 +243,16 @@ describe('/api/v2/skills', () => { expect(mocks.list).not.toHaveBeenCalled() }) + /** + * A personal key, not the suite's default workspace key. `skills.create` denies + * a workspace key like every other skill write: the per-skill editor row that + * authorizes an update or a delete resolves against a human subject a workspace + * key cannot supply, so allowing it to create left rows it could never remove. + */ it('creates a skill with the v2 source and status', async () => { + const principal = { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-personal' } + mocks.authenticate.mockResolvedValueOnce({ ...AUTH, principal, keyType: 'personal' as const }) + const response = await POST( request('POST', '/api/v2/skills', { workspaceId: WORKSPACE_ID, @@ -256,7 +265,7 @@ describe('/api/v2/skills', () => { expect(response.status).toBe(201) expect((await response.json()).data.id).toBe(skill.id) expect(mocks.create).toHaveBeenCalledWith({ - principal: PRINCIPAL, + principal, input: { workspaceId: WORKSPACE_ID, name: skill.name, @@ -266,7 +275,6 @@ describe('/api/v2/skills', () => { }, request: expect.anything(), }) - expect(mocks.capture).not.toHaveBeenCalled() }) it('keeps skill analytics on the personal-key v2 surface', async () => { diff --git a/apps/sim/hooks/queries/mcp.ts b/apps/sim/hooks/queries/mcp.ts index f0be5614ba7..b1b78100a08 100644 --- a/apps/sim/hooks/queries/mcp.ts +++ b/apps/sim/hooks/queries/mcp.ts @@ -306,7 +306,12 @@ export function useCreateMcpServer() { return { ...safeServerData, id: serverId, - connectionStatus: authType === 'oauth' ? ('disconnected' as const) : ('connected' as const), + /** + * Mirrors what registration writes. It used to claim `connected` for a + * non-OAuth server — a client-side copy of a server-side assumption that + * no connection had verified. + */ + connectionStatus: 'disconnected' as const, serverId, updated: wasUpdated, authType, diff --git a/apps/sim/lib/api/contracts/v2/__tests__/run-accounting.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/run-accounting.test.ts new file mode 100644 index 00000000000..7a97aab0d1e --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/__tests__/run-accounting.test.ts @@ -0,0 +1,58 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { logsOpenApiDocument } from '@/lib/api/contracts/v2/openapi/logs' +import { workflowsOpenApiDocument } from '@/lib/api/contracts/v2/openapi/workflows' +import { v2WorkflowListItemSchema } from '@/lib/api/contracts/v2/workflows' +import type { OpenApiDocumentDefinition } from '@/lib/api/openapi/types' + +/** + * The two documented facts about run accounting that a caller cannot discover + * from a response, and that a wrong description therefore turns into a silent + * wrong answer. + * + * `runCount` is a monotonic column on the workflow row, incremented only for a + * run that finished successfully and was not left paused, and never decremented + * by log retention. `GET /workflows/{id}/runs` reads the execution-log table, + * which lists every recorded run *and* is hard-deleted on the workspace's + * retention window. The two therefore disagree in both directions, and each + * operation has to say so where a caller reads it. + */ +function operationDescription(document: OpenApiDocumentDefinition, operationId: string): string { + const route = document.routes.find((entry) => entry.operation.operationId === operationId) + if (!route) throw new Error(`No documented operation ${operationId}`) + return route.operation.description +} + +function fieldDescription(field: string): string { + const shape = v2WorkflowListItemSchema.shape as Record + return shape[field]?.description ?? '' +} + +describe('v2 run accounting descriptions', () => { + it('discloses that runCount excludes runs that did not succeed', () => { + const description = fieldDescription('runCount') + + expect(description).toMatch(/succe/i) + expect(description).toMatch(/fail/i) + }) + + it('discloses that runCount is not the length of the runs list', () => { + expect(fieldDescription('runCount')).toMatch(/retention/i) + }) + + it('discloses that lastRunAt tracks the same successful-run population', () => { + expect(fieldDescription('lastRunAt')).toMatch(/succe/i) + }) + + it.each([ + ['workflows', () => operationDescription(workflowsOpenApiDocument, 'listWorkflowRunsV2')], + ['logs', () => operationDescription(logsOpenApiDocument, 'listLogs')], + ])('documents the run retention window on the %s list', (_name, read) => { + const description = read() + + expect(description).toMatch(/retention/i) + expect(description).toMatch(/30 days/i) + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/billing.ts b/apps/sim/lib/api/contracts/v2/billing.ts index c86d1224ce1..ada80300cb1 100644 --- a/apps/sim/lib/api/contracts/v2/billing.ts +++ b/apps/sim/lib/api/contracts/v2/billing.ts @@ -6,6 +6,7 @@ import { v2CursorListResponse, v2DataResponse, v2PaginationFields, + v2RunWindowBoundSchema, } from '@/lib/api/contracts/v2/shared' /** @@ -18,12 +19,6 @@ import { * = $5) — raw dollar costs and rate-limit internals are never on this wire. */ -/** `Date`-constructor-parseable string; validates parseability, not a wire format. */ -const parseableDateSchema = z - .string() - .min(1) - .refine((value) => !Number.isNaN(Date.parse(value)), { error: 'Invalid date' }) - /** * `.strict()` carries more weight here than on an ordinary read. `workspaceId` is * optional and selects *which payer* is reported, so a key Zod would otherwise strip — @@ -157,15 +152,21 @@ export const v2BillingLogsQuerySchema = z period: usageLogPeriodSchema .optional() .default('30d') - .describe('Relative window, all history, or a custom date range.'), - /** Required when `period` is `'custom'`. */ - startDate: parseableDateSchema - .optional() - .describe('Start of a custom window as a Date-parseable string.'), - /** Defaults to now when omitted for `'custom'`. */ - endDate: parseableDateSchema - .optional() - .describe('End of a custom window as a Date-parseable string; defaults to now.'), + .describe( + 'Relative window, all history, or a custom date range. `startDate` and `endDate` are accepted only with `custom`; every other value computes its own window.' + ), + /** Required when `period` is `'custom'`, and rejected otherwise. */ + startDate: v2RunWindowBoundSchema('startDate') + .describe( + 'Only include usage events recorded at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. Requires `period=custom`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected.' + ) + .optional(), + /** Defaults to now when omitted for `'custom'`; rejected for every other period. */ + endDate: v2RunWindowBoundSchema('endDate') + .describe( + 'Only include usage events recorded at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. Requires `period=custom`, and defaults to now when omitted. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected.' + ) + .optional(), ...v2PaginationFields({ description: 'Maximum usage events per page.' }), }) .strict() @@ -173,6 +174,41 @@ export const v2BillingLogsQuerySchema = z error: 'startDate is required when period is "custom"', path: ['startDate'], }) + /** + * `.strict()` only rejects keys the schema does not declare. Both bounds *are* + * declared, and `resolveDateRange` reads them in the `'custom'` branch alone, so + * a bound sent with any other period parsed, was accepted, and was then dropped — + * the query answered 200 over the default 30-day window. On a ledger a caller + * reconciles charges against, that is the worst shape of wrong answer: the rows + * are real, they are simply not the rows that were asked for, and nothing in the + * response distinguishes the two. Rejecting names the escape hatch instead. + */ + .superRefine((query, ctx) => { + if (query.period === 'custom') return + for (const field of ['startDate', 'endDate'] as const) { + if (query[field] === undefined) continue + ctx.addIssue({ + code: 'custom', + message: `${field} is only accepted when period=custom; period="${query.period}" computes its own window`, + path: [field], + }) + } + }) + /** + * Parity with `GET /logs` and `GET /workflows/{id}/runs`, which reject an + * inverted window rather than answering with the empty page an unsatisfiable + * `createdAt >= start AND createdAt <= end` produces. + */ + .refine( + (query) => + !query.startDate || + !query.endDate || + Date.parse(query.startDate) <= Date.parse(query.endDate), + { + error: 'startDate must be before or equal to endDate', + path: ['startDate'], + } + ) /** * One credit-consuming usage event. `creditCost` is apportioned across the diff --git a/apps/sim/lib/api/contracts/v2/knowledge.ts b/apps/sim/lib/api/contracts/v2/knowledge.ts index bd10b93f731..8e2f31e4fb9 100644 --- a/apps/sim/lib/api/contracts/v2/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/knowledge.ts @@ -39,7 +39,11 @@ import { v2UploadTransferSchema, } from '@/lib/api/contracts/v2/uploads' import { DEFAULT_CHUNKING_CONFIG } from '@/lib/knowledge/constants' -import { rerankerModelSchema } from '@/lib/knowledge/reranker-models' +import { + DEFAULT_RERANKER_MODEL, + rerankerModelSchema, + rerankerStatusSchema, +} from '@/lib/knowledge/reranker-models' import { knowledgeDocumentUploadMetadataSchema } from '@/lib/knowledge/upload-metadata' import { MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE } from '@/lib/uploads/shared/types' @@ -393,6 +397,18 @@ export const v2KnowledgeSearchDataSchema = z .nonnegative() .describe('Number of results returned.') .meta({ examples: [4] }), + /** + * Required, not optional. Reranking degrades to vector ordering on a provider + * failure or an unconfigured credential, and that fallback was previously + * indistinguishable from a reranker that ran — same 200, same order, no + * `rerankerScore` on any result. A field a caller has to remember to look for + * would reproduce the same gap for anyone who does not. + */ + rerankerStatus: rerankerStatusSchema + .describe( + 'What the reranker did on this search. `applied` means it ordered the results, which carry `rerankerScore`. `unavailable` means reranking was requested and attempted but could not complete, so results are in vector order and carry no `rerankerScore` — the search still succeeded, and the request is worth retrying. `skipped` means there was nothing to rank: a tag-only search, or no matching chunks. `not_requested` means `rerankerEnabled` was absent or false.' + ) + .meta({ examples: ['applied'] }), }) .meta({ id: 'V2KnowledgeSearchData', @@ -815,11 +831,22 @@ export const v2KnowledgeSearchBodySchema = v1KnowledgeSearchBodySchema .boolean() .optional() .describe( - 'Re-order retrieved chunks with a reranking model before truncating to `topK`. Ignored for a tag-only search, which has no query to rank against. Reranking is billed as an additional search unit.' + 'Re-order retrieved chunks with a reranking model before truncating to `topK`. Ignored for a tag-only search, which has no query to rank against. Reranking is billed as an additional search unit. Whether it actually ran is reported by `rerankerStatus` on the response: reranking is best-effort, and a provider failure falls back to vector ordering rather than failing the search.' ), + /** + * Defaulted, matching the internal search contract this one otherwise + * mirrors. Without it, `rerankerEnabled: true` on its own satisfied the + * schema, failed the use case's `input.rerankerModel` guard, and returned a + * 200 in plain vector order — while still paying for the four-times-`topK` + * candidate retrieval that reranking widens. The old description, "required + * for reranking to run", documented the trap instead of removing it. + */ rerankerModel: rerankerModelSchema .optional() - .describe('Reranking model to use; required for reranking to run.'), + .default(DEFAULT_RERANKER_MODEL) + .describe( + `Reranking model to use when \`rerankerEnabled\` is true. Defaults to \`${DEFAULT_RERANKER_MODEL}\`.` + ), rerankerInputCount: z .number() .int('rerankerInputCount must be a whole number') diff --git a/apps/sim/lib/api/contracts/v2/mcp-servers.ts b/apps/sim/lib/api/contracts/v2/mcp-servers.ts index 52015f9750b..c38c446439a 100644 --- a/apps/sim/lib/api/contracts/v2/mcp-servers.ts +++ b/apps/sim/lib/api/contracts/v2/mcp-servers.ts @@ -113,11 +113,18 @@ export const v2McpServerSchema = z enabled: mcpServerSchema.shape.enabled.describe( 'Whether the server tools are available to workflows.' ), + /** + * These three are written only by a real discovery. Registration stores a + * configuration without contacting the endpoint, so it leaves all three at + * their defaults — it used to stamp `connected` and a `lastConnected` of now + * for any non-OAuth server, which made both fields false the moment they + * were first read. + */ connectionStatus: mcpServerSchema.shape.connectionStatus.describe( - 'Result of the most recent connection attempt.' + 'Result of the most recent connection attempt. Registration and re-registration store a configuration without contacting the endpoint, so a server begins — and returns to — `disconnected` until a tool discovery runs.' ), lastError: mcpServerSchema.shape.lastError.describe( - 'Message from the most recent failed connection, or null when absent.' + 'Message from the most recent failed connection, or null when absent. A re-registration clears it, since the configuration it described no longer applies.' ), toolCount: mcpServerSchema.shape.toolCount.describe( 'Number of tools discovered on the server.' @@ -126,7 +133,7 @@ export const v2McpServerSchema = z 'ISO 8601 timestamp of the most recent tool-list refresh.' ), lastConnected: mcpServerSchema.shape.lastConnected.describe( - 'ISO 8601 timestamp of the most recent successful connection.' + 'ISO 8601 timestamp of the most recent successful connection. Absent until the server completes one; registering a server does not set it.' ), createdAt: mcpServerSchema.shape.createdAt.describe( 'ISO 8601 timestamp when the server was registered.' diff --git a/apps/sim/lib/api/contracts/v2/openapi/billing.ts b/apps/sim/lib/api/contracts/v2/openapi/billing.ts index c1555ad90a0..c2c9776269b 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/billing.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/billing.ts @@ -106,7 +106,7 @@ const routes = [ operationId: 'listBillingLogs', summary: 'List Billing Logs', description: - 'List the credit-denominated billing ledger with source filtering and opaque cursor pagination. `period` defaults to `30d`, so an unqualified request covers only the last 30 days: paginating to `nextCursor: null` exhausts that window, not the whole ledger. Pass `period=all` for full history, or `period=custom` with `startDate` and `endDate` for a specific range.', + 'List the credit-denominated billing ledger with source filtering and opaque cursor pagination. `period` defaults to `30d`, so an unqualified request covers only the last 30 days: paginating to `nextCursor: null` exhausts that window, not the whole ledger. Pass `period=all` for full history, or `period=custom` with `startDate` and `endDate` for a specific range. Both bounds are accepted only with `period=custom` — sending either alongside a relative period is a 400, never a page silently answered over a different window — and both take the same strict UTC ISO 8601 form as `GET /api/v2/logs`. An inverted window is a 400 rather than an empty page.', errors: RESOURCE_ERRORS, success: { description: 'A page of usage events.' }, }), diff --git a/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts b/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts index 3503cddf005..7d612d993da 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts @@ -211,7 +211,7 @@ const routes = [ operationId: 'searchKnowledge', summary: 'Search Knowledge', description: - 'Search one or more knowledge bases with semantic vector retrieval, optional hybrid full-text retrieval, and structured tag filters. Set `rerankerEnabled` with a `rerankerModel` to re-order the retrieved chunks with a reranking model before truncating to `topK`; reranked results carry a `rerankerScore` and are ordered by it, and reranking is billed as an additional search unit. Every result names the `knowledgeBaseId` it came from. The request body is capped at 2 MiB; a larger body is a 413.', + 'Search one or more knowledge bases with semantic vector retrieval, optional hybrid full-text retrieval, and structured tag filters. Set `rerankerEnabled` to re-order the retrieved chunks with a reranking model before truncating to `topK`; `rerankerModel` selects the model and defaults when omitted. Reranked results carry a `rerankerScore` and are ordered by it, and reranking is billed as an additional search unit. Reranking is best-effort: a reranker that cannot run — a provider failure, a timeout, or a deployment with no reranking credential — falls back to vector ordering rather than failing the search, so read `rerankerStatus` on the response to tell an ordering the reranker produced from one it never touched. Every result names the `knowledgeBaseId` it came from. The request body is capped at 2 MiB; a larger body is a 413.', errors: [...WORKSPACE_ERRORS, 'UsageLimitExceeded', 'NotFound', 'PayloadTooLarge'], success: { description: 'Matching document chunks ordered by relevance.' }, }), diff --git a/apps/sim/lib/api/contracts/v2/openapi/logs.ts b/apps/sim/lib/api/contracts/v2/openapi/logs.ts index b0cbc32eb83..7665f84fc2b 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/logs.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/logs.ts @@ -5,6 +5,7 @@ import { type ErrorResponseId, RATE_LIMIT_HEADERS, RESOURCE_ERRORS, + RUN_RETENTION, V2_API_KEY_SECURITY, V2_API_KEY_SECURITY_SCHEMES, V2_COMMON_HEADERS, @@ -94,8 +95,7 @@ const routes = [ logsOperation({ operationId: 'listLogs', summary: 'List Logs', - description: - 'List workflow execution logs for a workspace with filters, selectable detail, and opaque cursor pagination. This list predates the shared sort convention: it has no `sortBy` (the sort column is fixed to execution start time) and spells the direction `order` rather than `sortOrder`. Trace spans are stored separately from the log row and are pruned on their own retention schedule: `includeTraceSpans=true` on a run whose stored spans have aged out returns `traceSpans: []` rather than an error, so an empty array does not mean the run recorded no spans.', + description: `List workflow execution logs for a workspace with filters, selectable detail, and opaque cursor pagination. This list predates the shared sort convention: it has no \`sortBy\` (the sort column is fixed to execution start time) and spells the direction \`order\` rather than \`sortOrder\`. ${RUN_RETENTION} Trace spans are stored separately from the log row and are pruned on their own retention schedule: \`includeTraceSpans=true\` on a run whose stored spans have aged out returns \`traceSpans: []\` rather than an error, so an empty array does not mean the run recorded no spans.`, errors: RESOURCE_ERRORS, success: { description: 'A page of execution logs matching the filters.' }, }), diff --git a/apps/sim/lib/api/contracts/v2/openapi/resources.ts b/apps/sim/lib/api/contracts/v2/openapi/resources.ts index 5d0843332f3..ce1d82b25bd 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/resources.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/resources.ts @@ -95,6 +95,16 @@ const MCP_SERVER_EXAMPLE = { hasOauthClientSecret: false, } as const +/** + * What registration actually returns, as distinct from {@link MCP_SERVER_EXAMPLE}, + * which shows a server a discovery has already reached. Reusing the discovered + * example on the create response advertised a connection the call does not make. + */ +const MCP_SERVER_REGISTERED_EXAMPLE = (() => { + const { lastToolsRefresh: _refresh, lastConnected: _connected, ...rest } = MCP_SERVER_EXAMPLE + return { ...rest, connectionStatus: 'disconnected', toolCount: 0 } as const +})() + const MCP_TOOL_EXAMPLE = { name: 'search_docs', description: 'Search the internal documentation', @@ -268,7 +278,7 @@ const routes = [ operationId: 'listMcpServers', summary: 'List MCP Servers', description: - 'List MCP servers registered in a workspace. Request-header values and OAuth client secrets are never returned. Nothing caps how many servers a workspace registers, so this list is paginated: paginate with `limit` and `cursor`, stopping when `nextCursor` is null. `connectionStatus`, `toolCount`, `lastError`, and `lastToolsRefresh` describe the most recent tool discovery and stay at their registration defaults until one runs — call `GET /api/v2/mcp-servers/{id}/tools` to run it.', + 'List MCP servers registered in a workspace. Request-header values and OAuth client secrets are never returned. Nothing caps how many servers a workspace registers, so this list is paginated: paginate with `limit` and `cursor`, stopping when `nextCursor` is null. `connectionStatus`, `lastConnected`, `toolCount`, `lastError`, and `lastToolsRefresh` describe the most recent tool discovery and stay at their registration defaults — `disconnected`, with `lastConnected` absent — until one runs. Call `GET /api/v2/mcp-servers/{id}/tools` to run it.', errors: RESOURCE_ERRORS, success: { description: 'MCP servers registered in the workspace.' }, }), @@ -294,7 +304,7 @@ const routes = [ operationId: 'createMcpServer', summary: 'Create MCP Server', description: - 'Register an MCP server in a workspace. The endpoint URL determines server identity, must be absolute HTTP or HTTPS, and cannot contain environment-variable references. Header values and OAuth client secrets are write-only. `transport`, `timeout`, `retries`, and `enabled` are applied server-side when omitted; the effective values are in the response.', + 'Register an MCP server in a workspace. The endpoint URL determines server identity, must be absolute HTTP or HTTPS, and cannot contain environment-variable references. Header values and OAuth client secrets are write-only. `transport`, `timeout`, `retries`, and `enabled` are applied server-side when omitted; the effective values are in the response. Registration stores the configuration and does not connect to the endpoint, so a 201 is not evidence the server is reachable: the response carries `connectionStatus: "disconnected"` and omits `lastConnected`, and the workspace tool registry treats the server as unavailable until a discovery succeeds. Call `GET /api/v2/mcp-servers/{id}/tools` to attempt one and see the outcome. Re-registering an existing URL rewrites the configuration and returns the server to the same unverified state.', errors: RESOURCE_CONFLICT_BODY_ERRORS, success: { description: 'The MCP server was registered.' }, }), @@ -319,7 +329,7 @@ const routes = [ 'CreateMcpServerResponse', 'Create MCP server response', 'The registered MCP server without write-only credentials.', - [{ data: MCP_SERVER_EXAMPLE }] + [{ data: MCP_SERVER_REGISTERED_EXAMPLE }] ), } ), @@ -482,8 +492,7 @@ const routes = [ resourceOperation('Skills', { operationId: 'createSkill', summary: 'Create Skill', - description: - 'Create one skill in a workspace. Its kebab-case name must be unique and cannot be reserved by a built-in skill. Note that a workspace API key may create a skill but may not later update or delete it.', + description: `Create one skill in a workspace. Its kebab-case name must be unique and cannot be reserved by a built-in skill. ${WORKSPACE_API_KEY_DENIED}`, errors: RESOURCE_CONFLICT_BODY_ERRORS, success: { description: 'The skill was created.' }, }), diff --git a/apps/sim/lib/api/contracts/v2/openapi/shared.ts b/apps/sim/lib/api/contracts/v2/openapi/shared.ts index 80920a3be61..ae6bf88525f 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/shared.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/shared.ts @@ -239,6 +239,30 @@ export const WORKSPACE_API_KEY_DENIED = export const WORKSPACE_API_KEY_DENIED_AS_NOT_FOUND = 'A workspace API key cannot call this operation. Because unauthorized resources are concealed, the rejection is reported as `404` rather than `403`; use a personal API key.' +/** + * Appended to the two reads over `workflow_execution_logs`, which is the only + * store of a run and is hard-deleted — rows and execution files both — by the + * `cleanup-logs` background task once a run passes the payer's window. + * + * The window itself is `CLEANUP_CONFIG['cleanup-logs'].defaults` in + * `lib/billing/cleanup-dispatcher.ts`: 30 days on the free plan, and `null` + * — meaning the plan is skipped entirely and nothing is deleted — on Pro and + * Team. Enterprise resolves per organization through + * `resolveEffectiveRetentionHours`, with a per-workspace override, and is + * likewise unbounded until someone configures it. Self-hosted classifies every + * workspace as enterprise and dispatches nothing unless data retention is + * enabled. + * + * Stated because deletion is otherwise invisible: an aged-out run is not a + * tombstone or a 404, it is simply absent, and `runCount` on the workflow is + * never decremented to match — so a free-plan workflow can report dozens of + * runs beside an empty list and nothing in either response explains the gap. + * Kept as one constant so the two sibling reads cannot drift into two + * paraphrases of one window. + */ +export const RUN_RETENTION = + "Runs are hard-deleted once they pass the payer's log retention window, so an older run is absent from this list rather than reported as removed. The window is 30 days from run start on the free plan; Pro and Team have none configured and keep runs indefinitely; Enterprise sets its own per organization, with an optional per-workspace override, and is also unbounded until configured. A workflow's `runCount` is never reduced by this deletion, so a workflow can report runs while this list is empty." + export const V2_COMMON_HEADERS = { 'X-RateLimit-Limit': { schema: z.number().int().nonnegative().meta({ diff --git a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts index c2600ca27f6..7341b0255c0 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts @@ -8,6 +8,7 @@ import { RESOURCE_CONFLICT_ERRORS, RESOURCE_ERRORS, RESOURCE_MUTATION_ERRORS, + RUN_RETENTION, V2_API_KEY_SECURITY, V2_API_KEY_SECURITY_SCHEMES, V2_COMMON_HEADERS, @@ -592,8 +593,7 @@ const routes = [ workflowRunOperation({ operationId: 'listWorkflowRunsV2', summary: 'List Workflow Runs', - description: - 'List recorded runs of a workflow with filtering and opaque cursor pagination. Ordering deviates from the v2 `sortBy` + `sortOrder` convention: runs are sortable only by start time, so direction is carried by the single `order` param.', + description: `List recorded runs of a workflow with filtering and opaque cursor pagination. Ordering deviates from the v2 \`sortBy\` + \`sortOrder\` convention: runs are sortable only by start time, so direction is carried by the single \`order\` param. ${RUN_RETENTION}`, errors: RESOURCE_ERRORS, success: jsonSuccess('A page of workflow runs.'), }), diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index aa834c90874..a2a1b3c1be5 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -170,11 +170,31 @@ export const v2WorkflowListItemSchema = z .nullable() .describe('ISO 8601 activation timestamp, or null when not deployed.') .meta({ format: 'date-time' }), - runCount: z.number().int().nonnegative().describe('Total recorded workflow runs.'), + /** + * A monotonic column on the workflow row, not an aggregate over the run + * list. `updateWorkflowRunCounts` is called from exactly one place — + * `executeWorkflowCore`'s post-execution hook, under + * `result.success && result.status !== 'paused'` — and nothing ever + * decrements it, so the two ways it disagrees with + * `GET /workflows/{id}/runs` point in opposite directions and both are + * reachable at once. The description is what makes that legible; the + * counter itself is left alone because its stored values already carry the + * narrow meaning and no backfill can recover runs whose logs retention has + * already deleted. + */ + runCount: z + .number() + .int() + .nonnegative() + .describe( + 'Runs that finished successfully. A run that failed, was cancelled, or is still paused is not counted, and the counter is never reduced when a run ages out of log retention — so this is not the number of runs `GET /api/v2/workflows/{id}/runs` returns, in either direction.' + ), lastRunAt: z .string() .nullable() - .describe('ISO 8601 timestamp of the latest run, or null when never run.') + .describe( + 'ISO 8601 timestamp of the latest run counted by `runCount`, or null when none has been. Stamped by the same successful-run path, so a workflow whose only runs failed reports null here.' + ) .meta({ format: 'date-time' }), createdAt: z .string() diff --git a/apps/sim/lib/knowledge/application/search.test.ts b/apps/sim/lib/knowledge/application/search.test.ts index e089eb98761..d7e5571c10e 100644 --- a/apps/sim/lib/knowledge/application/search.test.ts +++ b/apps/sim/lib/knowledge/application/search.test.ts @@ -17,6 +17,11 @@ const mocks = vi.hoisted(() => ({ getTagDefinitions: vi.fn(), recordEmbeddingUsage: vi.fn(), importProvenance: vi.fn(), + rerank: vi.fn(), +})) + +vi.mock('@/lib/knowledge/reranker', () => ({ + rerank: mocks.rerank, })) vi.mock('@sim/platform-authz/workspace', () => ({ @@ -307,6 +312,75 @@ describe('knowledge search application use case', () => { }) }) + describe('reranker outcome reporting', () => { + const rerankedSearch = (rerankerEnabled?: boolean, query: string | undefined = 'answer') => + searchKnowledge.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workspaceId: 'workspace-1', + knowledgeBaseIds: ['knowledge-1'], + ...(query === undefined ? {} : { query }), + topK: 5, + ...(rerankerEnabled === undefined ? {} : { rerankerEnabled }), + rerankerModel: 'rerank-v4.0-pro' as const, + }, + }) + + it('reports applied when the reranker ordered the results', async () => { + mocks.rerank.mockResolvedValueOnce({ + results: [{ item: { id: 'embedding-1' }, relevanceScore: 0.93 }], + isBYOK: false, + }) + + const result = await rerankedSearch(true) + + expect(result.rerankerStatus).toBe('applied') + expect(result.results[0]).toMatchObject({ rerankerScore: 0.93 }) + }) + + /** + * The reproduced defect: a deployment with no Cohere credential threw inside + * `rerank`, the use case swallowed it, and the caller got a 200 whose results + * were byte-identical to an unreranked search with nothing to distinguish them. + */ + it('reports unavailable rather than silently falling back to vector ordering', async () => { + mocks.rerank.mockRejectedValueOnce(new Error('No Cohere API key configured.')) + + const result = await rerankedSearch(true) + + expect(result.rerankerStatus).toBe('unavailable') + expect(result.results[0]).not.toHaveProperty('rerankerScore') + }) + + it('reports skipped for a tag-only search, which has no query to rank against', async () => { + mocks.getTagDefinitions.mockResolvedValue([ + { tagSlot: 'tag1', displayName: 'team', fieldType: 'text' }, + ]) + + const result = await searchKnowledge.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workspaceId: 'workspace-1', + knowledgeBaseIds: ['knowledge-1'], + topK: 5, + tagFilters: [{ tagName: 'team', operator: 'eq', value: 'docs' }], + rerankerEnabled: true, + rerankerModel: 'rerank-v4.0-pro' as const, + }, + }) + + expect(result.rerankerStatus).toBe('skipped') + expect(mocks.rerank).not.toHaveBeenCalled() + }) + + it('reports not_requested when the caller did not ask for reranking', async () => { + const result = await rerankedSearch(undefined) + + expect(result.rerankerStatus).toBe('not_requested') + expect(mocks.rerank).not.toHaveBeenCalled() + }) + }) + it('propagates tag-definition infrastructure failures', async () => { const failure = new Error('tag database unavailable') mocks.getTagDefinitions.mockRejectedValueOnce(failure) diff --git a/apps/sim/lib/knowledge/application/search.ts b/apps/sim/lib/knowledge/application/search.ts index 131853d1c0a..3b4e9d7328a 100644 --- a/apps/sim/lib/knowledge/application/search.ts +++ b/apps/sim/lib/knowledge/application/search.ts @@ -30,6 +30,7 @@ import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants' import { getEmbeddingModelInfo } from '@/lib/knowledge/embedding-models' import { runWithKnowledgeModelInputProvenance } from '@/lib/knowledge/model-input-provenance' import { rerank } from '@/lib/knowledge/reranker' +import type { RerankerStatus } from '@/lib/knowledge/reranker-models' import { executeKnowledgeSearch, generateSearchEmbedding, @@ -316,6 +317,25 @@ export const searchKnowledge = defineAuthorizedKnowledgeUseCase({ const rerankerScores = new Map() let rerankerBilled = false let rerankerIsBYOK = false + /** + * Returned on every search. The fallback to vector ordering is deliberate — a + * Cohere outage should not take knowledge search down with it — but until this + * was reported the fallback was also invisible: a 200 whose results were + * byte-identical to an unreranked search, with no `rerankerScore` anywhere and + * nothing to say why. + * + * It starts at the outcome that holds if the rerank call below never happens or + * never completes, so only the success path has to move it. A request with + * nothing to rank — no query text, or no candidate rows — is `skipped` rather + * than `unavailable`: the reranker was never the obstacle. Anything else that + * was asked for and did not run is `unavailable`, including a request that + * reaches here with no model, which no HTTP contract can now produce. + */ + let rerankerStatus: RerankerStatus = !input.rerankerEnabled + ? 'not_requested' + : !hasQuery || rows.length === 0 + ? 'skipped' + : 'unavailable' if (useReranker && input.rerankerModel && rows.length > 0) { const candidateCount = rows.length try { @@ -343,6 +363,7 @@ export const searchKnowledge = defineAuthorizedKnowledgeUseCase({ for (const ranked of reranked.results) { rerankerScores.set(ranked.item.id, ranked.relevanceScore) } + rerankerStatus = 'applied' } } catch (error) { if (registry?.isPermanentlyIncomplete()) throw error @@ -352,6 +373,7 @@ export const searchKnowledge = defineAuthorizedKnowledgeUseCase({ candidateCount, }) rows = rows.slice(0, input.topK) + rerankerStatus = 'unavailable' } } else if (useReranker) { rows = rows.slice(0, input.topK) @@ -505,6 +527,7 @@ export const searchKnowledge = defineAuthorizedKnowledgeUseCase({ knowledgeBaseId: knowledgeBaseIds[0], topK: input.topK, totalResults: results.length, + rerankerStatus, cost, ...(context.workspaceId ? { workspaceId: context.workspaceId } : {}), userId, diff --git a/apps/sim/lib/knowledge/reranker-models.ts b/apps/sim/lib/knowledge/reranker-models.ts index 352efc4deb3..203329bd5a4 100644 --- a/apps/sim/lib/knowledge/reranker-models.ts +++ b/apps/sim/lib/knowledge/reranker-models.ts @@ -16,3 +16,25 @@ export const DEFAULT_RERANKER_MODEL: RerankerModelId = 'rerank-v4.0-fast' export function isSupportedRerankerModel(model: string): model is RerankerModelId { return rerankerModelSchema.safeParse(model).success } + +/** + * What the reranker actually did on a search, reported on every response. + * + * Reranking is best-effort by design: a provider outage, a timeout, or a + * deployment with no Cohere credential falls back to vector ordering so the + * search still answers. Without this field that fallback is invisible — the + * caller gets a 200, results in plain vector order, and no `rerankerScore` on + * any of them, which is indistinguishable from a reranker that ran and happened + * to agree with the vector order. Reporting the outcome is what makes a + * degradation detectable instead of a silent lie. + * + * - `not_requested` — `rerankerEnabled` was absent or false. + * - `skipped` — requested, but there was nothing to rank: a tag-only search has + * no query text to rank against, and a search that matched nothing has no + * candidates. + * - `unavailable` — requested and attempted, but the reranker could not + * complete. Results are in vector order and carry no `rerankerScore`. + * - `applied` — the reranker ordered the results, which carry `rerankerScore`. + */ +export const rerankerStatusSchema = z.enum(['not_requested', 'skipped', 'unavailable', 'applied']) +export type RerankerStatus = z.output diff --git a/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts b/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts index e8fd921b883..e83df3dd14d 100644 --- a/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts +++ b/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts @@ -250,6 +250,75 @@ describe('MCP server lifecycle orchestration', () => { expect(mockRevokeOauthTokens).toHaveBeenCalledWith('server-1', 'workspace-1') }) + it('registers a new server as disconnected rather than stamping a connection it never made', async () => { + mockGenerateMcpServerId.mockReturnValue('server-1') + dbChainMockFns.limit.mockResolvedValueOnce([]) + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'server-1', + workspaceId: 'workspace-1', + name: 'Example', + transport: 'streamable-http', + url: 'https://example.com/anything', + authType: 'headers', + }, + ]) + + const result = await performCreateMcpServer({ + workspaceId: 'workspace-1', + userId: 'user-1', + name: 'Example', + url: 'https://example.com/anything', + headers: { authorization: 'Bearer token' }, + }) + + expect(result.success).toBe(true) + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ connectionStatus: 'disconnected', lastConnected: null }) + ) + }) + + it('leaves a re-registered server disconnected until discovery re-runs', async () => { + mockGenerateMcpServerId.mockReturnValue('server-1') + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'server-1', + deletedAt: null, + url: 'https://example.com/mcp', + authType: 'headers', + oauthClientId: null, + oauthClientSecret: null, + }, + ]) + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'server-1', + workspaceId: 'workspace-1', + name: 'Example', + transport: 'streamable-http', + url: 'https://example.com/mcp', + authType: 'headers', + }, + ]) + + const result = await performCreateMcpServer({ + workspaceId: 'workspace-1', + userId: 'user-1', + name: 'Example', + url: 'https://example.com/mcp', + headers: { authorization: 'Bearer rotated' }, + }) + + expect(result.success).toBe(true) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + connectionStatus: 'disconnected', + lastConnected: null, + lastError: null, + }) + ) + }) + it('audits a re-registration that rewrites a live server as an update', async () => { mockGenerateMcpServerId.mockReturnValue('server-1') dbChainMockFns.limit.mockResolvedValueOnce([ diff --git a/apps/sim/lib/mcp/orchestration/server-lifecycle.ts b/apps/sim/lib/mcp/orchestration/server-lifecycle.ts index 32df6148dd9..3330b6efc72 100644 --- a/apps/sim/lib/mcp/orchestration/server-lifecycle.ts +++ b/apps/sim/lib/mcp/orchestration/server-lifecycle.ts @@ -204,7 +204,6 @@ export async function createMcpServer( currentEncryptedClientSecret: existingServer.oauthClientSecret, }) const isRevival = existingServer.deletedAt !== null - const authTypeChanged = existingServer.authType !== resolvedAuthType // Turning OAuth off orphans its tokens; revoke and delete them, mirroring the update path. const oauthDisabled = existingServer.authType === 'oauth' && resolvedAuthType !== 'oauth' const shouldClearOauth = urlChanged || credsChanged || isRevival || oauthDisabled @@ -229,19 +228,21 @@ export async function createMcpServer( updatedAt: new Date(), deletedAt: null, } - if (authTypeChanged || (shouldClearOauth && resolvedAuthType === 'oauth')) { - // An auth-type flip, or an OAuth URL/creds change, invalidates any prior connection: - // reset to disconnected and clear the stale error so the UI never shows - // connected-with-error until re-discovery. Mirrors performUpdateMcpServer. - updateValues.connectionStatus = 'disconnected' - updateValues.lastConnected = null - updateValues.lastError = null - } else if (resolvedAuthType !== 'oauth') { - // A non-OAuth (re-)registration with unchanged auth optimistically marks the server - // reachable; discovery corrects it if the endpoint is unhealthy. - updateValues.connectionStatus = 'connected' - updateValues.lastConnected = new Date() - } + /** + * A re-registration rewrites the URL, headers, transport, and timeouts — + * i.e. every input to a connection — so whatever the previous discovery + * established no longer describes this configuration. It resets rather + * than branching on auth type: the former `else` branch stamped + * `connected` plus a fresh `lastConnected` for any non-OAuth + * re-registration without contacting the endpoint, which published a + * successful connection that never happened and, because it left + * `lastError` alone, could publish `connected` beside a stale error. + * `mcpService.updateServerStatus` is the only writer entitled to claim a + * connection, and it does so after a real discovery. + */ + updateValues.connectionStatus = 'disconnected' + updateValues.lastConnected = null + updateValues.lastError = null if (params.oauthClientIdProvided) updateValues.oauthClientId = oauthClientId if (params.oauthClientSecretProvided) { updateValues.oauthClientSecret = oauthClientSecretEncrypted @@ -291,8 +292,21 @@ export async function createMcpServer( timeout, retries, enabled, - connectionStatus: resolvedAuthType === 'oauth' ? 'disconnected' : 'connected', - lastConnected: resolvedAuthType === 'oauth' ? null : new Date(), + /** + * Registration stores a configuration; it does not open a connection. The + * only network touch on this path is `detectMcpAuthType`, an OAuth + * discovery probe whose failure is swallowed, so a URL serving static HTML + * — or nothing at all — reached this insert and was written as + * `connected` with `lastConnected` set to now. Both columns are contracted + * as the result of, and the time of, a real connection attempt, and + * `tool-validation.ts` gates tool availability on the first of them, so an + * unverified server read as healthy. The honest initial state is the + * column default; `mcpService.updateServerStatus` moves it once a + * discovery actually runs, which `isServerEligibleForDiscovery` allows for + * a non-OAuth server immediately. + */ + connectionStatus: 'disconnected', + lastConnected: null, createdAt: new Date(), updatedAt: new Date(), }) diff --git a/apps/sim/lib/skills/application/operations.test.ts b/apps/sim/lib/skills/application/operations.test.ts index 7c03a3c3ed8..9577e779dcb 100644 --- a/apps/sim/lib/skills/application/operations.test.ts +++ b/apps/sim/lib/skills/application/operations.test.ts @@ -15,13 +15,31 @@ import { skillOperations } from '@/lib/skills/application/operations' * These tests exist so the next reader finds the reason instead of "fixing" it. */ describe('skill operation registry', () => { - it('gates creation on workspace role, which a workspace key can express', () => { + it('gates creation on a human subject, like every other write', () => { expect(skillOperations.create).toMatchObject({ minimumRole: 'write', - workspaceApiKey: 'allow', + workspaceApiKey: 'deny', + principalKinds: ['session', 'personal_api_key', 'delegated'], }) }) + /** + * The invariant the create/delete split violated. A principal kind that can + * create a skill must be able to remove it, or its only possible interaction + * with the resource is to accumulate rows it can never reach again. + */ + it('admits the same principal kinds to every write in the lifecycle', () => { + const writes = [ + skillOperations.create, + skillOperations.update, + skillOperations.upsert, + skillOperations.delete, + ] + const policies = writes.map((operation) => operation.workspaceApiKey) + + expect(new Set(policies).size).toBe(1) + }) + it('gates every edit path on a human subject rather than workspace role', () => { for (const operation of [ skillOperations.update, diff --git a/apps/sim/lib/skills/application/operations.ts b/apps/sim/lib/skills/application/operations.ts index 5540ce2f2e8..ccdcb4380a9 100644 --- a/apps/sim/lib/skills/application/operations.ts +++ b/apps/sim/lib/skills/application/operations.ts @@ -10,20 +10,29 @@ const HUMAN_PRINCIPAL_POLICY = { } as const /** - * Skill operations split on workspace API keys, and the split is structural - * rather than an oversight. + * Every skill write is human-subject-only. Reads are not. * - * `create` is gated on workspace `write`, which a workspace key can express, so - * it allows one. `update`, `upsert`, and `delete` are not gated on workspace - * role at all — their floor is `read` because the real authority is the - * per-skill editor row that `resolveEditableSkill` checks against the acting - * user. A workspace key has no user subject to check, so those operations deny - * it: `requirePrincipalSubjectUserId` would otherwise throw an unclassified - * error and surface as a caller-reachable `500` instead of a `403`. + * `update`, `upsert`, and `delete` are not gated on workspace role at all — + * their floor is `read` because the real authority is the per-skill editor row + * that `resolveEditableSkill` checks against the acting user. A workspace key + * has no user subject to check, so those operations deny it: + * `requirePrincipalSubjectUserId` would otherwise throw an unclassified error + * and surface as a caller-reachable `500` instead of a `403`. Widening them is + * not a policy flip — it needs an authorization model for a keyless principal + * against per-skill editors, which does not exist. * - * Widening them therefore is not a policy flip — it needs an authorization model - * for a keyless principal against per-skill editors, which does not exist. - * Pinned in `operations.test.ts`. + * `create` used to allow a workspace key on the reasoning that it is gated on + * workspace `write`, which a key can express. That reasoning held for the + * authorization check and broke everything after it. A workspace key that + * created a skill could never update or delete it, so its only possible + * interaction with the resource was to accumulate rows beyond its own reach — + * and the row it left behind was not even attributable to it: `create` + * attributes through `resolvePrincipalAttribution`, which maps a workspace key + * to the workspace's billing owner, so the write minted a `skill_member` editor + * grant for a human who did not act and who alone (with workspace admins) could + * then remove it. Denying `create` makes the lifecycle symmetric on the only + * consistent side available: the same per-skill editor model authorizes the + * whole of it. Pinned in `operations.test.ts`. */ export const skillOperations = { list: defineWorkspaceOperation({ @@ -47,8 +56,8 @@ export const skillOperations = { create: defineWorkspaceOperation({ id: 'skills.create', minimumRole: 'write', - workspaceApiKey: 'allow', - ...ALL_PRINCIPAL_POLICY, + workspaceApiKey: 'deny', + ...HUMAN_PRINCIPAL_POLICY, }), update: defineWorkspaceOperation({ id: 'skills.update', diff --git a/apps/sim/lib/workflows/executor/execution-core.test.ts b/apps/sim/lib/workflows/executor/execution-core.test.ts index 753f632109c..91aec41b9ec 100644 --- a/apps/sim/lib/workflows/executor/execution-core.test.ts +++ b/apps/sim/lib/workflows/executor/execution-core.test.ts @@ -1153,6 +1153,33 @@ describe('executeWorkflowCore terminal finalization sequencing', () => { expect(clearExecutionCancellationMock).toHaveBeenCalledWith('execution-1') }) + /** + * The population `runCount` actually counts. Cancelled and paused runs are + * already pinned above; a plain failure is the case a caller is most likely to + * assume is included, and the workflow contract's `runCount` description is + * written against this. + */ + it('leaves runCount untouched when the run fails', async () => { + executorExecuteMock.mockResolvedValue({ + success: false, + status: 'failed', + output: {}, + logs: [], + error: 'block threw', + metadata: { duration: 123, startTime: 'start', endTime: 'end' }, + }) + + await executeWorkflowCore({ + snapshot: createSnapshot() as any, + callbacks: {}, + loggingSession: loggingSession as any, + }) + + await loggingSession.setPostExecutionPromise.mock.calls[0][0] + + expect(updateWorkflowRunCountsMock).not.toHaveBeenCalled() + }) + it('routes paused executions through safeCompleteWithPause', async () => { const executionState = { blockStates: { 'function-1': { output: { result: 'raw-secret-value' } } }, From 551de85f222b7bcdb2076d89cd9457324f4e6fb4 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 16:59:48 -0700 Subject: [PATCH 10/56] fix(tables): refuse the writes v2 was silently discarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Uncoercible cell values were stored as null under a 200 on any optional column: "abc"/true/[1] into number, "yes"/1/{} into boolean, "not-a-date" into date, an undeclared option into select, an object into string. The read side already 400s on the same mismatch in a predicate, so the two halves of the API disagreed about the same value. `coerceRowValues` / `coerceRowToSchema` now take an explicit policy and default to `reject`; `null` is passed only where a machine produced the value for a cell no caller typed — a computed (workflow/enrichment) write and a CSV import, neither of which has anyone to answer with a 400. - A multi-select coerced `["green"]` to `[]` — the drop was inside the registry, so no policy above it could see it. It now refuses any part that matches no option, which is what the single branch and the bulk retype gate already did. - A bare number in a date cell was read as epoch milliseconds, so the far more common Unix-seconds shape stored a timestamp 50 years early. The unit is not recoverable from the value and both readings are in range, so a bare number is refused in both directions and the retype gate no longer needs an override to be stricter than the write path. - Unknown column names were dropped by the name→id remap: an insert of {"nosuchcol":"x"} created an empty row under a 201, and a patch of {"zzz":"x"} answered updatedCount:0, indistinguishable from an empty match. The v2 row boundary now names them and refuses. - The table ceiling was enforced only inside createTable, which for an upload-backed import does not run until the CSV has crossed the wire: a full workspace got a 201 and a presigned PUT for up to 5 GiB, then a 403 at complete with an orphaned object left behind. The advisory check now runs when the session is created; the authoritative one stays in the transaction because the quota can move mid-upload. - Cap workflow groups per table. GET /tables/{id}/groups is published as a full-set list, and the group count had no bound of its own — the indirect one does not survive an update path that adds no columns. - Present a group's outputs/dependencies/inputMappings by column NAME. They are created by name, stored by id, and were read back as ids on a surface that is otherwise name-keyed, so a group could not be round-tripped. - Publish the predicate grammar: the operator set, the per-type restrictions, and that `*` — not `%` — is the wildcard. It was true only in the SQL builder's own comments, so the natural guess matched zero rows under a 200. - Stop advertising a `workflowId` default of "" on group create; a manual group that omits it has always been refused. --- apps/docs/openapi-v2-tables.json | 30 ++--- .../v2/tables/[tableId]/groups/route.test.ts | 26 ++++- .../api/v2/tables/[tableId]/groups/route.ts | 16 ++- apps/sim/app/api/v2/tables/presenters.test.ts | 58 ++++++++++ apps/sim/app/api/v2/tables/presenters.ts | 19 ++++ apps/sim/lib/api/contracts/tables.ts | 39 ++++++- .../api/contracts/v2/__tests__/tables.test.ts | 40 +++++++ apps/sim/lib/api/contracts/v2/tables.ts | 19 +++- .../__tests__/column-type-registry.test.ts | 39 ++++--- .../lib/table/__tests__/validation.test.ts | 40 +++++-- apps/sim/lib/table/application/groups.ts | 5 +- apps/sim/lib/table/application/rows.test.ts | 103 +++++++++++++++++- apps/sim/lib/table/application/rows.ts | 34 +++++- apps/sim/lib/table/cell-write.ts | 6 +- apps/sim/lib/table/column-keys.ts | 20 +++- apps/sim/lib/table/column-types/date.ts | 35 +++--- apps/sim/lib/table/column-types/select.ts | 15 ++- apps/sim/lib/table/constants.ts | 9 ++ apps/sim/lib/table/import-data.ts | 5 +- .../orchestration/import-resource.test.ts | 73 +++++++++++++ .../table/orchestration/import-resource.ts | 15 ++- apps/sim/lib/table/rows/service.ts | 23 +++- apps/sim/lib/table/service.ts | 79 ++++++++++---- apps/sim/lib/table/validation.test.ts | 96 +++++++++++++++- apps/sim/lib/table/validation.ts | 51 +++++++-- .../lib/table/workflow-groups/service.test.ts | 103 ++++++++++++++++++ apps/sim/lib/table/workflow-groups/service.ts | 7 ++ 27 files changed, 881 insertions(+), 124 deletions(-) create mode 100644 apps/sim/lib/table/workflow-groups/service.test.ts diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 5ec3f9e398d..b675225d6b1 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -5010,7 +5010,7 @@ "description": "Unique workspace identifier." }, "filter": { - "description": "Recursive predicate tree with exactly one non-empty `all` or `any` group at each group node." + "description": "Recursive predicate tree with exactly one non-empty `all` or `any` group at each group node. Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Membership: `in`, `nin` (array operand). Emptiness: `isEmpty`, `isNotEmpty`, `isNull`, `isNotNull` (no operand). Substring, always case-insensitive, operand matched literally: `contains`, `ncontains`, `startsWith`, `endsWith`. Pattern: `like`/`nlike` (case-sensitive), `ilike`/`nilike` (case-insensitive). **`*` is the only wildcard** and stands for any run of characters; `%`, `_`, and backslash match themselves. Use `like: \"Hi*\"`, not `like: \"Hi%\"`. A `select` column compares by option id and restricts its operators: single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names are accepted as operands and resolved to ids." }, "data": { "description": "Row-data patch applied to every matching row.", @@ -5081,7 +5081,7 @@ "description": "Unique workspace identifier." }, "filter": { - "description": "Recursive predicate tree with exactly one non-empty `all` or `any` group at each group node." + "description": "Recursive predicate tree with exactly one non-empty `all` or `any` group at each group node. Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Membership: `in`, `nin` (array operand). Emptiness: `isEmpty`, `isNotEmpty`, `isNull`, `isNotNull` (no operand). Substring, always case-insensitive, operand matched literally: `contains`, `ncontains`, `startsWith`, `endsWith`. Pattern: `like`/`nlike` (case-sensitive), `ilike`/`nilike` (case-insensitive). **`*` is the only wildcard** and stands for any run of characters; `%`, `_`, and backslash match themselves. Use `like: \"Hi*\"`, not `like: \"Hi%\"`. A `select` column compares by option id and restricts its operators: single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names are accepted as operands and resolved to ids." }, "limit": { "description": "Maximum matching rows to delete.", @@ -5281,7 +5281,7 @@ "description": "Unique workspace identifier." }, "predicate": { - "description": "Recursive predicate tree with exactly one non-empty `all` or `any` group at each group node." + "description": "Recursive predicate tree with exactly one non-empty `all` or `any` group at each group node. Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Membership: `in`, `nin` (array operand). Emptiness: `isEmpty`, `isNotEmpty`, `isNull`, `isNotNull` (no operand). Substring, always case-insensitive, operand matched literally: `contains`, `ncontains`, `startsWith`, `endsWith`. Pattern: `like`/`nlike` (case-sensitive), `ilike`/`nilike` (case-insensitive). **`*` is the only wildcard** and stands for any run of characters; `%`, `_`, and backslash match themselves. Use `like: \"Hi*\"`, not `like: \"Hi%\"`. A `select` column compares by option id and restricts its operators: single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names are accepted as operands and resolved to ids." }, "sort": { "description": "Ordered table-row sort specification.", @@ -5381,7 +5381,7 @@ "description": "Unique workspace identifier." }, "predicate": { - "description": "Recursive predicate tree with exactly one non-empty `all` or `any` group at each group node." + "description": "Recursive predicate tree with exactly one non-empty `all` or `any` group at each group node. Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Membership: `in`, `nin` (array operand). Emptiness: `isEmpty`, `isNotEmpty`, `isNull`, `isNotNull` (no operand). Substring, always case-insensitive, operand matched literally: `contains`, `ncontains`, `startsWith`, `endsWith`. Pattern: `like`/`nlike` (case-sensitive), `ilike`/`nilike` (case-insensitive). **`*` is the only wildcard** and stands for any run of characters; `%`, `_`, and backslash match themselves. Use `like: \"Hi*\"`, not `like: \"Hi%\"`. A `select` column compares by option id and restricts its operators: single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names are accepted as operands and resolved to ids." } }, "required": ["workspaceId"], @@ -5639,7 +5639,7 @@ "description": "Saved row predicate, or null when the view is unfiltered.", "anyOf": [ { - "description": "Recursive predicate condition or group, normalized to a grouped predicate after validation." + "description": "Recursive predicate condition or group, normalized to a grouped predicate after validation. Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Membership: `in`, `nin` (array operand). Emptiness: `isEmpty`, `isNotEmpty`, `isNull`, `isNotNull` (no operand). Substring, always case-insensitive, operand matched literally: `contains`, `ncontains`, `startsWith`, `endsWith`. Pattern: `like`/`nlike` (case-sensitive), `ilike`/`nilike` (case-insensitive). **`*` is the only wildcard** and stands for any run of characters; `%`, `_`, and backslash match themselves. Use `like: \"Hi*\"`, not `like: \"Hi%\"`. A `select` column compares by option id and restricts its operators: single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names are accepted as operands and resolved to ids." }, { "type": "null" @@ -5752,7 +5752,7 @@ "description": "Saved row predicate, or null when the view is unfiltered.", "anyOf": [ { - "description": "Recursive predicate condition or group, normalized to a grouped predicate after validation." + "description": "Recursive predicate condition or group, normalized to a grouped predicate after validation. Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Membership: `in`, `nin` (array operand). Emptiness: `isEmpty`, `isNotEmpty`, `isNull`, `isNotNull` (no operand). Substring, always case-insensitive, operand matched literally: `contains`, `ncontains`, `startsWith`, `endsWith`. Pattern: `like`/`nlike` (case-sensitive), `ilike`/`nilike` (case-insensitive). **`*` is the only wildcard** and stands for any run of characters; `%`, `_`, and backslash match themselves. Use `like: \"Hi*\"`, not `like: \"Hi%\"`. A `select` column compares by option id and restricts its operators: single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names are accepted as operands and resolved to ids." }, { "type": "null" @@ -5832,7 +5832,7 @@ "description": "Saved row predicate, or null when the view is unfiltered.", "anyOf": [ { - "description": "Recursive predicate condition or group, normalized to a grouped predicate after validation." + "description": "Recursive predicate condition or group, normalized to a grouped predicate after validation. Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Membership: `in`, `nin` (array operand). Emptiness: `isEmpty`, `isNotEmpty`, `isNull`, `isNotNull` (no operand). Substring, always case-insensitive, operand matched literally: `contains`, `ncontains`, `startsWith`, `endsWith`. Pattern: `like`/`nlike` (case-sensitive), `ilike`/`nilike` (case-insensitive). **`*` is the only wildcard** and stands for any run of characters; `%`, `_`, and backslash match themselves. Use `like: \"Hi*\"`, not `like: \"Hi%\"`. A `select` column compares by option id and restricts its operators: single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names are accepted as operands and resolved to ids." }, { "type": "null" @@ -5970,7 +5970,7 @@ }, "columnName": { "type": "string", - "description": "Table column receiving the output." + "description": "Name of the table column receiving the output." } }, "required": ["blockId", "path", "columnName"], @@ -5990,7 +5990,7 @@ }, "columnName": { "type": "string", - "description": "Source table column name." + "description": "Name of the source table column." } }, "required": ["inputName", "columnName"], @@ -6156,9 +6156,9 @@ "minLength": 1 }, "workflowId": { - "default": "", - "description": "Backing workflow identifier for a manual group.", - "type": "string" + "description": "Backing workflow identifier. Required when `type` is `manual` (which is also the default when `type` is omitted); omit it for an `enrichment` group.", + "type": "string", + "minLength": 1 }, "enrichmentId": { "description": "Registry enrichment identifier.", @@ -6686,7 +6686,7 @@ } }, "filter": { - "description": "Recursive predicate tree with exactly one non-empty `all` or `any` group at each group node." + "description": "Recursive predicate tree with exactly one non-empty `all` or `any` group at each group node. Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Membership: `in`, `nin` (array operand). Emptiness: `isEmpty`, `isNotEmpty`, `isNull`, `isNotNull` (no operand). Substring, always case-insensitive, operand matched literally: `contains`, `ncontains`, `startsWith`, `endsWith`. Pattern: `like`/`nlike` (case-sensitive), `ilike`/`nilike` (case-insensitive). **`*` is the only wildcard** and stands for any run of characters; `%`, `_`, and backslash match themselves. Use `like: \"Hi*\"`, not `like: \"Hi%\"`. A `select` column compares by option id and restricts its operators: single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names are accepted as operands and resolved to ids." }, "excludeRowIds": { "description": "Rows excluded from a select-all run scope.", @@ -6829,7 +6829,7 @@ "description": "Case-insensitive cell substring to find." }, "predicate": { - "description": "Recursive predicate tree with exactly one non-empty `all` or `any` group at each group node." + "description": "Recursive predicate tree with exactly one non-empty `all` or `any` group at each group node. Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Membership: `in`, `nin` (array operand). Emptiness: `isEmpty`, `isNotEmpty`, `isNull`, `isNotNull` (no operand). Substring, always case-insensitive, operand matched literally: `contains`, `ncontains`, `startsWith`, `endsWith`. Pattern: `like`/`nlike` (case-sensitive), `ilike`/`nilike` (case-insensitive). **`*` is the only wildcard** and stands for any run of characters; `%`, `_`, and backslash match themselves. Use `like: \"Hi*\"`, not `like: \"Hi%\"`. A `select` column compares by option id and restricts its operators: single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names are accepted as operands and resolved to ids." }, "sort": { "description": "Ordered table-row sort specification.", @@ -7953,7 +7953,7 @@ "minLength": 1 }, "filter": { - "description": "Recursive predicate tree with exactly one non-empty `all` or `any` group at each group node." + "description": "Recursive predicate tree with exactly one non-empty `all` or `any` group at each group node. Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Membership: `in`, `nin` (array operand). Emptiness: `isEmpty`, `isNotEmpty`, `isNull`, `isNotNull` (no operand). Substring, always case-insensitive, operand matched literally: `contains`, `ncontains`, `startsWith`, `endsWith`. Pattern: `like`/`nlike` (case-sensitive), `ilike`/`nilike` (case-insensitive). **`*` is the only wildcard** and stands for any run of characters; `%`, `_`, and backslash match themselves. Use `like: \"Hi*\"`, not `like: \"Hi%\"`. A `select` column compares by option id and restricts its operators: single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names are accepted as operands and resolved to ids." }, "excludeRowIds": { "description": "Rows excluded from an all-scope cancellation.", diff --git a/apps/sim/app/api/v2/tables/[tableId]/groups/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/groups/route.test.ts index 94bfb5816c6..1d8c2b1fe43 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/groups/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/groups/route.test.ts @@ -87,7 +87,7 @@ describe('/api/v2/tables/[tableId]/groups', () => { v2RouteMocks.gate.mockResolvedValue(null) v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) - mocks.list.mockResolvedValue({ groups: [group] }) + mocks.list.mockResolvedValue({ table, groups: [group] }) mocks.create.mockResolvedValue({ table, group }) mocks.update.mockResolvedValue({ table, group, changed: true, startAutoRun: false }) mocks.remove.mockResolvedValue({ table, groupId: 'group-1' }) @@ -100,7 +100,10 @@ describe('/api/v2/tables/[tableId]/groups', () => { const response = await GET(req, context) expect(response.status).toBe(200) - expect(await response.json()).toEqual({ data: [group], nextCursor: null }) + expect(await response.json()).toEqual({ + data: [{ ...group, outputs: [{ ...group.outputs[0], columnName: 'Result' }] }], + nextCursor: null, + }) expect(mocks.list).toHaveBeenCalledWith({ principal, input: { tableId: 'table-1', workspaceId: WORKSPACE_ID }, @@ -136,6 +139,25 @@ describe('/api/v2/tables/[tableId]/groups', () => { expect(response.status).toBe(201) expect((await response.json()).data.group.id).toBe('group-1') + + /** + * `columnName` is sent as a column name and stored as a column id; reading + * back the id under the same field made the value un-round-trippable and + * unmatched by anything else on a surface that is otherwise name-keyed. + */ + const created = await POST( + writeRequest('POST', { + workspaceId: WORKSPACE_ID, + group: { + workflowId: 'workflow-1', + type: 'manual', + outputs: [{ blockId: 'block-1', path: 'result', columnName: 'Result' }], + }, + outputColumns: [{ name: 'Result', type: 'string' }], + }), + context + ) + expect((await created.json()).data.group.outputs[0].columnName).toBe('Result') expect(mocks.create).toHaveBeenCalledWith({ principal, input: expect.objectContaining({ diff --git a/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts b/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts index 8910958f55a..49d310b8181 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts @@ -14,6 +14,7 @@ import { } from '@/lib/table/application/groups' import { tableOperations } from '@/lib/table/application/operations' import { normalizeColumn } from '@/app/api/table/utils' +import { presentV2WorkflowGroup } from '@/app/api/v2/tables/presenters' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -26,7 +27,10 @@ export const GET = defineV2JsonRoute({ rateLimit: v2RateLimits.publicApi, errorPolicy: v2TableErrorPolicies.concealTableAuthorization, mapInput: ({ params, query }) => ({ tableId: params.tableId, workspaceId: query.workspaceId }), - present: ({ groups }) => ({ data: groups, nextCursor: null }), + present: ({ table, groups }) => ({ + data: groups.map((group) => presentV2WorkflowGroup(group, table.schema)), + nextCursor: null, + }), }) export const POST = defineV2JsonRoute({ @@ -38,7 +42,10 @@ export const POST = defineV2JsonRoute({ errorPolicy: v2TableErrorPolicies.concealTableAuthorization, mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), present: ({ table, group }) => ({ - data: { group, columns: table.schema.columns.map(normalizeColumn) }, + data: { + group: presentV2WorkflowGroup(group, table.schema), + columns: table.schema.columns.map(normalizeColumn), + }, }), }) @@ -51,7 +58,10 @@ export const PATCH = defineV2JsonRoute({ errorPolicy: v2TableErrorPolicies.concealTableAuthorization, mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), present: ({ table, group }) => ({ - data: { group, columns: table.schema.columns.map(normalizeColumn) }, + data: { + group: presentV2WorkflowGroup(group, table.schema), + columns: table.schema.columns.map(normalizeColumn), + }, }), }) diff --git a/apps/sim/app/api/v2/tables/presenters.test.ts b/apps/sim/app/api/v2/tables/presenters.test.ts index da5f27bca2c..7b08ee059ac 100644 --- a/apps/sim/app/api/v2/tables/presenters.test.ts +++ b/apps/sim/app/api/v2/tables/presenters.test.ts @@ -3,10 +3,12 @@ */ import { describe, expect, it } from 'vitest' +import type { TableSchema, WorkflowGroup } from '@/lib/table/types' import { presentV2CreateTableImport, presentV2TableExport, presentV2TableImport, + presentV2WorkflowGroup, } from '@/app/api/v2/tables/presenters' const createdAt = new Date('2026-08-01T00:00:00.000Z') @@ -80,3 +82,59 @@ describe('v2 table presenters', () => { }) }) }) + +/** + * A group is created with column **names** and was read back with stored column + * **ids** under the same `columnName` field, on a surface every other row/data + * endpoint keys by name. The value could not be round-tripped into another + * create, and named nothing the caller could see elsewhere. + */ +describe('presentV2WorkflowGroup', () => { + const schema: TableSchema = { + columns: [ + { id: 'col_score', name: 'score', type: 'number' }, + { id: 'col_input', name: 'website', type: 'string' }, + ], + } + + const stored = { + id: 'group-1', + workflowId: 'workflow-1', + outputs: [{ blockId: 'block-1', path: 'result', columnName: 'col_score' }], + dependencies: { columns: ['col_input'] }, + inputMappings: [{ inputName: 'url', columnName: 'col_input' }], + } as WorkflowGroup + + it('presents every column reference as the column name', () => { + const presented = presentV2WorkflowGroup(stored, schema) + + expect(presented.outputs[0].columnName).toBe('score') + expect(presented.dependencies?.columns).toEqual(['website']) + expect(presented.inputMappings?.[0].columnName).toBe('website') + }) + + it('leaves the stored group untouched', () => { + presentV2WorkflowGroup(stored, schema) + expect(stored.outputs[0].columnName).toBe('col_score') + }) + + it('passes a reference naming no current column through unchanged', () => { + const orphaned = { + ...stored, + outputs: [{ blockId: 'block-1', path: 'result', columnName: 'col_deleted' }], + } as WorkflowGroup + + expect(presentV2WorkflowGroup(orphaned, schema).outputs[0].columnName).toBe('col_deleted') + }) + + it('leaves a legacy name-keyed group alone', () => { + const legacy = { + ...stored, + outputs: [{ blockId: 'block-1', path: 'result', columnName: 'score' }], + dependencies: undefined, + inputMappings: undefined, + } as unknown as WorkflowGroup + + expect(presentV2WorkflowGroup(legacy, schema).outputs[0].columnName).toBe('score') + }) +}) diff --git a/apps/sim/app/api/v2/tables/presenters.ts b/apps/sim/app/api/v2/tables/presenters.ts index d194cb5c3fa..10a624e0980 100644 --- a/apps/sim/app/api/v2/tables/presenters.ts +++ b/apps/sim/app/api/v2/tables/presenters.ts @@ -1,3 +1,4 @@ +import { buildNameById, remapGroupColumnRefs } from '@/lib/table/column-keys' import { type TableExportRecord, toV2TableExport } from '@/lib/table/orchestration/export-resource' import { type CreateTableImportResult, @@ -5,6 +6,7 @@ import { toV2CreateTableImport, toV2TableImport, } from '@/lib/table/orchestration/import-resource' +import type { TableSchema, WorkflowGroup } from '@/lib/table/types' export function presentV2CreateTableImport(result: CreateTableImportResult) { return { data: toV2CreateTableImport(result) } @@ -17,3 +19,20 @@ export function presentV2TableImport(record: TableImportResource) { export function presentV2TableExport(record: TableExportRecord, queued = false) { return { data: toV2TableExport(record, queued) } } + +/** + * A workflow group with its column references presented as column **names**. + * + * Groups store `outputs[].columnName`, `dependencies.columns[]`, and + * `inputMappings[].columnName` as stable column **ids** so a rename cannot + * orphan them — but the field is named for, documented as, and accepted on + * create as a name, and every other v2 row surface is keyed by name. Reading + * back a `col_…` id under `columnName` meant a group could not be round-tripped + * into a create, and the value did not correspond to anything else the caller + * could see. `remapGroupColumnRefs` is the same rewrite the write path uses, + * driven by the inverse map; a ref naming no current column is left as-is, so a + * legacy name-keyed group and a ref to a since-deleted column both survive. + */ +export function presentV2WorkflowGroup(group: WorkflowGroup, schema: TableSchema): WorkflowGroup { + return remapGroupColumnRefs(group, buildNameById(schema)) +} diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index 5b500c7152f..4289e226cc9 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -469,6 +469,24 @@ export const TABLE_QUERY_MAX_BODY_BYTES = 1024 * 1024 /** Max sort keys — more than a few is already a smell. */ const MAX_SORT_KEYS = 16 +/** + * The published predicate grammar. + * + * Everything here was previously true only in the SQL builder's own comments: a + * caller reading the spec saw an untyped operand and an operator enum with no + * semantics, so the natural guess — SQL's own `%` wildcard — matched zero rows + * under a 200 and nothing said why. Stated on the operator and on the tree so it + * reaches the OpenAPI description of every endpoint that takes a predicate. + */ +const PREDICATE_OPERATOR_GRAMMAR = [ + 'Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`.', + 'Membership: `in`, `nin` (array operand).', + 'Emptiness: `isEmpty`, `isNotEmpty`, `isNull`, `isNotNull` (no operand).', + 'Substring, always case-insensitive, operand matched literally: `contains`, `ncontains`, `startsWith`, `endsWith`.', + 'Pattern: `like`/`nlike` (case-sensitive), `ilike`/`nilike` (case-insensitive). **`*` is the only wildcard** and stands for any run of characters; `%`, `_`, and backslash match themselves. Use `like: "Hi*"`, not `like: "Hi%"`.', + 'A `select` column compares by option id and restricts its operators: single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names are accepted as operands and resolved to ids.', +].join(' ') + /** * v2 filter wire format: the typed `{ all | any: [...] }` predicate tree (same * shape the engine consumes). Structure is validated here; schema-awareness @@ -486,9 +504,20 @@ const MAX_SORT_KEYS = 16 * would just fall through to the leaf branch, which is the more dangerous reading. */ const predicateLeafObjectSchema = z.strictObject({ - field: z.string().min(1, 'field is required').max(128), - op: z.enum(FILTER_OPS), - value: z.unknown().optional(), + field: z + .string() + .min(1, 'field is required') + .max(128) + .describe( + 'Column name to compare, or one of the system fields `id`, `createdAt`, `updatedAt`.' + ), + op: z.enum(FILTER_OPS).describe(PREDICATE_OPERATOR_GRAMMAR), + value: z + .unknown() + .optional() + .describe( + 'Operand. A scalar for the comparison operators, an array for `in`/`nin`, a pattern for the matching operators, and omitted for `isEmpty`/`isNotEmpty`/`isNull`/`isNotNull`.' + ), }) // double-cast-allowed: `z.unknown()` keeps the runtime permissive (a leaf value @@ -533,7 +562,7 @@ const predicateBoundarySchema = z.unknown().superRefine((value, ctx) => { const documentedPredicateSchema = predicateBoundarySchema .pipe(predicateTreeSchema) .describe( - 'Recursive predicate tree with exactly one non-empty `all` or `any` group at each group node.' + `Recursive predicate tree with exactly one non-empty \`all\` or \`any\` group at each group node. ${PREDICATE_OPERATOR_GRAMMAR}` ) // double-cast-allowed: the pipe's inferred input is `unknown`, and letting TS widen the recursive lazy union through it makes typecheck OOM @@ -549,7 +578,7 @@ export const predicateInputSchema = predicateBoundarySchema .pipe(predicateNodeSchema) .transform(normalizeTablePredicate) .describe( - 'Recursive predicate condition or group, normalized to a grouped predicate after validation.' + `Recursive predicate condition or group, normalized to a grouped predicate after validation. ${PREDICATE_OPERATOR_GRAMMAR}` ) as z.ZodType /** diff --git a/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts index 7a4f1c1c8fe..f21685a4f1e 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts @@ -397,4 +397,44 @@ describe('v2 table request bounds', () => { expect(bounds.maxItems).toBe(MAX_RUN_TARGET_ROW_IDS) expect(bounds.minItems).toBe(1) }) + + /** + * The shared group shape defaults `workflowId` to `''`, so the published + * schema advertised `default: ""` while `refineGroupSource` 400s any manual + * group that omits it — a documented fallback that always fails. + */ + it('does not advertise a workflowId default the create refuses to honor', () => { + const json = z.toJSONSchema(tableContracts.v2AddWorkflowGroupBodySchema, { + io: 'input', + unrepresentable: 'any', + }) as { + properties?: { group?: { properties?: { workflowId?: { default?: unknown } } } } + } + + expect(json.properties?.group?.properties?.workflowId?.default).toBeUndefined() + expect( + tableContracts.v2AddWorkflowGroupBodySchema.safeParse({ + workspaceId: '6fc7631d-88cd-46f8-9f0a-d4764daef7f8', + group: { + type: 'manual', + outputs: [{ blockId: 'block-1', path: 'result', columnName: 'Result' }], + }, + outputColumns: [{ name: 'Result', type: 'string' }], + }).success + ).toBe(false) + }) + + /** + * `*` is the wildcard, not `%`. Nothing published said so, so `like: "Hi%"` + * matched zero rows with a 200 while `like: "Hi*"` matched 1358. + */ + it('publishes the predicate operator grammar, including the wildcard', () => { + const published = JSON.stringify( + z.toJSONSchema(v2QueryRowsBodySchema, { io: 'input', unrepresentable: 'any' }) + ) + + expect(published).toContain('`*` is the only wildcard') + expect(published).toContain('single-select accepts `eq`, `ne`, `in`, `nin`') + expect(published).toContain('isEmpty') + }) }) diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index 323cd7d512c..2f5d186cd3e 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -1196,7 +1196,7 @@ export const v2WorkflowGroupSchema = z blockId: z.string().describe('Workflow block producing this output.'), path: z.string().describe('Path to the value in the workflow block output.'), outputId: z.string().optional().describe('Registry enrichment output identifier.'), - columnName: z.string().describe('Table column receiving the output.'), + columnName: z.string().describe('Name of the table column receiving the output.'), }) ) .describe('Workflow outputs mapped to table columns.'), @@ -1204,7 +1204,7 @@ export const v2WorkflowGroupSchema = z .array( z.object({ inputName: z.string().describe('Workflow input name.'), - columnName: z.string().describe('Source table column name.'), + columnName: z.string().describe('Name of the source table column.'), }) ) .optional() @@ -1295,6 +1295,21 @@ export const v2AddWorkflowGroupBodySchema = z .min(1) .optional() .describe('Optional client-provided workflow-group identifier.'), + /** + * The first-party shape defaults this to `''`, which published a + * `default: ""` the surface does not honor: a `manual` group — the + * type you get by omitting `type` — that omits `workflowId` is refused + * by `refineGroupSource`, so the spec promised a fallback that always + * 400s. Optional with no default and a description that names the + * condition is what is actually true. + */ + workflowId: z + .string() + .min(1, 'workflowId cannot be empty') + .optional() + .describe( + 'Backing workflow identifier. Required when `type` is `manual` (which is also the default when `type` is omitted); omit it for an `enrichment` group.' + ), }) .describe('Workflow or enrichment producer definition.'), outputColumns: z diff --git a/apps/sim/lib/table/__tests__/column-type-registry.test.ts b/apps/sim/lib/table/__tests__/column-type-registry.test.ts index 73c5ffc424c..fb72c9748f2 100644 --- a/apps/sim/lib/table/__tests__/column-type-registry.test.ts +++ b/apps/sim/lib/table/__tests__/column-type-registry.test.ts @@ -91,12 +91,11 @@ describe('conversion write-back', () => { // transformed value back — filters and sorts apply `jsonbCast` to whatever is // stored, so a value left in its old shape breaks every query on the column. it.each` - type | stored | expected - ${'date'} | ${1700000000000} | ${'2023-11-14T22:13:20.000Z'} - ${'date'} | ${'2024-01-01'} | ${'2024-01-01'} - ${'currency'} | ${'$1,234.56'} | ${1234.56} - ${'currency'} | ${'1.234,56'} | ${1234.56} - ${'number'} | ${'1999'} | ${1999} + type | stored | expected + ${'date'} | ${'2024-01-01'} | ${'2024-01-01'} + ${'currency'} | ${'$1,234.56'} | ${1234.56} + ${'currency'} | ${'1.234,56'} | ${1234.56} + ${'number'} | ${'1999'} | ${1999} `('$type coerces $stored to a value its jsonbCast can read', ({ type, stored, expected }) => { const column = { name: 'c', type } as ColumnDefinition const result = COLUMN_TYPE_REGISTRY[type as ColumnType].coerce(stored, column) @@ -104,11 +103,18 @@ describe('conversion write-back', () => { }) it('never leaves a numeric-cast type holding something Postgres cannot cast', () => { - // The concrete failure this guards: an epoch number left in a `date` - // column makes `(data->>'col')::timestamptz` throw on every query. + // The concrete failure this guards: a number left in a `date` column makes + // `(data->>'col')::timestamptz` throw on every query. A bare number is now + // refused outright rather than read as epoch milliseconds — the value + // cannot say whether it means seconds or milliseconds, and both readings + // are in range — so the column can never come to hold one either way. for (const definition of ALL_COLUMN_TYPES) { if (definition.jsonbCast !== 'timestamptz') continue - const coerced = definition.coerce(1700000000000, { name: 'c', type: definition.id }) + expect(definition.coerce(1700000000000, { name: 'c', type: definition.id }).ok).toBe(false) + const coerced = definition.coerce('2023-11-14T22:13:20.000Z', { + name: 'c', + type: definition.id, + }) expect(coerced.ok).toBe(true) expect(typeof (coerced as { value: unknown }).value).toBe('string') } @@ -135,16 +141,17 @@ describe('intentional divergences from the pre-registry behavior', () => { } }) - it('refuses to bulk-convert a number column to date', () => { - // `date.coerce` accepts an epoch for a single deliberate write, but - // reinterpreting a whole numeric column as epoch milliseconds is - // destructive and irreversible — 1, 5, 42 would become three timestamps in - // January 1970. The gate may be stricter than `coerce`, never looser. + it('refuses a number as a date, on the write path and the bulk gate alike', () => { + // The gate used to be stricter than `coerce` here: a whole numeric column + // reinterpreted as epoch milliseconds would turn 1, 5, 42 into three + // timestamps in January 1970. The write path had the same problem one value + // at a time — `1600000000` is September 2020 as seconds and January 1970 as + // milliseconds, both in range — so `coerce` now refuses a bare number too + // and the gate needs no override. const column: ColumnDefinition = { name: 'd', type: 'date' } for (const value of [0, 1, 42, 1700000000]) { expect(isValueCompatible(value, column)).toBe(false) - // The write path still accepts it. - expect(COLUMN_TYPE_REGISTRY.date.coerce(value as never, column).ok).toBe(true) + expect(COLUMN_TYPE_REGISTRY.date.coerce(value as never, column).ok).toBe(false) } expect(isValueCompatible('2024-01-01', column)).toBe(true) }) diff --git a/apps/sim/lib/table/__tests__/validation.test.ts b/apps/sim/lib/table/__tests__/validation.test.ts index fc3b77ed574..308f73c143d 100644 --- a/apps/sim/lib/table/__tests__/validation.test.ts +++ b/apps/sim/lib/table/__tests__/validation.test.ts @@ -358,9 +358,15 @@ describe('Validation', () => { expect(data.founded).toBe(1999) }) - it('nulls an un-coercible value for an optional number column', () => { + it('rejects an un-coercible value for an optional number column', () => { const data = { name: 'Acme', founded: 2000, age: 'unknown' } const result = coerceRowToSchema(data, schema) + expect(result.valid).toBe(false) + }) + + it('nulls an un-coercible optional value under the `null` policy', () => { + const data = { name: 'Acme', founded: 2000, age: 'unknown' } + const result = coerceRowToSchema(data, schema, 'null') expect(result.valid).toBe(true) expect(data.age).toBeNull() }) @@ -387,12 +393,10 @@ describe('Validation', () => { expect(data.active).toBe(false) }) - it('coerces an epoch number to an ISO date string', () => { - const epoch = Date.parse('2024-01-15T00:00:00Z') - const data = { name: 'Acme', founded: 2000, created: epoch } + it('refuses a bare epoch number, whose unit the value cannot state', () => { + const data = { name: 'Acme', founded: 2000, created: Date.parse('2024-01-15T00:00:00Z') } const result = coerceRowToSchema(data, schema) - expect(result.valid).toBe(true) - expect(data.created).toBe(new Date(epoch).toISOString()) + expect(result.valid).toBe(false) }) it('coerces a Date instance to an ISO date string', () => { @@ -403,16 +407,16 @@ describe('Validation', () => { expect(data.created).toBe(date.toISOString()) }) - it('nulls an out-of-range epoch number for an optional date column without throwing', () => { + it('nulls an out-of-range epoch number under the `null` policy without throwing', () => { const data = { name: 'Acme', founded: 2000, created: 1e20 } - const result = coerceRowToSchema(data, schema) + const result = coerceRowToSchema(data, schema, 'null') expect(result.valid).toBe(true) expect(data.created).toBeNull() }) - it('nulls an invalid Date instance for an optional date column without throwing', () => { + it('nulls an invalid Date instance under the `null` policy without throwing', () => { const data = { name: 'Acme', founded: 2000, created: new Date('not-a-date') } - const result = coerceRowToSchema(data, schema) + const result = coerceRowToSchema(data, schema, 'null') expect(result.valid).toBe(true) expect(data.created).toBeNull() }) @@ -447,9 +451,15 @@ describe('Validation', () => { expect(patch.age).toBe(42) }) - it('nulls an un-coercible optional value in a patch', () => { + it('leaves an un-coercible optional patch value in place for downstream validation', () => { const patch: { age: unknown } = { age: 'nope' } coerceRowValues(patch as never, schema) + expect(patch.age).toBe('nope') + }) + + it('nulls an un-coercible optional patch value under the `null` policy', () => { + const patch: { age: unknown } = { age: 'nope' } + coerceRowValues(patch as never, schema, 'null') expect(patch.age).toBeNull() }) @@ -523,9 +533,15 @@ describe('Validation', () => { expect(patch.price).toBe(42) }) - it('nulls an unreadable amount on an optional column', () => { + it('leaves an unreadable amount in place on an optional column so validation reports it', () => { const patch: Record = { price: 'ask sales' } coerceRowValues(patch as never, currencySchema) + expect(patch.price).toBe('ask sales') + }) + + it('nulls an unreadable amount on an optional column under the `null` policy', () => { + const patch: Record = { price: 'ask sales' } + coerceRowValues(patch as never, currencySchema, 'null') expect(patch.price).toBeNull() }) diff --git a/apps/sim/lib/table/application/groups.ts b/apps/sim/lib/table/application/groups.ts index b6f01039557..8252f5a939b 100644 --- a/apps/sim/lib/table/application/groups.ts +++ b/apps/sim/lib/table/application/groups.ts @@ -176,7 +176,10 @@ export const listTableGroupsUseCase = defineAuthorizedTableUseCase({ assertedWorkspaceId: input.workspaceId, }), async execute({ context }) { - return { groups: (context.table.schema as TableSchema).workflowGroups ?? [] } + return { + table: context.table, + groups: (context.table.schema as TableSchema).workflowGroups ?? [], + } }, }) diff --git a/apps/sim/lib/table/application/rows.test.ts b/apps/sim/lib/table/application/rows.test.ts index b358923f778..2cce0ad9671 100644 --- a/apps/sim/lib/table/application/rows.test.ts +++ b/apps/sim/lib/table/application/rows.test.ts @@ -475,7 +475,7 @@ describe('replaceTableRows application use case', () => { tableId: TABLE.id, assertedWorkspaceId: TABLE.workspaceId, requestId: 'request-1', - rows: [{ name: 'Ada', unknown: 'dropped' }], + rows: [{ name: 'Ada' }], }, }) @@ -499,6 +499,16 @@ describe('replaceTableRows application use case', () => { expect(mockSignalRowsChanged).toHaveBeenCalledWith(TABLE.id) }) + it('refuses a replacement row naming a column the table does not have', async () => { + await expect( + replaceTableRows.execute({ + principal: PRINCIPAL, + input: { tableId: TABLE.id, rows: [{ name: 'Ada', unknown: 'x' }] }, + }) + ).rejects.toThrow(/Row 1: Unknown column: unknown/) + expect(mockReplaceRowsPrimitive).not.toHaveBeenCalled() + }) + it('rejects more than 10,000 rows before opening the atomic primitive', async () => { await expect( replaceTableRows.execute({ @@ -953,3 +963,94 @@ describe('table row write secret provenance defaulting', () => { ) }) }) + +/** + * The name→id remap drops keys naming no column, and nothing upstream had + * checked that there were none to drop. An insert of `{"nosuchcol":"x"}` + * therefore answered 201 having created an empty row, and a patch of + * `{"zzz":"x"}` answered `updatedCount: 0` — the same answer a predicate that + * matched nothing gives, so a caller could not tell a typo from an empty match. + */ +describe('unknown column names are refused, not dropped', () => { + beforeEach(() => { + vi.clearAllMocks() + mockResolvePermission.mockResolvedValue('write') + mockResolveContext.mockResolvedValue({ + tableId: TABLE.id, + table: TABLE, + workspaceId: TABLE.workspaceId, + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + mockValidateRowData.mockResolvedValue({ valid: true }) + mockValidateBatchRows.mockResolvedValue({ valid: true }) + mockInsertRow.mockResolvedValue({ id: 'row-1', data: {} }) + mockBatchInsertRows.mockResolvedValue([{ id: 'row-1', data: {} }]) + mockUpdateRow.mockResolvedValue({ id: 'row-1', data: {} }) + mockUpdateRowsByFilter.mockResolvedValue({ affectedCount: 0 }) + mockUpsertRow.mockResolvedValue({ operation: 'insert', row: { id: 'row-1', data: {} } }) + }) + + it('refuses a single insert naming a column the table does not have', async () => { + await expect( + createTableRows.execute({ + principal: PRINCIPAL, + input: { kind: 'single', tableId: TABLE.id, data: { nosuchcol: 'x' } }, + }) + ).rejects.toThrow(/Unknown column: nosuchcol/) + expect(mockInsertRow).not.toHaveBeenCalled() + }) + + it('names every unknown column at once', async () => { + await expect( + createTableRows.execute({ + principal: PRINCIPAL, + input: { kind: 'single', tableId: TABLE.id, data: { zzz: 'x', qqq: 'y' } }, + }) + ).rejects.toThrow(/Unknown columns: zzz, qqq/) + }) + + it('refuses a batch insert and says which row was wrong', async () => { + await expect( + createTableRows.execute({ + principal: PRINCIPAL, + input: { kind: 'batch', tableId: TABLE.id, rows: [{ name: 'Ada' }, { zzz: 'x' }] }, + }) + ).rejects.toThrow(/Row 2: Unknown column: zzz/) + expect(mockBatchInsertRows).not.toHaveBeenCalled() + }) + + it('refuses a predicate update rather than reporting an empty match', async () => { + await expect( + updateTableRows.execute({ + principal: PRINCIPAL, + input: { + tableId: TABLE.id, + filter: { all: [{ field: 'name', op: 'eq', value: 'Ada' }] }, + data: { zzz: 'x' }, + }, + }) + ).rejects.toThrow(/Unknown column: zzz/) + expect(mockUpdateRowsByFilter).not.toHaveBeenCalled() + }) + + it('refuses a single-row update naming an unknown column', async () => { + await expect( + updateTableRow.execute({ + principal: PRINCIPAL, + input: { tableId: TABLE.id, rowId: 'row-1', data: { zzz: 'x' } }, + }) + ).rejects.toThrow(/Unknown column: zzz/) + expect(mockUpdateRow).not.toHaveBeenCalled() + }) + + it('still accepts a write naming only known columns', async () => { + await expect( + createTableRows.execute({ + principal: PRINCIPAL, + input: { kind: 'single', tableId: TABLE.id, data: { name: 'Ada' } }, + }) + ).resolves.toBeDefined() + }) +}) diff --git a/apps/sim/lib/table/application/rows.ts b/apps/sim/lib/table/application/rows.ts index 8aaa15651bf..5bc80ef2b8e 100644 --- a/apps/sim/lib/table/application/rows.ts +++ b/apps/sim/lib/table/application/rows.ts @@ -43,7 +43,7 @@ import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized import { resolveActiveTableContext } from '@/lib/table/application/context' import { tableOperations } from '@/lib/table/application/operations' import { assertRowCapacity, notifyTableRowUsage } from '@/lib/table/billing' -import { buildIdByName } from '@/lib/table/column-keys' +import { buildIdByName, unknownColumnNames } from '@/lib/table/column-keys' import { columnTypeOf } from '@/lib/table/column-types' import { TableQueryValidationError } from '@/lib/table/errors' import { signalTableRowsChanged } from '@/lib/table/events' @@ -115,8 +115,33 @@ function actorUserId( }).attributedUserId } +/** + * Refuses a wire row naming a column the table does not have. + * + * The name→id remap drops unrecognised keys, so without this an insert of + * `{"nosuchcol":"x"}` created an empty row under a 201, and a patch of + * `{"zzz":"x"}` answered `updatedCount: 0` — indistinguishable from a predicate + * that matched nothing, and in both cases the client is told the write + * succeeded. Naming the offending columns is the only answer that lets a caller + * tell a typo apart from an empty match. + */ +function assertKnownColumnNames( + data: RowData, + idByName: ReadonlyMap, + rowLabel?: string +): void { + const unknown = unknownColumnNames(data, idByName) + if (unknown.length === 0) return + const where = rowLabel ? `${rowLabel}: ` : '' + throw new TableRowsValidationError( + `${where}Unknown column${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}` + ) +} + function namedDataToStorage(data: RowData, table: TableDefinition): RowData { - return rowDataNameToId(data, buildIdByName(table.schema)) + const idByName = buildIdByName(table.schema) + assertKnownColumnNames(data, idByName) + return rowDataNameToId(data, idByName) } /** @@ -126,7 +151,10 @@ function namedDataToStorage(data: RowData, table: TableDefinition): RowData { */ function namedRowsToStorage(rows: readonly RowData[], table: TableDefinition): RowData[] { const idByName = buildIdByName(table.schema) - return rows.map((row) => rowDataNameToId(row, idByName)) + return rows.map((row, index) => { + assertKnownColumnNames(row, idByName, `Row ${index + 1}`) + return rowDataNameToId(row, idByName) + }) } /** diff --git a/apps/sim/lib/table/cell-write.ts b/apps/sim/lib/table/cell-write.ts index 4f3d9f16839..cf2b4e34373 100644 --- a/apps/sim/lib/table/cell-write.ts +++ b/apps/sim/lib/table/cell-write.ts @@ -136,11 +136,13 @@ export async function writeWorkflowGroupState( // ("Open"), which the grid resolves as an option id, finds nothing, and // renders as an empty cell until the next refetch. Coerce a copy: the patch // object itself is identity-compared for the progress writer's retry - // bookkeeping, so it must not be mutated. + // bookkeeping, so it must not be mutated. The `null` policy mirrors what + // `updateRow` persists for a computed write, so the snapshot the client sees + // and the row on disk agree about a block output its column cannot hold. const rawEventOutputs = payload.eventOutputs ?? dataPatch const hasOutputs = rawEventOutputs && Object.keys(rawEventOutputs).length > 0 const eventOutputs = hasOutputs ? { ...rawEventOutputs } : rawEventOutputs - if (hasOutputs && eventOutputs) coerceRowValues(eventOutputs, table.schema) + if (hasOutputs && eventOutputs) coerceRowValues(eventOutputs, table.schema, 'null') const runningBlockIds = payload.executionState.runningBlockIds const blockErrors = payload.executionState.blockErrors void appendTableEvent({ diff --git a/apps/sim/lib/table/column-keys.ts b/apps/sim/lib/table/column-keys.ts index 71565101f4d..9fa67433e84 100644 --- a/apps/sim/lib/table/column-keys.ts +++ b/apps/sim/lib/table/column-keys.ts @@ -139,7 +139,13 @@ export function buildNameById(schema: TableSchema): Map { /** * Remaps a wire row keyed by column **name** to the stored **id** keying. Used * at the name-translating boundaries on the way in. Keys not matching a known - * column are dropped (validation has already run against the schema). + * column are dropped. + * + * Dropping is only safe once someone has established that there are none to + * drop — a key that survives to here unrecognised is a cell the caller asked to + * write and the table never stored. Callers on a surface that can answer the + * client check {@link unknownColumnNames} first; see + * `namedDataToStorage` in `application/rows.ts`. */ export function rowDataNameToId(data: RowData, idByName: Map): RowData { const out: RowData = {} @@ -150,6 +156,18 @@ export function rowDataNameToId(data: RowData, idByName: Map): R return out } +/** + * Wire row keys naming no column in `idByName`, in the order they were sent. + * + * The v2 row surface is keyed by column **name**, so a stored column **id** is + * as unknown here as a typo — it names no key the caller could have read off a + * row, and letting it through would reinstate the silent drop for exactly the + * callers most likely to believe they had written something. + */ +export function unknownColumnNames(data: RowData, idByName: ReadonlyMap): string[] { + return Object.keys(data).filter((name) => !idByName.has(name)) +} + /** * Translates a filter's field names → column ids (recursing into `$or`/`$and`). * Fields with no matching column (e.g. `createdAt`) pass through unchanged. Used diff --git a/apps/sim/lib/table/column-types/date.ts b/apps/sim/lib/table/column-types/date.ts index 11b980eeacd..c78768f4f38 100644 --- a/apps/sim/lib/table/column-types/date.ts +++ b/apps/sim/lib/table/column-types/date.ts @@ -5,7 +5,6 @@ import { normalizeDateCellValue, storedDateToEditable, } from '@/lib/table/dates' -import type { JsonValue } from '@/lib/table/types' export const dateColumnType: ColumnTypeDefinition = { id: 'date', @@ -27,26 +26,26 @@ export const dateColumnType: ColumnTypeDefinition = { const normalized = normalizeDateCellValue(value) return normalized === null ? { ok: false } : { ok: true, value: normalized } } - // Date instances and epoch numbers may still be out of the representable - // range (>±8.64e15ms) — guard `toISOString()`, which throws RangeError on - // an Invalid Date, so an over-range value degrades to `{ ok: false }` - // rather than crashing the write. - const date = value instanceof Date ? value : typeof value === 'number' ? new Date(value) : null - if (date && !Number.isNaN(date.getTime())) return { ok: true, value: date.toISOString() } + // A bare number is refused, in every direction. It is the one input whose + // meaning cannot be recovered from the value itself: `1600000000` is + // September 2020 read as Unix seconds and 19 January 1970 read as + // milliseconds, both readings are in range, and nothing on the wire says + // which was meant. Milliseconds used to win, so a seconds-based epoch — + // the far more common shape — stored a timestamp 50 years early under a + // 200. An ISO-8601 string carries its own unit; that is what a date cell + // takes. This also removes the reason the bulk retype gate had to be + // stricter than the write path, so it no longer overrides. + // + // A Date instance may still be out of the representable range (>±8.64e15ms), + // so `toISOString()` is guarded — it throws RangeError on an Invalid Date — + // and an over-range value degrades to `{ ok: false }` rather than crashing + // the write. + if (value instanceof Date && !Number.isNaN(value.getTime())) { + return { ok: true, value: value.toISOString() } + } return { ok: false } }, - isCompatibleWith(value) { - // Stricter than `coerce` on purpose. Writing a number into a date cell is a - // deliberate act — the caller means epoch milliseconds. Reinterpreting a - // whole NUMBER column as epochs is not: a column of 1, 5, 42 would become - // three timestamps in January 1970, irreversibly, and a Unix-seconds column - // would land in 1970 rather than the year it means. Refuse the bulk - // conversion; single writes still accept epochs. - if (typeof value === 'number') return false - return dateColumnType.coerce(value as JsonValue, { name: '', type: 'date' }).ok - }, - validateCell(value, column) { const valid = value instanceof Date || (typeof value === 'string' && !Number.isNaN(Date.parse(value))) diff --git a/apps/sim/lib/table/column-types/select.ts b/apps/sim/lib/table/column-types/select.ts index 88c3f65ec09..5f4d92bfc3b 100644 --- a/apps/sim/lib/table/column-types/select.ts +++ b/apps/sim/lib/table/column-types/select.ts @@ -50,9 +50,20 @@ export const selectColumnType: ColumnTypeDefinition = { }, coerce(value, column) { + if (column.multiple) { + // `resolveSelectCellValue` DROPS parts that match no option, which is + // right for a display read of a cell whose option was since deleted, but + // is a silent discard on a write: `["green"]` would resolve to `[]` and + // store an empty cell for a value the caller asked to keep. A write only + // coerces when every part it named resolves — the same rule the single + // branch has always had, and the same rule `isCompatibleWith` uses for + // the bulk conversion. + const options = column.options ?? [] + const parts = splitMultiSelectInput(value) + if (parts.some((part) => resolveSelectOptionId(part, options) === null)) return { ok: false } + } const resolved = resolveSelectCellValue(value, column) - // A multi target always resolves (to `[]` at worst); a single target that - // matches no option has nothing safe to store. + // A single target that matches no option has nothing safe to store. return resolved === null ? { ok: false } : { ok: true, value: resolved } }, diff --git a/apps/sim/lib/table/constants.ts b/apps/sim/lib/table/constants.ts index 8fa6a4432d1..7ab2e7aa098 100644 --- a/apps/sim/lib/table/constants.ts +++ b/apps/sim/lib/table/constants.ts @@ -59,6 +59,15 @@ export const TABLE_LIMITS = { * bounds every reader that materializes a workspace's folder tree. */ MAX_VIEWS_PER_TABLE: 100, + /** + * Workflow/enrichment groups per table. Same reason as + * {@link TABLE_LIMITS.MAX_VIEWS_PER_TABLE}: `GET /tables/{id}/groups` is a + * full-set read that always answers `nextCursor: null`, so its published + * "bounded set" claim is only true if the write side keeps it true. The + * indirect bound (every group must add at least one output column, and + * columns are capped) does not survive an update path that adds no columns. + */ + MAX_WORKFLOW_GROUPS_PER_TABLE: 100, } as const /** diff --git a/apps/sim/lib/table/import-data.ts b/apps/sim/lib/table/import-data.ts index 593e79a513d..fce5491a03f 100644 --- a/apps/sim/lib/table/import-data.ts +++ b/apps/sim/lib/table/import-data.ts @@ -87,7 +87,10 @@ export async function bulkInsertImportBatch( `Row ${i + 1}: ${sizeValidation.errors.join(', ')}` ) } - const schemaValidation = coerceRowToSchema(data.rows[i], table.schema) + // A CSV cell that does not fit its mapped column blanks that cell rather + // than failing the file: the import has no caller waiting on a 400, and one + // malformed cell in a 100k-row upload must not reject the other 99,999. + const schemaValidation = coerceRowToSchema(data.rows[i], table.schema, 'null') if (!schemaValidation.valid) { throw new OrchestrationError( 'validation', diff --git a/apps/sim/lib/table/orchestration/import-resource.test.ts b/apps/sim/lib/table/orchestration/import-resource.test.ts index 478bc8a2e6b..32c37475146 100644 --- a/apps/sim/lib/table/orchestration/import-resource.test.ts +++ b/apps/sim/lib/table/orchestration/import-resource.test.ts @@ -10,6 +10,7 @@ const { mockGetUserSettings, mockGetWorkspaceFile, mockGetWorkspaceTableLimits, + mockAssertWorkspaceTableCapacity, mockRunDetached, } = vi.hoisted(() => ({ mockCreateTable: vi.fn(), @@ -18,6 +19,7 @@ const { mockGetUserSettings: vi.fn(), mockGetWorkspaceFile: vi.fn(), mockGetWorkspaceTableLimits: vi.fn(), + mockAssertWorkspaceTableCapacity: vi.fn(), mockRunDetached: vi.fn(), })) @@ -35,6 +37,7 @@ vi.mock('@/lib/core/utils/background', () => ({ runDetached: mockRunDetached })) vi.mock('@/lib/table/billing', () => ({ getWorkspaceTableLimits: mockGetWorkspaceTableLimits })) vi.mock('@/lib/table/import-runner', () => ({ runTableImport: vi.fn() })) vi.mock('@/lib/table/service', () => ({ + assertWorkspaceTableCapacity: mockAssertWorkspaceTableCapacity, createTable: mockCreateTable, getTableById: vi.fn(), })) @@ -135,6 +138,7 @@ describe('createAuthorizedTableImportResource workspace file size', () => { describe('createAuthorizedTableImportResource upload size', () => { beforeEach(() => { vi.clearAllMocks() + mockGetWorkspaceTableLimits.mockResolvedValue({ maxTables: 100, maxRowsPerTable: 10_000 }) mockCreateUploadSession.mockResolvedValue({ id: 'import-1', userId: 'user-1', @@ -251,3 +255,72 @@ describe('findTableImportResource on a job that is not a v2 import resource', () }) }) }) + +/** + * The table ceiling was enforced only by `createTable`, which for an + * upload-backed import does not run until the CSV has already been transferred. + * A workspace at its limit got a 201 and a presigned PUT for up to 5 GiB, and + * learned it was refused only at `complete` — by which point the bytes were paid + * for and an orphaned object was sitting in storage. + */ +describe('createAuthorizedTableImportResource table quota', () => { + const limitReached = Object.assign(new Error('Workspace has reached maximum table limit (5)'), { + code: 'WORKSPACE_RESOURCE_LIMIT_REACHED', + }) + + beforeEach(() => { + vi.clearAllMocks() + mockGetWorkspaceTableLimits.mockResolvedValue({ maxTables: 5, maxRowsPerTable: 10_000 }) + mockGetUserSettings.mockResolvedValue({ timezone: 'UTC' }) + mockCreateUploadSession.mockResolvedValue({ + id: 'import-1', + userId: 'user-1', + status: 'uploading', + uploadToken: 'signed-token', + transfer: { method: 'put', url: 'https://storage.example/upload', headers: {} }, + createdAt: new Date('2026-08-04T12:00:00.000Z'), + updatedAt: new Date('2026-08-04T12:00:00.000Z'), + completedAt: null, + }) + }) + + it('refuses an upload-backed import for a new table before handing out a transfer', async () => { + mockAssertWorkspaceTableCapacity.mockRejectedValue(limitReached) + + await expect( + createImport({ + workspaceId: WORKSPACE_ID, + source: { type: 'upload', name: 'data.csv', contentType: 'text/csv', size: 1024 }, + target: TARGET, + }) + ).rejects.toThrow(/maximum table limit/) + + expect(mockAssertWorkspaceTableCapacity).toHaveBeenCalledWith(WORKSPACE_ID, 5) + expect(mockCreateUploadSession).not.toHaveBeenCalled() + }) + + it('creates the session when the workspace still has room', async () => { + mockAssertWorkspaceTableCapacity.mockResolvedValue(undefined) + + await createImport({ + workspaceId: WORKSPACE_ID, + source: { type: 'upload', name: 'data.csv', contentType: 'text/csv', size: 1024 }, + target: TARGET, + }) + + expect(mockCreateUploadSession).toHaveBeenCalledOnce() + }) + + it('does not check the table ceiling when importing into an existing table', async () => { + mockAssertWorkspaceTableCapacity.mockResolvedValue(undefined) + mockGetWorkspaceFile.mockResolvedValue(workspaceFile(1024)) + + await createImport({ + workspaceId: WORKSPACE_ID, + source: { type: 'upload', name: 'data.csv', contentType: 'text/csv', size: 1024 }, + target: { type: 'existing', tableId: 'table-1', mode: 'append' }, + }).catch(() => undefined) + + expect(mockAssertWorkspaceTableCapacity).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/table/orchestration/import-resource.ts b/apps/sim/lib/table/orchestration/import-resource.ts index e01d97a0d44..71bf8370eca 100644 --- a/apps/sim/lib/table/orchestration/import-resource.ts +++ b/apps/sim/lib/table/orchestration/import-resource.ts @@ -29,7 +29,7 @@ import { import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner' import { markTableJobRunningInWorkspace } from '@/lib/table/jobs/service' import { assertRowDelete, assertRowInsert } from '@/lib/table/mutation-locks' -import { createTable, getTableById } from '@/lib/table/service' +import { assertWorkspaceTableCapacity, createTable, getTableById } from '@/lib/table/service' import type { TableImportJobPayload } from '@/lib/table/types' import { getWorkspaceFile, type WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { @@ -527,6 +527,17 @@ function parseImportJobPayload(payload: unknown): ParsedTableImportPayload | nul } } +/** + * Everything about a target that can be refused before the CSV moves. + * + * Runs at session creation AND again when the upload completes. The table + * ceiling in particular has to be checked in both places and for different + * reasons: at completion because the authoritative gate lives in `createTable`'s + * transaction and the quota can be reached while a large file uploads, and at + * creation because otherwise the only answer a full workspace ever gets is a 403 + * after it has already transferred up to 5 GiB to a presigned URL — leaving an + * orphaned object behind for a table that was never creatable. + */ async function validateTarget( workspaceId: string, target: V2TableImportTarget, @@ -536,6 +547,8 @@ async function validateTarget( if (resolvedFolderId && !(await findActiveFolder(resolvedFolderId, workspaceId, 'table'))) { throw new OrchestrationError('not_found', 'Folder not found in this workspace') } + const { maxTables } = await getWorkspaceTableLimits(workspaceId) + await assertWorkspaceTableCapacity(workspaceId, maxTables) return } await requireExistingTarget(workspaceId, target) diff --git a/apps/sim/lib/table/rows/service.ts b/apps/sim/lib/table/rows/service.ts index d9ab9aa15f8..f3e790cefc9 100644 --- a/apps/sim/lib/table/rows/service.ts +++ b/apps/sim/lib/table/rows/service.ts @@ -104,6 +104,7 @@ import { coerceRowToSchema, coerceRowValues, getUniqueColumns, + type UncoercibleValuePolicy, validateRowSize, } from '@/lib/table/validation' import { cancelWorkflowGroupRuns, runWorkflowColumn } from '@/lib/table/workflow-columns' @@ -1551,6 +1552,16 @@ export interface UpdateRowOptions { computedWrite?: boolean } +/** + * A computed write has no caller to answer with a 400 — the block already ran, + * and failing the write would strand the whole cell run over one output that + * does not fit its bound column. It blanks that cell instead. Every other write + * carries a value someone asked to store, so an uncoercible one is refused. + */ +function uncoercibleValuePolicy(options: { computedWrite?: boolean }): UncoercibleValuePolicy { + return options.computedWrite ? 'null' : 'reject' +} + /** * A row stores every cell in one jsonb `data` column, so a row update writes the changed cells as * an in-DB JSONB merge (`data = data || {changed}::jsonb`) rather than replacing the whole object. @@ -1609,7 +1620,11 @@ export async function updateRow( } // Validate against schema - const schemaValidation = coerceRowToSchema(mergedData, table.schema) + const schemaValidation = coerceRowToSchema( + mergedData, + table.schema, + uncoercibleValuePolicy(options) + ) if (!schemaValidation.valid) { throw new OrchestrationError( 'validation', @@ -2230,7 +2245,11 @@ export async function batchUpdateRows( ) } - const schemaValidation = coerceRowToSchema(merged, table.schema) + const schemaValidation = coerceRowToSchema( + merged, + table.schema, + uncoercibleValuePolicy(options) + ) if (!schemaValidation.valid) { throw new OrchestrationError( 'validation', diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 9fc18554370..b971addd78a 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -464,6 +464,62 @@ export async function queryTables( return { tables, nextKeys: hasMore && last ? encodeKeyset(keys, last) : null } } +/** + * The refusal {@link createTable} raises when a workspace is at its table + * ceiling. Shared so the advisory pre-check answers with the identical code, + * status, and message as the authoritative one inside the transaction. + */ +function workspaceTableLimitReached(maxTables: number): ForbiddenOperationError { + /** + * A quota ceiling, not bad input — both create routes have always answered + * 403 for it. It names its cause so a client can tell a ceiling apart from a + * role or key-kind refusal: one is cleared by deleting a table, the other by + * changing who is calling. + * + * The status is left as it shipped, and it disagrees with its sibling: + * {@link TableRowLimitError} answers 400 for the row ceiling. Neither is + * obviously right — a capacity ceiling is arguably a 409, since the request is + * well-formed, the caller is authorized, and the conflict is with the + * collection's current state, which the caller can clear. The disagreement is + * recorded rather than resolved here because the row ceiling is also reachable + * from the internal surface, where the 400 is shipped and not behind the v2 + * flag, so restatusing one and not the other would widen the split instead of + * closing it. + */ + return new ForbiddenOperationError( + 'WORKSPACE_RESOURCE_LIMIT_REACHED', + `Workspace has reached maximum table limit (${maxTables})` + ) +} + +/** + * Advisory table-quota check for a caller that is about to make the user pay + * for work before {@link createTable} would run. + * + * The authoritative check is the `FOR UPDATE` count inside `createTable`'s + * transaction and stays there — this one races, by construction, because the + * ceiling can be reached (or cleared) during whatever the caller does next. It + * exists so that "next" is not a multi-gigabyte upload: the CSV import used to + * hand out a presigned PUT for a table it already knew it could not create, and + * only answered 403 after the whole file had crossed the wire, leaving an + * orphaned object behind. + */ +export async function assertWorkspaceTableCapacity( + workspaceId: string, + maxTables: number +): Promise { + const [{ count: existingCount }] = await db + .select({ count: count() }) + .from(userTableDefinitions) + .where( + and( + eq(userTableDefinitions.workspaceId, workspaceId), + isNull(userTableDefinitions.archivedAt) + ) + ) + if (Number(existingCount) >= maxTables) throw workspaceTableLimitReached(maxTables) +} + /** * Creates a new table. * @@ -554,28 +610,7 @@ export async function createTable( ) ) - if (Number(existingCount) >= maxTables) { - /** - * A quota ceiling, not bad input — both create routes have always - * answered 403 for it. It names its cause so a client can tell a - * ceiling apart from a role or key-kind refusal: one is cleared by - * deleting a table, the other by changing who is calling. - * - * The status is left as it shipped, and it disagrees with its sibling: - * {@link TableRowLimitError} answers 400 for the row ceiling. Neither is - * obviously right — a capacity ceiling is arguably a 409, since the - * request is well-formed, the caller is authorized, and the conflict is - * with the collection's current state, which the caller can clear. The - * disagreement is recorded rather than resolved here because the row - * ceiling is also reachable from the internal surface, where the 400 is - * shipped and not behind the v2 flag, so restatusing one and not the - * other would widen the split instead of closing it. - */ - throw new ForbiddenOperationError( - 'WORKSPACE_RESOURCE_LIMIT_REACHED', - `Workspace has reached maximum table limit (${maxTables})` - ) - } + if (Number(existingCount) >= maxTables) throw workspaceTableLimitReached(maxTables) const duplicateName = await trx .select({ id: userTableDefinitions.id }) diff --git a/apps/sim/lib/table/validation.test.ts b/apps/sim/lib/table/validation.test.ts index 5e2b28732a0..96b001468df 100644 --- a/apps/sim/lib/table/validation.test.ts +++ b/apps/sim/lib/table/validation.test.ts @@ -100,22 +100,49 @@ describe('coerceRowToSchema — select', () => { expect(data.col_status).toBe('opt_closed') }) - it('nulls an unmatched value on an optional column', () => { + it('rejects an unmatched value on an optional column', () => { const data: RowData = { col_status: 'banana' } const result = coerceRowToSchema(data, schemaWith(selectColumn)) + expect(result.valid).toBe(false) + expect(result.errors.join(' ')).toContain('status') + }) + + it('nulls an unmatched value under the `null` policy', () => { + const data: RowData = { col_status: 'banana' } + const result = coerceRowToSchema(data, schemaWith(selectColumn), 'null') expect(result.valid).toBe(true) expect(data.col_status).toBeNull() }) }) describe('coerceRowToSchema — multiselect', () => { - it('resolves names and drops unmatched entries', () => { - const data: RowData = { col_tags: ['Alpha', 'opt_b', 'ghost'] } + it('resolves names', () => { + const data: RowData = { col_tags: ['Alpha', 'opt_b'] } const result = coerceRowToSchema(data, schemaWith(multiselectColumn)) expect(result.valid).toBe(true) expect(data.col_tags).toEqual(['opt_a', 'opt_b']) }) + it('rejects an entry matching no option instead of dropping it', () => { + const data: RowData = { col_tags: ['Alpha', 'ghost'] } + const result = coerceRowToSchema(data, schemaWith(multiselectColumn)) + expect(result.valid).toBe(false) + }) + + it('rejects a lone unmatched entry rather than storing an empty list', () => { + const data: RowData = { col_tags: ['green'] } + const result = coerceRowToSchema(data, schemaWith(multiselectColumn)) + expect(result.valid).toBe(false) + expect(data.col_tags).not.toEqual([]) + }) + + it('drops unmatched entries under the `null` policy', () => { + const data: RowData = { col_tags: ['Alpha', 'opt_b', 'ghost'] } + const result = coerceRowToSchema(data, schemaWith(multiselectColumn), 'null') + expect(result.valid).toBe(true) + expect(data.col_tags).toBeNull() + }) + it('wraps a single string into a one-element array', () => { const data: RowData = { col_tags: 'opt_a' as unknown as string[] } coerceRowToSchema(data, schemaWith(multiselectColumn)) @@ -123,6 +150,69 @@ describe('coerceRowToSchema — multiselect', () => { }) }) +/** + * The defect this pins: every one of these answered 200 with the cell stored as + * `null`, on an optional column, with nothing in the response saying a value had + * been discarded. The read side already 400s on the same mismatch in a filter + * predicate, so the two halves of the API disagreed about the same value. + */ +describe('coerceRowToSchema — uncoercible values are refused, not silently nulled', () => { + const numberColumn: ColumnDefinition = { id: 'col_n', name: 'n', type: 'number' } + const booleanColumn: ColumnDefinition = { id: 'col_b', name: 'b', type: 'boolean' } + const dateColumn: ColumnDefinition = { id: 'col_d', name: 'd', type: 'date' } + const stringColumn: ColumnDefinition = { id: 'col_s', name: 's', type: 'string' } + + const cases: Array<[string, ColumnDefinition, RowData[string]]> = [ + ['string into number', numberColumn, 'abc'], + ['boolean into number', numberColumn, true], + ['array into number', numberColumn, [1]], + ['"NaN" into number', numberColumn, 'NaN'], + ['"yes" into boolean', booleanColumn, 'yes'], + ['1 into boolean', booleanColumn, 1], + ['object into boolean', booleanColumn, {}], + ['unparseable string into date', dateColumn, 'not-a-date'], + ['object into string', stringColumn, { a: 1 }], + ] + + it.each(cases)('rejects %s', (_label, column, value) => { + const data: RowData = { [column.id as string]: value } + const result = coerceRowToSchema(data, schemaWith(column)) + expect(result.valid).toBe(false) + expect(data[column.id as string]).not.toBeNull() + }) + + it.each(cases)('nulls %s under the `null` policy', (_label, column, value) => { + const data: RowData = { [column.id as string]: value } + const result = coerceRowToSchema(data, schemaWith(column), 'null') + expect(result.valid).toBe(true) + expect(data[column.id as string]).toBeNull() + }) + + it('still applies unambiguous conversions', () => { + const data: RowData = { col_n: '1999' } + expect(coerceRowToSchema(data, schemaWith(numberColumn)).valid).toBe(true) + expect(data.col_n).toBe(1999) + }) + + /** + * A bare number cannot say whether it means seconds or milliseconds, and both + * readings land in range. Milliseconds used to win, so `1600000000` — a + * Unix-seconds timestamp for September 2020 — stored 19 January 1970 with a + * 200. + */ + it('refuses a bare epoch number rather than guessing its unit', () => { + const data: RowData = { col_d: 1600000000 } + const result = coerceRowToSchema(data, schemaWith(dateColumn)) + expect(result.valid).toBe(false) + expect(data.col_d).not.toBe('1970-01-19T12:26:40.000Z') + }) + + it('accepts an ISO-8601 string, which states its own unit', () => { + const data: RowData = { col_d: '2020-09-13T12:26:40Z' } + expect(coerceRowToSchema(data, schemaWith(dateColumn)).valid).toBe(true) + }) +}) + describe('resolveSelectOptionId', () => { const options = selectColumn.options ?? [] diff --git a/apps/sim/lib/table/validation.ts b/apps/sim/lib/table/validation.ts index 270ee5e3ee4..d63497ec569 100644 --- a/apps/sim/lib/table/validation.ts +++ b/apps/sim/lib/table/validation.ts @@ -274,17 +274,41 @@ function coerceValueToColumnType(value: JsonValue, column: ColumnDefinition): Co return columnTypeOf(column).coerce(value, column) } +/** + * What a write does with a value its column's type cannot coerce. + * + * - `reject` — leave the value in place so the following + * {@link validateRowAgainstSchema} reports it and the write fails. This is + * the default, and the only policy any caller-supplied value may use: a + * client that sends `"abc"` for a `number` column has made a mistake, and + * answering 200 while storing `null` destroys the cell it was trying to + * write. It also matches the read side, which already refuses the same + * mismatch in a filter predicate rather than matching nothing. + * - `null` — blank the cell instead. Reserved for values a *machine* produced + * for a cell the caller did not type: a workflow/enrichment block whose + * output does not fit its bound column, and a CSV import where one bad cell + * in a 100k-row file must not fail the file. Nothing there has a caller to + * return a 400 to. + * + * A `required` column is never blanked under either policy — a null would fail + * the required check immediately after. + */ +export type UncoercibleValuePolicy = 'reject' | 'null' + /** * Coerces each present value in `data` toward its column's declared type **in * place**. Values that already match are untouched; unambiguous conversions - * (e.g. `"1999"` → `1999`) are applied; values that cannot be coerced are set to - * `null` when the column is optional, or left in place when required (so a - * subsequent {@link validateRowAgainstSchema} reports them). + * (e.g. `"1999"` → `1999`) are applied; values that cannot be coerced are + * handled per {@link UncoercibleValuePolicy}. * * Operates per-present-column, so it is safe on a partial patch (columns absent * from `data` are skipped — it never invents a missing-required-field error). */ -export function coerceRowValues(data: RowData, schema: TableSchema): void { +export function coerceRowValues( + data: RowData, + schema: TableSchema, + policy: UncoercibleValuePolicy = 'reject' +): void { for (const column of schema.columns) { const key = getColumnId(column) const value = data[key] @@ -293,7 +317,7 @@ export function coerceRowValues(data: RowData, schema: TableSchema): void { const coerced = coerceValueToColumnType(value, column) if (coerced.ok) { data[key] = coerced.value - } else if (!column.required) { + } else if (policy === 'null' && !column.required) { data[key] = null } } @@ -304,14 +328,17 @@ export function coerceRowValues(data: RowData, schema: TableSchema): void { * then validates the result. * * This is the write-path entry point — callers that persist a complete row use - * it instead of {@link validateRowAgainstSchema} so a single off-type field (a - * tool returning `"unknown"` for a numeric column, say) nulls that one cell - * rather than failing the entire row write. Callers persisting only a partial - * patch should use {@link coerceRowValues} on the patch and validate the merged - * row separately. + * it instead of {@link validateRowAgainstSchema} so the coercion and the check + * that follows it can never disagree about what a cell holds. Callers persisting + * only a partial patch should use {@link coerceRowValues} on the patch and + * validate the merged row separately. */ -export function coerceRowToSchema(data: RowData, schema: TableSchema): ValidationResult { - coerceRowValues(data, schema) +export function coerceRowToSchema( + data: RowData, + schema: TableSchema, + policy: UncoercibleValuePolicy = 'reject' +): ValidationResult { + coerceRowValues(data, schema, policy) return validateRowAgainstSchema(data, schema) } diff --git a/apps/sim/lib/table/workflow-groups/service.test.ts b/apps/sim/lib/table/workflow-groups/service.test.ts new file mode 100644 index 00000000000..dd1f73cc2bf --- /dev/null +++ b/apps/sim/lib/table/workflow-groups/service.test.ts @@ -0,0 +1,103 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableDefinition, WorkflowGroup } from '@/lib/table/types' + +const { mockWithLockedTable, mockGetTableById } = vi.hoisted(() => ({ + mockWithLockedTable: vi.fn(), + mockGetTableById: vi.fn(), +})) + +vi.mock('@/lib/table/service', () => ({ + getTableById: mockGetTableById, + withLockedTable: mockWithLockedTable, +})) +vi.mock('@/lib/table/mutation-locks', () => ({ + assertColumnDestructive: vi.fn(), + assertSchemaMutable: vi.fn(), +})) +vi.mock('@/lib/table/rows/secret-provenance', () => ({ + updateTableRowsWithDerivedSecretProvenance: vi.fn(), +})) +vi.mock('@/lib/table/workflow-columns', () => ({ + assertValidSchema: vi.fn(), + runWorkflowColumn: vi.fn().mockResolvedValue(undefined), + stripGroupDeps: (schema: unknown) => schema, +})) + +import { TABLE_LIMITS } from '@/lib/table/constants' +import { addWorkflowGroup } from '@/lib/table/workflow-groups/service' + +function groupAt(index: number): WorkflowGroup { + return { + id: `group-${index}`, + workflowId: 'workflow-1', + outputs: [{ blockId: 'block-1', path: 'out', columnName: `out_${index}` }], + } as WorkflowGroup +} + +function tableWithGroups(count: number): TableDefinition { + return { + id: 'table-1', + name: 'People', + description: null, + schema: { + columns: [{ id: 'col_a', name: 'name', type: 'string' }], + workflowGroups: Array.from({ length: count }, (_unused, index) => groupAt(index)), + }, + metadata: null, + rowCount: 0, + maxRows: 10_000, + workspaceId: 'workspace-1', + createdBy: 'user-1', + archivedAt: null, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + } as TableDefinition +} + +/** + * `GET /tables/{id}/groups` is published as a full-set list — one page, always + * `nextCursor: null`. Nothing made that claim true: the group count had no cap + * of its own, and the indirect bound (a create must add at least one column, and + * columns are capped) does not survive an update path that adds none. + */ +describe('addWorkflowGroup group ceiling', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + function add(existingGroups: number) { + const table = tableWithGroups(existingGroups) + mockWithLockedTable.mockImplementation( + async (_tableId: string, mutate: (t: TableDefinition, trx: unknown) => Promise) => + mutate(table, { + update: () => ({ set: () => ({ where: () => Promise.resolve() }) }), + execute: () => Promise.resolve(), + }) + ) + mockGetTableById.mockResolvedValue(table) + return addWorkflowGroup( + { + tableId: 'table-1', + workspaceId: 'workspace-1', + group: groupAt(9999), + outputColumns: [{ name: 'out_9999', type: 'string', workflowGroupId: 'group-9999' }], + autoRun: false, + actorUserId: 'user-1', + } as Parameters[0], + 'request-1' + ) + } + + it('refuses a create that would cross MAX_WORKFLOW_GROUPS_PER_TABLE', async () => { + await expect(add(TABLE_LIMITS.MAX_WORKFLOW_GROUPS_PER_TABLE)).rejects.toThrow( + /maximum of \d+ workflow groups/ + ) + }) + + it('allows the create that lands exactly on the ceiling', async () => { + await expect(add(TABLE_LIMITS.MAX_WORKFLOW_GROUPS_PER_TABLE - 1)).resolves.toBeDefined() + }) +}) diff --git a/apps/sim/lib/table/workflow-groups/service.ts b/apps/sim/lib/table/workflow-groups/service.ts index 35378a997b9..43a356347a7 100644 --- a/apps/sim/lib/table/workflow-groups/service.ts +++ b/apps/sim/lib/table/workflow-groups/service.ts @@ -143,6 +143,13 @@ export async function addWorkflowGroup( ) } + if (groups.length >= TABLE_LIMITS.MAX_WORKFLOW_GROUPS_PER_TABLE) { + throw new OrchestrationError( + 'validation', + `Table has reached the maximum of ${TABLE_LIMITS.MAX_WORKFLOW_GROUPS_PER_TABLE} workflow groups` + ) + } + const existingNames = new Set(schema.columns.map((c) => c.name.toLowerCase())) for (const col of data.outputColumns) { if (!NAME_PATTERN.test(col.name)) { From fb56439c6d60dfd145734cd272bfb5f96a4645e5 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 17:01:23 -0700 Subject: [PATCH 11/56] fix(v2): bind every paged list's cursor to its filters, not just its sort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A v2 cursor names a position in one sequence, and a list decides that sequence from its sort AND its filters. Only the sort was stamped on the shared keyset codec, so a cursor from an unfiltered walk was accepted under a changed `search`, `scope`, `deployedOnly`, or folder and answered from a sequence the caller never asked for. The two offset lists already stamped both; nothing else did. The failure differs by scheme but is silent in both. An offset lands at an unrelated ordinal. A keyset stays internally coherent — correctly ordered, duplicate-free — and drops every match sorting before its position, which a caller holding an opaque token reads as "almost nothing matched". One mechanism, shared with the table-row codec: canonical JSON plus a SHA-256 fingerprint (`lib/api/cursor-binding.ts`), stamped by `cursorFilterScope` alongside `cursorSortKey`. The two stamps stay separate so the 400 names which half changed. `limit` is never bound — it selects how much of the sequence to return, not what it is. The three lists whose token is minted by a domain codec (`/logs`, `/audit-logs`, `/billing/logs`) get the same binding by wrapping that token in a query-stamped envelope; the domain cursor is untouched. `present` now also receives the parsed request, so a presenter reads the filters it stamps straight from the query instead of the use case carrying an HTTP cursor concern back out — the `cursorSort`/`cursorScope` round-trips through three application services are removed. `list-pagination.test.ts` now declares each paged list's binding and checks it against the contract in both directions, so a new list, or a new filter on an existing one, fails until its binding is decided. --- .agents/skills/v2-api-conventions/SKILL.md | 15 +- .claude/commands/v2-api-conventions.md | 15 +- .cursor/commands/v2-api-conventions.md | 15 +- apps/docs/openapi-v2-billing.json | 4 +- apps/docs/openapi-v2-files-audit.json | 8 +- apps/docs/openapi-v2-knowledge.json | 8 +- apps/docs/openapi-v2-logs.json | 4 +- apps/docs/openapi-v2-resources.json | 24 +-- apps/docs/openapi-v2-tables.json | 4 +- apps/docs/openapi-v2-workflows.json | 12 +- apps/sim/app/api/v2/audit-logs/route.test.ts | 7 +- apps/sim/app/api/v2/audit-logs/route.ts | 32 ++- .../sim/app/api/v2/billing/logs/route.test.ts | 36 +++- apps/sim/app/api/v2/billing/logs/route.ts | 36 +++- apps/sim/app/api/v2/credentials/route.ts | 37 +++- apps/sim/app/api/v2/custom-tools/route.ts | 30 ++- apps/sim/app/api/v2/files/route.test.ts | 60 +++++- apps/sim/app/api/v2/files/route.ts | 38 +++- .../[id]/documents/collection.test.ts | 35 +++- .../api/v2/knowledge/[id]/documents/route.ts | 46 +++-- apps/sim/app/api/v2/knowledge/route.ts | 35 +++- apps/sim/app/api/v2/lib/response.ts | 184 +++++++++++++---- apps/sim/app/api/v2/logs/route.test.ts | 65 ++++++ apps/sim/app/api/v2/logs/route.ts | 56 +++++- apps/sim/app/api/v2/mcp-servers/route.ts | 30 ++- apps/sim/app/api/v2/secrets/route.ts | 31 ++- apps/sim/app/api/v2/skills/route.test.ts | 39 ++-- apps/sim/app/api/v2/skills/route.ts | 37 ++-- apps/sim/app/api/v2/tables/route.ts | 28 ++- .../api/v2/workflows/[id]/runs/route.test.ts | 2 +- .../app/api/v2/workflows/[id]/runs/route.ts | 38 +++- apps/sim/app/api/v2/workflows/route.test.ts | 65 ++++++ apps/sim/app/api/v2/workflows/route.ts | 37 +++- .../v2/__tests__/list-pagination.test.ts | 184 ++++++++++++++++- apps/sim/lib/api/contracts/v2/shared.ts | 49 +++-- apps/sim/lib/api/cursor-binding.test.ts | 187 ++++++++++++++++++ apps/sim/lib/api/cursor-binding.ts | 83 ++++++++ apps/sim/lib/api/offset-cursor-scope.test.ts | 80 -------- apps/sim/lib/api/server/routes/types.ts | 16 +- .../lib/api/server/routes/v2-json-route.ts | 2 +- .../lib/knowledge/application/documents.ts | 7 - apps/sim/lib/skills/application/use-cases.ts | 6 - apps/sim/lib/table/rows/cursor.ts | 27 +-- .../application/list-workspace-files.ts | 3 +- 44 files changed, 1415 insertions(+), 342 deletions(-) create mode 100644 apps/sim/lib/api/cursor-binding.test.ts create mode 100644 apps/sim/lib/api/cursor-binding.ts delete mode 100644 apps/sim/lib/api/offset-cursor-scope.test.ts diff --git a/.agents/skills/v2-api-conventions/SKILL.md b/.agents/skills/v2-api-conventions/SKILL.md index cd82f3c07ce..3b54cf478d8 100644 --- a/.agents/skills/v2-api-conventions/SKILL.md +++ b/.agents/skills/v2-api-conventions/SKILL.md @@ -100,10 +100,14 @@ That gives `limit` (integer, 1..`V2_MAX_PAGE_SIZE`, defaulting to `V2_DEFAULT_PA Three cursor schemes exist. Two are the shared codecs in `response.ts`, both opaque base64-JSON, and which of them you use is decided by what the read can express, not by taste: -- **Keyset** (`readSortedCursor` in, `encodeSortedCursor` out) — the default. Requires the page to come from one ordered SQL read. The sort is stamped into the cursor and re-checked on replay, so changing `sortBy` mid-pagination is a 400, not a silently skipped page. -- **Offset** (`decodeOffsetCursor` / `encodeOffsetCursor`) — only when a keyset is impossible. Two lists qualify: `GET /skills` merges a static in-code registry with DB rows and re-sorts in JS, and `GET /knowledge/{id}/documents` sits on a limit/offset query. An offset cursor **must** be stamped with `offsetCursorScope(...)` covering every param that filters or orders the sequence (not `limit`, which only selects how much of it to return). A bare offset replayed against a re-sorted or re-filtered sequence names a different row, which skips or repeats results — the exact failure the keyset's sort stamp already prevents. +- **Keyset** (`readSortedCursor` in, `encodeSortedCursor` out) — the default. Requires the page to come from one ordered SQL read. The sort AND the filters are stamped into the cursor and re-checked on replay, so changing `sortBy` or any filter mid-pagination is a 400, not a silently skipped page. +- **Offset** (`decodeOffsetCursor` / `encodeOffsetCursor`) — only when a keyset is impossible. Two lists qualify: `GET /skills` merges a static in-code registry with DB rows and re-sorts in JS, and `GET /knowledge/{id}/documents` sits on a limit/offset query. A bare offset replayed against a re-sorted or re-filtered sequence names a different row, which skips or repeats results. -The third is **per-domain**: a list whose read predates the shared codecs, or whose page boundary is not expressible as one, mints its own — a bare `encodeCursor({ version })` on `GET /workflows/{id}/versions` and `encodeCursor({ email })` on the workspace member list, the local codecs in `lib/audit-logs/query.ts`, `lib/logs/list-logs.ts`, and `lib/table/rows/cursor.ts`, and a usage-event id passed straight through by `GET /billing/logs`. They are opaque to a caller in exactly the same way, but they do not get the shared codec's sort stamp, so they cannot reject a cursor replayed under a changed sort. **A new list picks one of the two shared schemes.** Do not add a fourth. +Both take the same two stamps: `cursorSortKey(sortBy, sortOrder)` for the ordering, and `cursorFilterScope({ ... })` for every param that filters the sequence. **`limit` is never a stamp** — it selects how much of the sequence to return, not what the sequence is, and binding it strands every cursor the moment a caller changes page size. Params that only shape the response body are out for the same reason. + +The third is **per-domain**: a list whose read predates the shared codecs, or whose page boundary is not expressible as one, mints its own — a bare `encodeCursor({ version })` on `GET /workflows/{id}/versions` and `encodeCursor({ email })` on the workspace member list, the local codecs in `lib/audit-logs/query.ts`, `lib/logs/list-logs.ts`, and `lib/table/rows/cursor.ts`, and a usage-event id passed straight through by `GET /billing/logs`. Those tokens stay opaque and untouched, but a domain-minted cursor on a list a caller can re-filter is wrapped at the surface with `encodeScopedCursor(cursorFilterScope({...}), token)` and unwrapped with `readScopedCursor`, so it carries the same binding as the shared schemes. **A new list picks one of the two shared schemes.** Do not add a fourth. + +Every paged list's binding is declared in `lib/api/contracts/v2/__tests__/list-pagination.test.ts` and checked against what the contract actually accepts, in both directions. A new list, or a new filter on an existing one, fails that test until its binding is declared or the param is explicitly recorded as unable to change the sequence. **A keyset's key list must end in a unique column (`id`).** A non-unique trailing key cannot separate tied rows, so the page boundary either repeats or drops them. `lib/api/list-keyset-paging.test.ts` demonstrates the failure. @@ -136,7 +140,7 @@ That last one is the standard to aim for. A message that only says `Invalid inpu Order matters because each layer is checked against the one before it. 1. **Contract** in `lib/api/contracts/v2/.ts` via `defineRouteContract`. Response schemas are `.parse`d on the way out, so a field the producer does not actually emit becomes a 500 on a successful read — assert only what you can prove. -2. **Application use case** owns canonical loading, authorization, business behavior, and audit. The route's `present` receives **only the use-case result**, so anything the presenter needs (e.g. the active `sortBy`/`sortOrder` to stamp a cursor) must be returned by the use case. +2. **Application use case** owns canonical loading, authorization, business behavior, and audit. The route's `present` receives the use-case result **and the parsed request**, so a presenter reads request params (the active `sortBy`/`sortOrder` and filters it stamps into a cursor) straight from `query`/`params` rather than making the use case carry an HTTP concern back out. 3. **Route** with `defineV2JsonRoute`, declaring `contract`, `auth: v2ApiKeyAuth`, `operation`, `rateLimit`, `errorPolicy`, `mapInput`, `useCase`, `present`. Auth and rate limiting run before parsing. 4. **OpenAPI description** in `lib/api/contracts/v2/openapi/.ts`, then `bun run generate:openapi`. A description that claims behaviour the route does not have is the same class of bug as a wrong schema. @@ -190,7 +194,7 @@ That makes the money path safe against double-execution **for callers that opt i The base64-JSON cursor is **not signed**, and does not need to be. Tampering is bounded by construction, and that is a property to preserve: - Every key value is re-validated by its `KeysetKey.bind`, which returns `null` for a wrong-typed or unparseable value and becomes a 400. A forged cursor cannot reach SQL as `NaN` or an `Invalid Date`. -- The sort is stamped into the cursor and re-checked (`decodeSortedCursor`), so a cursor from a differently-sorted query is a 400, not a silently skipped page. +- The sort and a fingerprint of the filters are stamped into the cursor and re-checked (`decodeSortedCursor`), so a cursor from a differently-sorted or differently-filtered query is a 400, not a silently skipped page. The filters are hashed (SHA-256, via `lib/api/cursor-binding.ts`) rather than embedded, so the token stays short and a caller cannot cheaply construct a filter that collides with another sequence's stamp. - The offset codec rejects anything that is not a non-negative integer. - Authorization is **never** carried in the cursor. Every list re-derives its workspace scope from the authenticated principal, so a cursor lifted from another query — or another tenant — can only move the caller within their own authorized result set. @@ -206,6 +210,7 @@ Run this against any new or changed v2 endpoint. - [ ] No caller-supplied value can produce a 500 — check every numeric param reaches SQL as a validated integer, and that any bound value passed as an argument to a SQL function carries an explicit type. - [ ] `limit` comes from `v2PaginationFields`, not a hand-written `z.coerce.number()`. - [ ] If the response carries `nextCursor`, the query accepts `limit` + `cursor` and the query actually applies them. +- [ ] The cursor is bound to every param that filters or orders the sequence, and to none that do not (never `limit`), with the binding declared in `list-pagination.test.ts`. - [ ] Keyset sorts end in a unique `id` key. - [ ] The list is classified in `list-pagination.test.ts`. - [ ] Cross-tenant access answers 404, never 403 — and carries `Cache-Control: private, no-store`, because RFC 9110 §15.5.5 makes 404 heuristically cacheable and an authorization-dependent 404 must never be stored. `v2Error` sets this unconditionally; do not build a v2 response any other way. diff --git a/.claude/commands/v2-api-conventions.md b/.claude/commands/v2-api-conventions.md index 9f43a888e23..b7c30389a50 100644 --- a/.claude/commands/v2-api-conventions.md +++ b/.claude/commands/v2-api-conventions.md @@ -99,10 +99,14 @@ That gives `limit` (integer, 1..`V2_MAX_PAGE_SIZE`, defaulting to `V2_DEFAULT_PA Three cursor schemes exist. Two are the shared codecs in `response.ts`, both opaque base64-JSON, and which of them you use is decided by what the read can express, not by taste: -- **Keyset** (`readSortedCursor` in, `encodeSortedCursor` out) — the default. Requires the page to come from one ordered SQL read. The sort is stamped into the cursor and re-checked on replay, so changing `sortBy` mid-pagination is a 400, not a silently skipped page. -- **Offset** (`decodeOffsetCursor` / `encodeOffsetCursor`) — only when a keyset is impossible. Two lists qualify: `GET /skills` merges a static in-code registry with DB rows and re-sorts in JS, and `GET /knowledge/{id}/documents` sits on a limit/offset query. An offset cursor **must** be stamped with `offsetCursorScope(...)` covering every param that filters or orders the sequence (not `limit`, which only selects how much of it to return). A bare offset replayed against a re-sorted or re-filtered sequence names a different row, which skips or repeats results — the exact failure the keyset's sort stamp already prevents. +- **Keyset** (`readSortedCursor` in, `encodeSortedCursor` out) — the default. Requires the page to come from one ordered SQL read. The sort AND the filters are stamped into the cursor and re-checked on replay, so changing `sortBy` or any filter mid-pagination is a 400, not a silently skipped page. +- **Offset** (`decodeOffsetCursor` / `encodeOffsetCursor`) — only when a keyset is impossible. Two lists qualify: `GET /skills` merges a static in-code registry with DB rows and re-sorts in JS, and `GET /knowledge/{id}/documents` sits on a limit/offset query. A bare offset replayed against a re-sorted or re-filtered sequence names a different row, which skips or repeats results. -The third is **per-domain**: a list whose read predates the shared codecs, or whose page boundary is not expressible as one, mints its own — a bare `encodeCursor({ version })` on `GET /workflows/{id}/versions` and `encodeCursor({ email })` on the workspace member list, the local codecs in `lib/audit-logs/query.ts`, `lib/logs/list-logs.ts`, and `lib/table/rows/cursor.ts`, and a usage-event id passed straight through by `GET /billing/logs`. They are opaque to a caller in exactly the same way, but they do not get the shared codec's sort stamp, so they cannot reject a cursor replayed under a changed sort. **A new list picks one of the two shared schemes.** Do not add a fourth. +Both take the same two stamps: `cursorSortKey(sortBy, sortOrder)` for the ordering, and `cursorFilterScope({ ... })` for every param that filters the sequence. **`limit` is never a stamp** — it selects how much of the sequence to return, not what the sequence is, and binding it strands every cursor the moment a caller changes page size. Params that only shape the response body are out for the same reason. + +The third is **per-domain**: a list whose read predates the shared codecs, or whose page boundary is not expressible as one, mints its own — a bare `encodeCursor({ version })` on `GET /workflows/{id}/versions` and `encodeCursor({ email })` on the workspace member list, the local codecs in `lib/audit-logs/query.ts`, `lib/logs/list-logs.ts`, and `lib/table/rows/cursor.ts`, and a usage-event id passed straight through by `GET /billing/logs`. Those tokens stay opaque and untouched, but a domain-minted cursor on a list a caller can re-filter is wrapped at the surface with `encodeScopedCursor(cursorFilterScope({...}), token)` and unwrapped with `readScopedCursor`, so it carries the same binding as the shared schemes. **A new list picks one of the two shared schemes.** Do not add a fourth. + +Every paged list's binding is declared in `lib/api/contracts/v2/__tests__/list-pagination.test.ts` and checked against what the contract actually accepts, in both directions. A new list, or a new filter on an existing one, fails that test until its binding is declared or the param is explicitly recorded as unable to change the sequence. **A keyset's key list must end in a unique column (`id`).** A non-unique trailing key cannot separate tied rows, so the page boundary either repeats or drops them. `lib/api/list-keyset-paging.test.ts` demonstrates the failure. @@ -135,7 +139,7 @@ That last one is the standard to aim for. A message that only says `Invalid inpu Order matters because each layer is checked against the one before it. 1. **Contract** in `lib/api/contracts/v2/.ts` via `defineRouteContract`. Response schemas are `.parse`d on the way out, so a field the producer does not actually emit becomes a 500 on a successful read — assert only what you can prove. -2. **Application use case** owns canonical loading, authorization, business behavior, and audit. The route's `present` receives **only the use-case result**, so anything the presenter needs (e.g. the active `sortBy`/`sortOrder` to stamp a cursor) must be returned by the use case. +2. **Application use case** owns canonical loading, authorization, business behavior, and audit. The route's `present` receives the use-case result **and the parsed request**, so a presenter reads request params (the active `sortBy`/`sortOrder` and filters it stamps into a cursor) straight from `query`/`params` rather than making the use case carry an HTTP concern back out. 3. **Route** with `defineV2JsonRoute`, declaring `contract`, `auth: v2ApiKeyAuth`, `operation`, `rateLimit`, `errorPolicy`, `mapInput`, `useCase`, `present`. Auth and rate limiting run before parsing. 4. **OpenAPI description** in `lib/api/contracts/v2/openapi/.ts`, then `bun run generate:openapi`. A description that claims behaviour the route does not have is the same class of bug as a wrong schema. @@ -189,7 +193,7 @@ That makes the money path safe against double-execution **for callers that opt i The base64-JSON cursor is **not signed**, and does not need to be. Tampering is bounded by construction, and that is a property to preserve: - Every key value is re-validated by its `KeysetKey.bind`, which returns `null` for a wrong-typed or unparseable value and becomes a 400. A forged cursor cannot reach SQL as `NaN` or an `Invalid Date`. -- The sort is stamped into the cursor and re-checked (`decodeSortedCursor`), so a cursor from a differently-sorted query is a 400, not a silently skipped page. +- The sort and a fingerprint of the filters are stamped into the cursor and re-checked (`decodeSortedCursor`), so a cursor from a differently-sorted or differently-filtered query is a 400, not a silently skipped page. The filters are hashed (SHA-256, via `lib/api/cursor-binding.ts`) rather than embedded, so the token stays short and a caller cannot cheaply construct a filter that collides with another sequence's stamp. - The offset codec rejects anything that is not a non-negative integer. - Authorization is **never** carried in the cursor. Every list re-derives its workspace scope from the authenticated principal, so a cursor lifted from another query — or another tenant — can only move the caller within their own authorized result set. @@ -205,6 +209,7 @@ Run this against any new or changed v2 endpoint. - [ ] No caller-supplied value can produce a 500 — check every numeric param reaches SQL as a validated integer, and that any bound value passed as an argument to a SQL function carries an explicit type. - [ ] `limit` comes from `v2PaginationFields`, not a hand-written `z.coerce.number()`. - [ ] If the response carries `nextCursor`, the query accepts `limit` + `cursor` and the query actually applies them. +- [ ] The cursor is bound to every param that filters or orders the sequence, and to none that do not (never `limit`), with the binding declared in `list-pagination.test.ts`. - [ ] Keyset sorts end in a unique `id` key. - [ ] The list is classified in `list-pagination.test.ts`. - [ ] Cross-tenant access answers 404, never 403 — and carries `Cache-Control: private, no-store`, because RFC 9110 §15.5.5 makes 404 heuristically cacheable and an authorization-dependent 404 must never be stored. `v2Error` sets this unconditionally; do not build a v2 response any other way. diff --git a/.cursor/commands/v2-api-conventions.md b/.cursor/commands/v2-api-conventions.md index 5629c2ec41d..a658349e2fb 100644 --- a/.cursor/commands/v2-api-conventions.md +++ b/.cursor/commands/v2-api-conventions.md @@ -94,10 +94,14 @@ That gives `limit` (integer, 1..`V2_MAX_PAGE_SIZE`, defaulting to `V2_DEFAULT_PA Three cursor schemes exist. Two are the shared codecs in `response.ts`, both opaque base64-JSON, and which of them you use is decided by what the read can express, not by taste: -- **Keyset** (`readSortedCursor` in, `encodeSortedCursor` out) — the default. Requires the page to come from one ordered SQL read. The sort is stamped into the cursor and re-checked on replay, so changing `sortBy` mid-pagination is a 400, not a silently skipped page. -- **Offset** (`decodeOffsetCursor` / `encodeOffsetCursor`) — only when a keyset is impossible. Two lists qualify: `GET /skills` merges a static in-code registry with DB rows and re-sorts in JS, and `GET /knowledge/{id}/documents` sits on a limit/offset query. An offset cursor **must** be stamped with `offsetCursorScope(...)` covering every param that filters or orders the sequence (not `limit`, which only selects how much of it to return). A bare offset replayed against a re-sorted or re-filtered sequence names a different row, which skips or repeats results — the exact failure the keyset's sort stamp already prevents. +- **Keyset** (`readSortedCursor` in, `encodeSortedCursor` out) — the default. Requires the page to come from one ordered SQL read. The sort AND the filters are stamped into the cursor and re-checked on replay, so changing `sortBy` or any filter mid-pagination is a 400, not a silently skipped page. +- **Offset** (`decodeOffsetCursor` / `encodeOffsetCursor`) — only when a keyset is impossible. Two lists qualify: `GET /skills` merges a static in-code registry with DB rows and re-sorts in JS, and `GET /knowledge/{id}/documents` sits on a limit/offset query. A bare offset replayed against a re-sorted or re-filtered sequence names a different row, which skips or repeats results. -The third is **per-domain**: a list whose read predates the shared codecs, or whose page boundary is not expressible as one, mints its own — a bare `encodeCursor({ version })` on `GET /workflows/{id}/versions` and `encodeCursor({ email })` on the workspace member list, the local codecs in `lib/audit-logs/query.ts`, `lib/logs/list-logs.ts`, and `lib/table/rows/cursor.ts`, and a usage-event id passed straight through by `GET /billing/logs`. They are opaque to a caller in exactly the same way, but they do not get the shared codec's sort stamp, so they cannot reject a cursor replayed under a changed sort. **A new list picks one of the two shared schemes.** Do not add a fourth. +Both take the same two stamps: `cursorSortKey(sortBy, sortOrder)` for the ordering, and `cursorFilterScope({ ... })` for every param that filters the sequence. **`limit` is never a stamp** — it selects how much of the sequence to return, not what the sequence is, and binding it strands every cursor the moment a caller changes page size. Params that only shape the response body are out for the same reason. + +The third is **per-domain**: a list whose read predates the shared codecs, or whose page boundary is not expressible as one, mints its own — a bare `encodeCursor({ version })` on `GET /workflows/{id}/versions` and `encodeCursor({ email })` on the workspace member list, the local codecs in `lib/audit-logs/query.ts`, `lib/logs/list-logs.ts`, and `lib/table/rows/cursor.ts`, and a usage-event id passed straight through by `GET /billing/logs`. Those tokens stay opaque and untouched, but a domain-minted cursor on a list a caller can re-filter is wrapped at the surface with `encodeScopedCursor(cursorFilterScope({...}), token)` and unwrapped with `readScopedCursor`, so it carries the same binding as the shared schemes. **A new list picks one of the two shared schemes.** Do not add a fourth. + +Every paged list's binding is declared in `lib/api/contracts/v2/__tests__/list-pagination.test.ts` and checked against what the contract actually accepts, in both directions. A new list, or a new filter on an existing one, fails that test until its binding is declared or the param is explicitly recorded as unable to change the sequence. **A keyset's key list must end in a unique column (`id`).** A non-unique trailing key cannot separate tied rows, so the page boundary either repeats or drops them. `lib/api/list-keyset-paging.test.ts` demonstrates the failure. @@ -130,7 +134,7 @@ That last one is the standard to aim for. A message that only says `Invalid inpu Order matters because each layer is checked against the one before it. 1. **Contract** in `lib/api/contracts/v2/.ts` via `defineRouteContract`. Response schemas are `.parse`d on the way out, so a field the producer does not actually emit becomes a 500 on a successful read — assert only what you can prove. -2. **Application use case** owns canonical loading, authorization, business behavior, and audit. The route's `present` receives **only the use-case result**, so anything the presenter needs (e.g. the active `sortBy`/`sortOrder` to stamp a cursor) must be returned by the use case. +2. **Application use case** owns canonical loading, authorization, business behavior, and audit. The route's `present` receives the use-case result **and the parsed request**, so a presenter reads request params (the active `sortBy`/`sortOrder` and filters it stamps into a cursor) straight from `query`/`params` rather than making the use case carry an HTTP concern back out. 3. **Route** with `defineV2JsonRoute`, declaring `contract`, `auth: v2ApiKeyAuth`, `operation`, `rateLimit`, `errorPolicy`, `mapInput`, `useCase`, `present`. Auth and rate limiting run before parsing. 4. **OpenAPI description** in `lib/api/contracts/v2/openapi/.ts`, then `bun run generate:openapi`. A description that claims behaviour the route does not have is the same class of bug as a wrong schema. @@ -184,7 +188,7 @@ That makes the money path safe against double-execution **for callers that opt i The base64-JSON cursor is **not signed**, and does not need to be. Tampering is bounded by construction, and that is a property to preserve: - Every key value is re-validated by its `KeysetKey.bind`, which returns `null` for a wrong-typed or unparseable value and becomes a 400. A forged cursor cannot reach SQL as `NaN` or an `Invalid Date`. -- The sort is stamped into the cursor and re-checked (`decodeSortedCursor`), so a cursor from a differently-sorted query is a 400, not a silently skipped page. +- The sort and a fingerprint of the filters are stamped into the cursor and re-checked (`decodeSortedCursor`), so a cursor from a differently-sorted or differently-filtered query is a 400, not a silently skipped page. The filters are hashed (SHA-256, via `lib/api/cursor-binding.ts`) rather than embedded, so the token stays short and a caller cannot cheaply construct a filter that collides with another sequence's stamp. - The offset codec rejects anything that is not a non-negative integer. - Authorization is **never** carried in the cursor. Every list re-derives its workspace scope from the authenticated principal, so a cursor lifted from another query — or another tenant — can only move the caller within their own authorized result set. @@ -200,6 +204,7 @@ Run this against any new or changed v2 endpoint. - [ ] No caller-supplied value can produce a 500 — check every numeric param reaches SQL as a validated integer, and that any bound value passed as an argument to a SQL function carries an explicit type. - [ ] `limit` comes from `v2PaginationFields`, not a hand-written `z.coerce.number()`. - [ ] If the response carries `nextCursor`, the query accepts `limit` + `cursor` and the query actually applies them. +- [ ] The cursor is bound to every param that filters or orders the sequence, and to none that do not (never `limit`), with the binding declared in `list-pagination.test.ts`. - [ ] Keyset sorts end in a unique `id` key. - [ ] The list is classified in `list-pagination.test.ts`. - [ ] Cross-tenant access answers 404, never 403 — and carries `Cache-Control: private, no-store`, because RFC 9110 §15.5.5 makes 404 heuristically cacheable and an authorization-dependent 404 must never be stored. `v2Error` sets this unconditionally; do not build a v2 response any other way. diff --git a/apps/docs/openapi-v2-billing.json b/apps/docs/openapi-v2-billing.json index dd5eac4314f..809de2b242c 100644 --- a/apps/docs/openapi-v2-billing.json +++ b/apps/docs/openapi-v2-billing.json @@ -187,9 +187,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", "schema": { - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", "type": "string", "minLength": 1 } diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 072d126f106..35a7e68ad82 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -129,9 +129,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", "schema": { - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", "type": "string", "minLength": 1 } @@ -1119,9 +1119,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", "schema": { - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", "type": "string", "minLength": 1 } diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index 6a2769f45a5..3fa91cd12bf 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -113,9 +113,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", "schema": { - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", "type": "string", "minLength": 1 } @@ -717,9 +717,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", "schema": { - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", "type": "string", "minLength": 1 } diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index 5c4291c7fc6..532eafbcc6c 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -204,9 +204,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", "schema": { - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", "type": "string", "minLength": 1 } diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 04109520c3f..7cae1f9e8ed 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -152,9 +152,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", "schema": { - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", "type": "string", "minLength": 1 } @@ -277,9 +277,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", "schema": { - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", "type": "string", "minLength": 1 } @@ -785,9 +785,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", "schema": { - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", "type": "string", "minLength": 1 } @@ -1207,9 +1207,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", "schema": { - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", "type": "string", "minLength": 1 } @@ -1651,9 +1651,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", "schema": { - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", "type": "string", "minLength": 1 } @@ -1787,9 +1787,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", "schema": { - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", "type": "string", "minLength": 1 } diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 5ec3f9e398d..f8e43d1e5b9 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -113,9 +113,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", "schema": { - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", "type": "string", "minLength": 1 } diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index ff7ca7c2401..d8990af9b7e 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -91,9 +91,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", "schema": { - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", "type": "string", "minLength": 1 } @@ -510,9 +510,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", "schema": { - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", "type": "string", "minLength": 1 } @@ -1321,9 +1321,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", "schema": { - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", "type": "string", "minLength": 1 } diff --git a/apps/sim/app/api/v2/audit-logs/route.test.ts b/apps/sim/app/api/v2/audit-logs/route.test.ts index ec46e6fe9d6..0932526878c 100644 --- a/apps/sim/app/api/v2/audit-logs/route.test.ts +++ b/apps/sim/app/api/v2/audit-logs/route.test.ts @@ -84,7 +84,12 @@ describe('v2 audit-log routes', () => { const response = await listLogs(request) expect(response.status).toBe(200) - expect(await response.json()).toMatchObject({ data: [{ id: 'audit-1' }], nextCursor: 'next-1' }) + const body = await response.json() + expect(body).toMatchObject({ data: [{ id: 'audit-1' }] }) + /** The domain token travels inside the query-bound wrapper, not bare. */ + expect(JSON.parse(Buffer.from(body.nextCursor, 'base64').toString())).toMatchObject({ + inner: 'next-1', + }) expect(mocks.list).toHaveBeenCalledWith({ principal: auth.principal, input: expect.objectContaining({ diff --git a/apps/sim/app/api/v2/audit-logs/route.ts b/apps/sim/app/api/v2/audit-logs/route.ts index b9bc8743819..73a66fb58e0 100644 --- a/apps/sim/app/api/v2/audit-logs/route.ts +++ b/apps/sim/app/api/v2/audit-logs/route.ts @@ -8,6 +8,32 @@ import { import { listAuditLogs } from '@/lib/audit-logs/application/list-audit-logs' import { auditLogOperations } from '@/lib/audit-logs/application/operations' import { formatV2AuditLogEntry } from '@/app/api/v2/audit-logs/format' +import { cursorFilterScope, encodeScopedCursor, readScopedCursor } from '@/app/api/v2/lib/response' + +/** Every param that changes which audit entries, in which order, this list returns. */ +function auditLogCursorFilters(query: { + organizationId: string + includeDeparted: boolean + action?: string + resourceType?: string + resourceId?: string + workspaceId?: string + actorEmail?: string + startDate?: string + endDate?: string +}) { + return cursorFilterScope({ + organizationId: query.organizationId, + includeDeparted: query.includeDeparted, + action: query.action, + resourceType: query.resourceType, + resourceId: query.resourceId, + workspaceId: query.workspaceId, + actorEmail: query.actorEmail, + startDate: query.startDate, + endDate: query.endDate, + }) +} export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -38,11 +64,11 @@ export const GET = defineV2JsonRoute({ endDate: query.endDate, }, limit: query.limit, - cursor: query.cursor, + cursor: readScopedCursor(query.cursor, auditLogCursorFilters(query)), }), useCase: listAuditLogs, - present: ({ data, nextCursor }) => ({ + present: ({ data, nextCursor }, { query }) => ({ data: data.map(formatV2AuditLogEntry), - nextCursor: nextCursor ?? null, + nextCursor: nextCursor ? encodeScopedCursor(auditLogCursorFilters(query), nextCursor) : null, }), }) diff --git a/apps/sim/app/api/v2/billing/logs/route.test.ts b/apps/sim/app/api/v2/billing/logs/route.test.ts index b323e834a83..1ec3afff99b 100644 --- a/apps/sim/app/api/v2/billing/logs/route.test.ts +++ b/apps/sim/app/api/v2/billing/logs/route.test.ts @@ -27,6 +27,15 @@ vi.mock('@/lib/billing/application/list-billing-logs', () => ({ import { UNKNOWN_CURSOR_MESSAGE } from '@/lib/billing/core/usage-log' import { OrchestrationError } from '@/lib/core/orchestration/types' import { GET } from '@/app/api/v2/billing/logs/route' +import { cursorFilterScope, encodeScopedCursor } from '@/app/api/v2/lib/response' + +/** A ledger cursor exactly as the route mints one, for the filters given. */ +function ledgerCursor( + inner: string, + filters: { source?: string; workspaceId?: string; period?: string } +): string { + return encodeScopedCursor(cursorFilterScope(filters), inner) +} const auth = { principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' }, @@ -101,9 +110,12 @@ describe('GET /api/v2/billing/logs', () => { mocks.execute.mockRejectedValueOnce( new OrchestrationError('validation', UNKNOWN_CURSOR_MESSAGE) ) + const cursor = ledgerCursor('log-from-another-ledger', { period: '30d' }) const response = await GET( - new NextRequest('http://localhost:3000/api/v2/billing/logs?cursor=log-from-another-ledger') + new NextRequest( + `http://localhost:3000/api/v2/billing/logs?cursor=${encodeURIComponent(cursor)}` + ) ) expect(response.status).toBe(400) @@ -112,6 +124,28 @@ describe('GET /api/v2/billing/logs', () => { }) }) + /** + * The ledger cursor is a usage-event id, so it names a row rather than an + * ordinal — but which rows follow it depends entirely on the window and source + * filters, so replaying one across a changed filter walks a different ledger + * and never reaches the entries the caller narrowed to. + */ + it('rejects a cursor replayed under a different filter without reaching the ledger', async () => { + const cursor = ledgerCursor('usage-1', { period: '30d' }) + + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/billing/logs?source=workflow&cursor=${encodeURIComponent(cursor)}` + ) + ) + + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ + error: { code: 'BAD_REQUEST', message: expect.stringContaining('requested filters') }, + }) + expect(mocks.execute).not.toHaveBeenCalled() + }) + it('authenticates before rejecting invalid custom ranges', async () => { const response = await GET( new NextRequest('http://localhost:3000/api/v2/billing/logs?period=custom') diff --git a/apps/sim/app/api/v2/billing/logs/route.ts b/apps/sim/app/api/v2/billing/logs/route.ts index 65c6692adf5..26f098acf33 100644 --- a/apps/sim/app/api/v2/billing/logs/route.ts +++ b/apps/sim/app/api/v2/billing/logs/route.ts @@ -5,10 +5,37 @@ import { listBillingLogs } from '@/lib/billing/application/list-billing-logs' import { billingOperations } from '@/lib/billing/application/operations' import { toBillingUsageLogSource, toInternalUsageLogSources } from '@/lib/billing/usage-sources' import { resolveDateRange } from '@/app/api/users/me/usage-logs/shared' +import { cursorFilterScope, encodeScopedCursor, readScopedCursor } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 +/** + * Every param that changes which ledger entries, in which order, this list + * returns. + * + * The raw params are stamped, not the range `resolveDateRange` derives from + * them: a relative `period` resolves against the clock, so hashing the resolved + * window would produce a different stamp on every request and reject each next + * page. `period=30d` and an explicit custom range covering the same days are + * therefore two scopes, which is right — one is a moving window. + */ +function billingLogCursorFilters(query: { + source?: string + workspaceId?: string + period?: string + startDate?: string + endDate?: string +}) { + return cursorFilterScope({ + source: query.source, + workspaceId: query.workspaceId, + period: query.period, + startDate: query.startDate, + endDate: query.endDate, + }) +} + /** Cursor-paged, credit-denominated billing ledger. */ export const GET = defineV2JsonRoute({ contract: v2ListBillingLogsContract, @@ -24,11 +51,11 @@ export const GET = defineV2JsonRoute({ startDate: dateRange.startDate, endDate: dateRange.endDate, limit: query.limit, - cursor: query.cursor, + cursor: readScopedCursor(query.cursor, billingLogCursorFilters(query)), } }, useCase: listBillingLogs, - present: ({ usage, creditsByLogId }) => ({ + present: ({ usage, creditsByLogId }, { query }) => ({ data: usage.logs.map((log) => ({ id: log.id, createdAt: log.createdAt, @@ -38,6 +65,9 @@ export const GET = defineV2JsonRoute({ runId: log.executionId ?? null, creditCost: creditsByLogId[log.id] ?? 0, })), - nextCursor: usage.pagination.hasMore ? (usage.pagination.nextCursor ?? null) : null, + nextCursor: + usage.pagination.hasMore && usage.pagination.nextCursor + ? encodeScopedCursor(billingLogCursorFilters(query), usage.pagination.nextCursor) + : null, }), }) diff --git a/apps/sim/app/api/v2/credentials/route.ts b/apps/sim/app/api/v2/credentials/route.ts index 057ab1012be..6e6f80552fd 100644 --- a/apps/sim/app/api/v2/credentials/route.ts +++ b/apps/sim/app/api/v2/credentials/route.ts @@ -8,11 +8,31 @@ import { import { listWorkspaceCredentials } from '@/lib/credentials/application/list-workspace-credentials' import { credentialOperations } from '@/lib/credentials/application/operations' import { toV2Credential } from '@/app/api/v2/credentials/utils' -import { cursorSortKey, encodeSortedCursor, readSortedCursor } from '@/app/api/v2/lib/response' +import { + cursorFilterScope, + cursorSortKey, + encodeSortedCursor, + readSortedCursor, +} from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 +/** Every param that changes which credentials, in which order, this list returns. */ +function credentialCursorFilters(query: { + workspaceId: string + type?: string + providerId?: string + search?: string +}) { + return cursorFilterScope({ + workspaceId: query.workspaceId, + type: query.type, + providerId: query.providerId, + search: query.search, + }) +} + /** GET /api/v2/credentials — List the credentials the caller can see in a workspace. */ export const GET = defineV2JsonRoute({ contract: v2ListCredentialsContract, @@ -22,13 +42,22 @@ export const GET = defineV2JsonRoute({ errorPolicy: v2OrchestrationErrorPolicy, mapInput: ({ query }) => ({ ...query, - cursorKeys: readSortedCursor(query.cursor, query.sortBy, query.sortOrder), + cursorKeys: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + credentialCursorFilters(query) + ), }), useCase: listWorkspaceCredentials, - present: ({ credentials, nextCursorKeys, sortBy, sortOrder }) => ({ + present: ({ credentials, nextCursorKeys }, { query }) => ({ data: credentials.map(toV2Credential), nextCursor: nextCursorKeys - ? encodeSortedCursor(cursorSortKey(sortBy, sortOrder), nextCursorKeys) + ? encodeSortedCursor( + cursorSortKey(query.sortBy, query.sortOrder), + nextCursorKeys, + credentialCursorFilters(query) + ) : null, }), }) diff --git a/apps/sim/app/api/v2/custom-tools/route.ts b/apps/sim/app/api/v2/custom-tools/route.ts index 88d691efd3a..546e0de9e93 100644 --- a/apps/sim/app/api/v2/custom-tools/route.ts +++ b/apps/sim/app/api/v2/custom-tools/route.ts @@ -14,11 +14,24 @@ import { listWorkspaceCustomToolsUseCase, } from '@/lib/custom-tools/application/use-cases' import { toV2CustomTool } from '@/app/api/v2/custom-tools/utils' -import { cursorSortKey, encodeSortedCursor, readSortedCursor } from '@/app/api/v2/lib/response' +import { + cursorFilterScope, + cursorSortKey, + encodeSortedCursor, + readSortedCursor, +} from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 +/** Every param that changes which custom tools, in which order, this list returns. */ +function customToolCursorFilters(query: { workspaceId: string; search?: string }) { + return cursorFilterScope({ + workspaceId: query.workspaceId, + search: query.search, + }) +} + /** GET /api/v2/custom-tools — List custom tools in a workspace. */ export const GET = defineV2JsonRoute({ contract: v2ListCustomToolsContract, @@ -28,13 +41,22 @@ export const GET = defineV2JsonRoute({ errorPolicy: v2OrchestrationErrorPolicy, mapInput: ({ query }) => ({ ...query, - cursorKeys: readSortedCursor(query.cursor, query.sortBy, query.sortOrder), + cursorKeys: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + customToolCursorFilters(query) + ), }), useCase: listWorkspaceCustomToolsUseCase, - present: ({ tools, nextCursorKeys, sortBy, sortOrder }) => ({ + present: ({ tools, nextCursorKeys }, { query }) => ({ data: tools.map(toV2CustomTool), nextCursor: nextCursorKeys - ? encodeSortedCursor(cursorSortKey(sortBy, sortOrder), nextCursorKeys) + ? encodeSortedCursor( + cursorSortKey(query.sortBy, query.sortOrder), + nextCursorKeys, + customToolCursorFilters(query) + ) : null, }), }) diff --git a/apps/sim/app/api/v2/files/route.test.ts b/apps/sim/app/api/v2/files/route.test.ts index 242bfbf5aa4..b646c18d87b 100644 --- a/apps/sim/app/api/v2/files/route.test.ts +++ b/apps/sim/app/api/v2/files/route.test.ts @@ -90,7 +90,6 @@ describe('/api/v2/files', () => { mocks.queryFiles.mockResolvedValue({ files: [FILE], nextKeys: undefined, - cursorSort: 'name:asc', }) mocks.createFile.mockResolvedValue({ file: FILE }) mocks.getUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) @@ -157,7 +156,6 @@ describe('/api/v2/files', () => { mocks.queryFiles.mockResolvedValueOnce({ files: [{ ...FILE, deletedAt: new Date('2026-08-06T00:00:00.000Z') }], nextKeys: undefined, - cursorSort: 'uploadedAt:asc', }) const response = await GET( new NextRequest( @@ -187,7 +185,6 @@ describe('/api/v2/files', () => { mocks.queryFiles.mockResolvedValueOnce({ files: [{ ...FILE, folderId: 'folder-1', folderPath: 'Finance\\/Legal' }], nextKeys: undefined, - cursorSort: 'name:asc', }) const response = await GET( new NextRequest(`http://localhost:3000/api/v2/files?workspaceId=${WORKSPACE_ID}`) @@ -197,6 +194,63 @@ describe('/api/v2/files', () => { expect((await response.json()).data[0].folderPath).toBe('/Finance%2FLegal') }) + /** + * A keyset cursor stays *coherent* under a changed filter, which is what makes + * it dangerous: replaying it under a narrowed `search` returns a correctly + * ordered page of the new matches that happen to sort after the old position, + * and silently omits every match before it. The caller sees an opaque token + * and a short page, and reads that as "almost nothing matched". + */ + it.each([ + ['search', 'search=quarterly'], + ['scope', 'scope=archived'], + ['folderPath', 'folderPath=/Finance'], + ])('refuses a cursor replayed under a different %s', async (_filter, param) => { + mocks.queryFiles.mockResolvedValueOnce({ files: [FILE], nextKeys: ['notes.md', FILE.id] }) + const firstPage = await ( + await GET(new NextRequest(`http://localhost:3000/api/v2/files?workspaceId=${WORKSPACE_ID}`)) + ).json() + expect(firstPage.nextCursor).toEqual(expect.any(String)) + mocks.queryFiles.mockClear() + + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/files?workspaceId=${WORKSPACE_ID}&${param}&cursor=${encodeURIComponent(firstPage.nextCursor)}` + ) + ) + + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ + error: { code: 'BAD_REQUEST', message: expect.stringContaining('requested filters') }, + }) + expect(mocks.queryFiles).not.toHaveBeenCalled() + }) + + /** + * `limit` is not part of the binding: it selects how much of the sequence to + * return, not what the sequence is. + */ + it('resumes a cursor under an unchanged filter and a changed page size', async () => { + mocks.queryFiles.mockResolvedValueOnce({ files: [FILE], nextKeys: ['notes.md', FILE.id] }) + const firstPage = await ( + await GET(new NextRequest(`http://localhost:3000/api/v2/files?workspaceId=${WORKSPACE_ID}`)) + ).json() + mocks.queryFiles.mockResolvedValueOnce({ files: [FILE], nextKeys: undefined }) + + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/files?workspaceId=${WORKSPACE_ID}&limit=5&cursor=${encodeURIComponent(firstPage.nextCursor)}` + ) + ) + + expect(response.status).toBe(200) + expect(mocks.queryFiles).toHaveBeenLastCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ limit: 5, after: ['notes.md', FILE.id] }), + }) + ) + }) + it('rejects malformed cursors before the application service', async () => { const response = await GET( new NextRequest( diff --git a/apps/sim/app/api/v2/files/route.ts b/apps/sim/app/api/v2/files/route.ts index 8a5a20ceee7..f91711e248d 100644 --- a/apps/sim/app/api/v2/files/route.ts +++ b/apps/sim/app/api/v2/files/route.ts @@ -11,11 +11,31 @@ import { queryWorkspaceFilePage } from '@/lib/workspace-files/application/list-w import { fileOperations } from '@/lib/workspace-files/application/operations' import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES } from '@/lib/workspace-files/orchestration' import { toV2File, toV2Files } from '@/app/api/v2/files/utils' -import { cursorSortKey, encodeSortedCursor, readSortedCursor } from '@/app/api/v2/lib/response' +import { + cursorFilterScope, + cursorSortKey, + encodeSortedCursor, + readSortedCursor, +} from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 +/** Every param that changes which files, in which order, this list returns. */ +function fileCursorFilters(query: { + workspaceId: string + scope?: string + folderPath?: string + search?: string +}) { + return cursorFilterScope({ + workspaceId: query.workspaceId, + scope: query.scope, + folderPath: query.folderPath, + search: query.search, + }) +} + /** GET /api/v2/files — List files with search, sort, and cursor pagination. */ export const GET = defineV2JsonRoute({ contract: v2ListFilesContract, @@ -31,13 +51,21 @@ export const GET = defineV2JsonRoute({ sortBy: query.sortBy, sortOrder: query.sortOrder, limit: query.limit, - after: readSortedCursor(query.cursor, query.sortBy, query.sortOrder), - cursorSort: cursorSortKey(query.sortBy, query.sortOrder), + after: readSortedCursor(query.cursor, query.sortBy, query.sortOrder, fileCursorFilters(query)), }), useCase: queryWorkspaceFilePage, - present: async ({ files, nextKeys, cursorSort }) => { + present: async ({ files, nextKeys }, { query }) => { const items: V2File[] = await toV2Files(files) - return { data: items, nextCursor: nextKeys ? encodeSortedCursor(cursorSort, nextKeys) : null } + return { + data: items, + nextCursor: nextKeys + ? encodeSortedCursor( + cursorSortKey(query.sortBy, query.sortOrder), + nextKeys, + fileCursorFilters(query) + ) + : null, + } }, }) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/collection.test.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/collection.test.ts index b199cbc898e..6f018f33d1b 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/collection.test.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/collection.test.ts @@ -117,7 +117,6 @@ describe('GET /api/v2/knowledge/[id]/documents', () => { documents: [DOCUMENT], tagDefinitions: TAG_DEFINITIONS, pagination: { total: 1, limit: 50, offset: 0, hasMore: false }, - cursorScope: 'scope', workspaceId: WORKSPACE_ID, }) }) @@ -151,19 +150,37 @@ describe('GET /api/v2/knowledge/[id]/documents', () => { ) }) - it('stamps the tag filters into the cursor scope so a replayed cursor cannot cross filters', async () => { + it('stamps the tag filters into the cursor so a replayed cursor cannot cross filters', async () => { const tagFilters = JSON.stringify([{ tagName: 'category', operator: 'eq', value: 'billing' }]) + mockListDocuments.mockResolvedValue({ + documents: [DOCUMENT], + tagDefinitions: TAG_DEFINITIONS, + pagination: { total: 4, limit: 2, offset: 0, hasMore: true }, + workspaceId: WORKSPACE_ID, + }) - await GET(buildListRequest(`?workspaceId=${WORKSPACE_ID}`), context) - await GET( - buildListRequest(`?workspaceId=${WORKSPACE_ID}&tagFilters=${encodeURIComponent(tagFilters)}`), + const unfiltered = await ( + await GET(buildListRequest(`?workspaceId=${WORKSPACE_ID}`), context) + ).json() + const filtered = await ( + await GET( + buildListRequest( + `?workspaceId=${WORKSPACE_ID}&tagFilters=${encodeURIComponent(tagFilters)}` + ), + context + ) + ).json() + + expect(unfiltered.nextCursor).not.toEqual(filtered.nextCursor) + + const replayed = await GET( + buildListRequest( + `?workspaceId=${WORKSPACE_ID}&tagFilters=${encodeURIComponent(tagFilters)}&cursor=${encodeURIComponent(unfiltered.nextCursor)}` + ), context ) - const [unfiltered, filtered] = mockListDocuments.mock.calls.map( - ([call]) => call.input.cursorScope - ) - expect(unfiltered).not.toEqual(filtered) + expect(replayed.status).toBe(400) }) it('rejects malformed and wrongly shaped tag filters with a 400', async () => { diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts index 1f11166b8f8..d41d7a5b719 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts @@ -32,9 +32,10 @@ import { MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE } from '@/lib/uploads/shared/types' import { validateFileType } from '@/lib/uploads/utils/validation' import { toV2DocumentSummary, toV2TaggedDocument } from '@/app/api/v2/knowledge/utils' import { + cursorFilterScope, + cursorSortKey, decodeOffsetCursor, encodeOffsetCursor, - offsetCursorScope, } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' @@ -42,6 +43,20 @@ export const revalidate = 0 const MAX_FILE_SIZE = MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE +/** Every param that changes which documents, in which order, this list returns. */ +function documentCursorFilters( + knowledgeBaseId: string, + query: { workspaceId: string; enabledFilter?: string; search?: string; tagFilters?: string } +) { + return cursorFilterScope({ + knowledgeBaseId, + workspaceId: query.workspaceId, + enabledFilter: query.enabledFilter, + search: query.search, + tagFilters: query.tagFilters, + }) +} + /** GET /api/v2/knowledge/[id]/documents — List documents in a knowledge base. */ export const GET = defineV2JsonRoute({ contract: v2ListKnowledgeDocumentsContract, @@ -54,38 +69,31 @@ export const GET = defineV2JsonRoute({ if (!tagFilters.success) { throw new OrchestrationError('validation', tagFilters.message) } - /** - * The offset counts positions in the filtered, sorted document sequence, so - * every param that changes that sequence is stamped into the cursor and - * re-checked here. `limit` selects how much of the sequence to return, not - * what the sequence is, so it stays out. - */ - const cursorScope = offsetCursorScope({ - knowledgeBaseId: params.id, - enabledFilter: query.enabledFilter, - search: query.search, - sortBy: query.sortBy, - sortOrder: query.sortOrder, - tagFilters: query.tagFilters, - }) return { knowledgeBaseId: params.id, assertedWorkspaceId: query.workspaceId, enabledFilter: query.enabledFilter, search: query.search, limit: query.limit, - offset: decodeOffsetCursor(query.cursor, cursorScope), + offset: decodeOffsetCursor( + query.cursor, + cursorSortKey(query.sortBy, query.sortOrder), + documentCursorFilters(params.id, query) + ), sortBy: query.sortBy, sortOrder: query.sortOrder, tagNameFilters: tagFilters.filters, - cursorScope, } }, useCase: listKnowledgeDocuments, - present: ({ documents, tagDefinitions, pagination, cursorScope }) => ({ + present: ({ documents, tagDefinitions, pagination }, { params, query }) => ({ data: documents.map((document) => toV2TaggedDocument(document, tagDefinitions)), nextCursor: pagination.hasMore - ? encodeOffsetCursor(cursorScope ?? '', pagination.offset + pagination.limit) + ? encodeOffsetCursor( + cursorSortKey(query.sortBy, query.sortOrder), + documentCursorFilters(params.id, query), + pagination.offset + pagination.limit + ) : null, }), }) diff --git a/apps/sim/app/api/v2/knowledge/route.ts b/apps/sim/app/api/v2/knowledge/route.ts index 789db35d934..830142f961b 100644 --- a/apps/sim/app/api/v2/knowledge/route.ts +++ b/apps/sim/app/api/v2/knowledge/route.ts @@ -16,11 +16,29 @@ import { import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { captureServerEvent } from '@/lib/posthog/server' import { toV2KnowledgeBase, toV2KnowledgeBases } from '@/app/api/v2/knowledge/utils' -import { cursorSortKey, encodeSortedCursor, readSortedCursor } from '@/app/api/v2/lib/response' +import { + cursorFilterScope, + cursorSortKey, + encodeSortedCursor, + readSortedCursor, +} from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 +/** Every param that changes which knowledge bases, in which order, this list returns. */ +function knowledgeCursorFilters(query: { + workspaceId: string + folderPath?: string + search?: string +}) { + return cursorFilterScope({ + workspaceId: query.workspaceId, + folderPath: query.folderPath, + search: query.search, + }) +} + /** GET /api/v2/knowledge — List knowledge bases in a workspace. */ export const GET = defineV2JsonRoute({ contract: v2ListKnowledgeBasesContract, @@ -35,13 +53,22 @@ export const GET = defineV2JsonRoute({ sortBy: query.sortBy, sortOrder: query.sortOrder, limit: query.limit, - cursorKeys: readSortedCursor(query.cursor, query.sortBy, query.sortOrder), + cursorKeys: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + knowledgeCursorFilters(query) + ), }), useCase: listKnowledgeBases, - present: async ({ knowledgeBases, nextCursorKeys, sortBy, sortOrder }) => ({ + present: async ({ knowledgeBases, nextCursorKeys }, { query }) => ({ data: await toV2KnowledgeBases(knowledgeBases), nextCursor: nextCursorKeys - ? encodeSortedCursor(cursorSortKey(sortBy, sortOrder), nextCursorKeys) + ? encodeSortedCursor( + cursorSortKey(query.sortBy, query.sortOrder), + nextCursorKeys, + knowledgeCursorFilters(query) + ) : null, }), }) diff --git a/apps/sim/app/api/v2/lib/response.ts b/apps/sim/app/api/v2/lib/response.ts index 8228a8ee3d5..3939b7e004d 100644 --- a/apps/sim/app/api/v2/lib/response.ts +++ b/apps/sim/app/api/v2/lib/response.ts @@ -1,5 +1,10 @@ import { NextResponse } from 'next/server' import type { ZodError } from 'zod' +import { + type CursorScopePart, + cursorScopeKey, + REFILTERED_CURSOR_MESSAGE, +} from '@/lib/api/cursor-binding' import { type CursorKey, INVALID_CURSOR_MESSAGE } from '@/lib/api/list-query' import { getValidationErrorMessage, serializeZodIssues } from '@/lib/api/server' import { ADMISSION_RETRY_AFTER_SECONDS } from '@/lib/core/admission/transient-failure' @@ -251,54 +256,74 @@ export function decodeCursor>(cursor: string): T | n } interface OffsetCursorPayload { - /** The query state the offset counts positions within. */ - scope: string + /** The ordering the offset counts positions within. */ + sort: string + /** Fingerprint of the filters the offset counts positions within. */ + filter?: string offset: number } /** - * The filters and sort an offset cursor was minted under. + * The filters a v2 cursor was minted under, as one fingerprint. * - * An offset is only meaningful against one exact sequence, so everything that - * reorders or re-filters that sequence has to travel with it. Build the stamp - * from every such param; a value that does not affect ordering or membership - * (the page size itself) must stay out, or paging with a different `limit` - * would be rejected for no reason. + * A cursor is only meaningful against one exact sequence, so everything that + * re-filters that sequence has to travel with it. Build the stamp from every + * such param; a value that does not affect membership or ordering — the page + * size, or a param that only shapes the response body — must stay out, or + * paging with a different `limit` would be rejected for no reason. The sort + * travels separately, as {@link cursorSortKey}, so a mismatch can name which + * half of the query changed. + * + * Shared with the table-row codec through {@link cursorScopeKey}, so a filter + * stamp is one format across every paginated surface rather than one per list. + * `undefined` is a list read with no filters applied at all. */ -export function offsetCursorScope(parts: Record): string { - return Object.keys(parts) - .sort() - .map((key) => `${key}=${parts[key] ?? ''}`) - .join('&') +export function cursorFilterScope(parts: Record): string | undefined { + return cursorScopeKey(parts) } -/** An offset cursor stamped with the query state that produced it. */ -export function encodeOffsetCursor(scope: string, offset: number): string { - return encodeCursor({ scope, offset } satisfies OffsetCursorPayload) +/** An offset cursor stamped with the sort and filters that produced it. */ +export function encodeOffsetCursor( + sort: string, + filter: string | undefined, + offset: number +): string { + return encodeCursor({ + sort, + ...(filter ? { filter } : {}), + offset, + } satisfies OffsetCursorPayload) } /** - * Reads back an offset cursor, refusing one minted under different filters or a - * different sort. + * Reads back an offset cursor, refusing one minted under a different sort or + * different filters. * * An absent cursor means page one. A cursor that is not valid base64-JSON, or * that does not carry a non-negative integer `offset`, is rejected rather than * coerced to 0: silently restarting at page one while the caller believes it is * paging forward makes a paging client loop over the first page forever. * - * The `scope` check is the offset counterpart of {@link decodeSortedCursor}'s - * sort stamp. A bare offset replayed against a newly filtered or re-sorted - * sequence names a different position in it, which silently skips rows, repeats - * them, or lands past the end and returns an empty page — the failure a keyset - * cursor is already protected from. The v2 error policies render the thrown - * validation error as the canonical 400. + * An offset is the weaker of the two schemes here: unlike a keyset it names an + * ordinal, not a position, so replaying it against a re-filtered or re-sorted + * sequence lands at an unrelated point in it — skipping rows, repeating them, or + * landing past the end and returning an empty page the caller reads as "no more + * matches". The v2 error policies render the thrown validation error as the + * canonical 400. */ -export function decodeOffsetCursor(cursor: string | undefined, scope: string): number { +export function decodeOffsetCursor( + cursor: string | undefined, + sort: string, + filter?: string | undefined +): number { if (!cursor) return 0 const decoded = decodeCursor>(cursor) - if (!decoded || decoded.scope !== scope) { + if (!decoded || decoded.sort !== sort) { throw new OrchestrationError('validation', INVALID_CURSOR_MESSAGE) } + if ((decoded.filter ?? undefined) !== (filter || undefined)) { + throw new OrchestrationError('validation', REFILTERED_CURSOR_MESSAGE) + } const { offset } = decoded if (typeof offset !== 'number' || !Number.isInteger(offset) || offset < 0) { throw new OrchestrationError('validation', 'Invalid cursor') @@ -318,14 +343,21 @@ export function cursorSortKey(sortBy: string, sortOrder: string): string { interface SortedCursorPayload { sort: string keys: CursorKey[] + /** Fingerprint of the filters the page was read under; absent = unfiltered. */ + filter?: string } /** - * A keyset cursor stamped with the sort that produced it. The keys are only - * meaningful under that exact ordering, so the stamp travels with them. + * A keyset cursor stamped with the sort AND the filters that produced it. The + * keys are only meaningful under that exact ordering, and only name a useful + * position within that exact row set, so both stamps travel with them. */ -export function encodeSortedCursor(sort: string, keys: CursorKey[]): string { - return encodeCursor({ sort, keys } satisfies SortedCursorPayload) +export function encodeSortedCursor( + sort: string, + keys: CursorKey[], + filter?: string | undefined +): string { + return encodeCursor({ sort, keys, ...(filter ? { filter } : {}) } satisfies SortedCursorPayload) } export type DecodedSortedCursor = @@ -333,25 +365,45 @@ export type DecodedSortedCursor = | { status: 'ok'; keys: CursorKey[] } /** Malformed, or minted under a different sort — the page cannot be resumed. */ | { status: 'invalid' } + /** Minted under different filters — the position names another sequence. */ + | { status: 'refiltered' } /** * Reads a keyset cursor back, refusing one that does not belong to the - * requested sort. Resuming a `name`-ordered cursor under `createdAt` would - * compare the wrong column and silently duplicate or skip rows, so a mismatch - * is a client error rather than a best-effort page. A cursor that isn't valid - * base64-JSON is rejected for the same reason: ignoring it would restart from - * page one while the caller believes it is paging forward. + * requested query. + * + * Resuming a `name`-ordered cursor under `createdAt` would compare the wrong + * column and silently duplicate or skip rows, so a sort mismatch is a client + * error rather than a best-effort page. A cursor that isn't valid base64-JSON + * is rejected for the same reason: ignoring it would restart from page one + * while the caller believes it is paging forward. + * + * A filter mismatch is rejected too, and it is worth being precise about why, + * because a keyset does not corrupt the way an offset does. `(sortKey, id)` + * names an absolute position, so replaying it under a narrower filter still + * returns a coherent, duplicate-free page — of everything matching the NEW + * filter that happens to sort after that position. Every match before it is + * silently absent. The cursor is documented as opaque, so a caller has no way + * to tell that truncated page from a complete one, and the client that most + * plausibly does this (narrow the search box, keep paging) is exactly the one + * that will believe its filter matched almost nothing. Restarting pagination is + * the only correct response, so the API says so instead of guessing. * * This checks the envelope only. The key VALUES are caller-controlled too, and * are type-checked against the sort's keys by `keysetAfter`, which is where a * bad arity or an unparseable timestamp is caught. */ -export function decodeSortedCursor(cursor: string | undefined, sort: string): DecodedSortedCursor { +export function decodeSortedCursor( + cursor: string | undefined, + sort: string, + filter?: string | undefined +): DecodedSortedCursor { if (!cursor) return { status: 'absent' } const decoded = decodeCursor>(cursor) if (!decoded || decoded.sort !== sort || !Array.isArray(decoded.keys)) { return { status: 'invalid' } } + if ((decoded.filter ?? undefined) !== (filter || undefined)) return { status: 'refiltered' } return { status: 'ok', keys: decoded.keys } } @@ -359,23 +411,71 @@ export function decodeSortedCursor(cursor: string | undefined, sort: string): De * The keyset a paged list should resume from, or `undefined` for page one. * * This is the `mapInput` half of every keyset list: it stamps the request's - * sort, reads the cursor back under it, and turns a cursor that was minted - * under a different sort into the canonical 400 rather than letting mismatched - * keys reach `keysetAfter`. Sharing it is what keeps "a bad cursor is a 400" - * from being re-decided per route. + * sort and filters, reads the cursor back under them, and turns a cursor minted + * under a different query into the canonical 400 rather than letting mismatched + * keys reach `keysetAfter` or a stale position reach a re-filtered read. Sharing + * it is what keeps "a bad cursor is a 400" from being re-decided per route. + * + * Build `filter` with {@link cursorFilterScope} from the same params on both + * sides of the request. A list with no filters at all passes nothing. */ export function readSortedCursor( cursor: string | undefined, sortBy: string, - sortOrder: string + sortOrder: string, + filter?: string | undefined ): CursorKey[] | undefined { - const decoded = decodeSortedCursor(cursor, cursorSortKey(sortBy, sortOrder)) + const decoded = decodeSortedCursor(cursor, cursorSortKey(sortBy, sortOrder), filter) if (decoded.status === 'invalid') { throw new OrchestrationError('validation', INVALID_CURSOR_MESSAGE) } + if (decoded.status === 'refiltered') { + throw new OrchestrationError('validation', REFILTERED_CURSOR_MESSAGE) + } return decoded.status === 'ok' ? decoded.keys : undefined } +interface ScopedCursorPayload { + /** Fingerprint of the filters and sort the inner token was minted under. */ + scope?: string + /** The domain codec's own opaque token, passed through untouched. */ + inner: string +} + +/** + * Binds a cursor minted by a domain codec to the query it was minted under. + * + * `GET /logs`, `GET /audit-logs`, and `GET /billing/logs` page through readers + * that predate the shared v2 codecs and mint their own tokens, so the stamp + * cannot live inside the payload the way it does for {@link encodeSortedCursor}. + * Wrapping keeps the domain token opaque and untouched while still giving those + * lists the same binding as the rest of the surface — one rule for v2 callers + * rather than "some lists notice, some don't". + */ +export function encodeScopedCursor(scope: string | undefined, inner: string): string { + return encodeCursor({ ...(scope ? { scope } : {}), inner } satisfies ScopedCursorPayload) +} + +/** + * Unwraps a {@link encodeScopedCursor} token, yielding the domain codec's own + * cursor, or `undefined` for page one. A token that is malformed or was minted + * under a different query is the canonical 400 — the domain codec never sees it. + */ +export function readScopedCursor( + cursor: string | undefined, + scope: string | undefined +): string | undefined { + if (!cursor) return undefined + const decoded = decodeCursor>(cursor) + if (!decoded || typeof decoded.inner !== 'string') { + throw new OrchestrationError('validation', INVALID_CURSOR_MESSAGE) + } + if ((decoded.scope ?? undefined) !== (scope || undefined)) { + throw new OrchestrationError('validation', REFILTERED_CURSOR_MESSAGE) + } + return decoded.inner +} + const V2_CODE_BY_ORCHESTRATION_ERROR: Record = { validation: 'BAD_REQUEST', unauthorized: 'UNAUTHORIZED', diff --git a/apps/sim/app/api/v2/logs/route.test.ts b/apps/sim/app/api/v2/logs/route.test.ts index 2b6bb177f72..f12a8c624a0 100644 --- a/apps/sim/app/api/v2/logs/route.test.ts +++ b/apps/sim/app/api/v2/logs/route.test.ts @@ -116,6 +116,71 @@ describe('GET /api/v2/logs', () => { expect(body.data[0]).toMatchObject({ runId: 'run-1', status: 'paused' }) }) + /** + * The run-log cursor is minted by the domain codec, so it carries only its own + * `(startedAt, id)` position and the requested order. Binding it to the filters + * is what stops a cursor taken from an unfiltered walk from resuming inside a + * `level=error` read at an unrelated point in that shorter sequence. + */ + it('refuses a cursor replayed under a different filter', async () => { + mocks.execute.mockResolvedValueOnce({ + items: [{ log, executionData: null }], + nextCursor: Buffer.from( + JSON.stringify({ startedAt: log.startedAt.toISOString(), id: 'run-1', order: 'desc' }) + ).toString('base64'), + includeFullDetails: false, + includeFinalOutput: false, + includeTraceSpans: false, + }) + const firstPage = await ( + await GET(new NextRequest(`http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}`)) + ).json() + expect(firstPage.nextCursor).toEqual(expect.any(String)) + mocks.execute.mockClear() + + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&level=error&cursor=${encodeURIComponent(firstPage.nextCursor)}` + ) + ) + + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ + error: { code: 'BAD_REQUEST', message: expect.stringContaining('requested filters') }, + }) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + /** + * These three decide how much of each row is rendered, not which rows are in + * the sequence, so they must stay out of the binding. + */ + it.each([['details=full'], ['includeTraceSpans=true'], ['includeFinalOutput=true']])( + 'resumes a cursor across a changed %s', + async (param) => { + mocks.execute.mockResolvedValueOnce({ + items: [{ log, executionData: null }], + nextCursor: Buffer.from( + JSON.stringify({ startedAt: log.startedAt.toISOString(), id: 'run-1', order: 'desc' }) + ).toString('base64'), + includeFullDetails: false, + includeFinalOutput: false, + includeTraceSpans: false, + }) + const firstPage = await ( + await GET(new NextRequest(`http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}`)) + ).json() + + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&${param}&cursor=${encodeURIComponent(firstPage.nextCursor)}` + ) + ) + + expect(response.status).toBe(200) + } + ) + it('rejects malformed cursors after admission and before protected reads', async () => { const response = await GET( new NextRequest( diff --git a/apps/sim/app/api/v2/logs/route.ts b/apps/sim/app/api/v2/logs/route.ts index ea37bd87e30..c186c40b83e 100644 --- a/apps/sim/app/api/v2/logs/route.ts +++ b/apps/sim/app/api/v2/logs/route.ts @@ -10,10 +10,52 @@ import { v2LogErrorPolicies } from '@/lib/logs/api/route-policies' import { listPublicLogs } from '@/lib/logs/application/list-public-logs' import { logOperations } from '@/lib/logs/application/operations' import { decodePublicLogCursor } from '@/lib/logs/public-queries' +import { cursorFilterScope, encodeScopedCursor, readScopedCursor } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 +/** + * Every param that changes which logs, in which order, this list returns. + * + * `details`, `includeFinalOutput`, and `includeTraceSpans` are deliberately + * absent: they decide how much of each row is rendered, not which rows are in + * the sequence, so a caller may turn them on mid-walk. + */ +function logCursorFilters(query: { + workspaceId: string + workflowIds?: string + triggers?: string + level?: string + startDate?: string + endDate?: string + runId?: string + minDurationMs?: number + maxDurationMs?: number + minCost?: number + maxCost?: number + model?: string + folderPaths?: string + order?: string +}) { + return cursorFilterScope({ + workspaceId: query.workspaceId, + workflowIds: query.workflowIds, + triggers: query.triggers, + level: query.level, + startDate: query.startDate, + endDate: query.endDate, + runId: query.runId, + minDurationMs: query.minDurationMs, + maxDurationMs: query.maxDurationMs, + minCost: query.minCost, + maxCost: query.maxCost, + model: query.model, + folderPaths: query.folderPaths, + order: query.order, + }) +} + export const GET = defineV2JsonRoute({ contract: v2ListLogsContract, auth: v2ApiKeyAuth, @@ -21,10 +63,9 @@ export const GET = defineV2JsonRoute({ rateLimit: v2RateLimits.publicApi, errorPolicy: v2LogErrorPolicies.default, mapInput: ({ query }) => { - const decodedCursor = query.cursor - ? decodePublicLogCursor(query.cursor, query.order ?? 'desc') - : null - if (query.cursor && !decodedCursor) { + const inner = readScopedCursor(query.cursor, logCursorFilters(query)) + const decodedCursor = inner ? decodePublicLogCursor(inner, query.order ?? 'desc') : null + if (inner && !decodedCursor) { throw new OrchestrationError('validation', 'Invalid cursor') } return { @@ -53,7 +94,10 @@ export const GET = defineV2JsonRoute({ } }, useCase: listPublicLogs, - present: ({ items, nextCursor, includeFullDetails, includeFinalOutput, includeTraceSpans }) => ({ + present: ( + { items, nextCursor, includeFullDetails, includeFinalOutput, includeTraceSpans }, + { query } + ) => ({ data: items.map(({ log, executionData }): V2LogListItem => { const item: V2LogListItem = { runId: log.executionId, @@ -86,6 +130,6 @@ export const GET = defineV2JsonRoute({ } return item }), - nextCursor, + nextCursor: nextCursor ? encodeScopedCursor(logCursorFilters(query), nextCursor) : null, }), }) diff --git a/apps/sim/app/api/v2/mcp-servers/route.ts b/apps/sim/app/api/v2/mcp-servers/route.ts index 53949d93738..2d11561972c 100644 --- a/apps/sim/app/api/v2/mcp-servers/route.ts +++ b/apps/sim/app/api/v2/mcp-servers/route.ts @@ -11,12 +11,25 @@ import { import { mcpServerOperations } from '@/lib/mcp/application/operations' import { createMcpServerUseCase, listMcpServersUseCase } from '@/lib/mcp/application/use-cases' import { captureServerEvent } from '@/lib/posthog/server' -import { cursorSortKey, encodeSortedCursor, readSortedCursor } from '@/app/api/v2/lib/response' +import { + cursorFilterScope, + cursorSortKey, + encodeSortedCursor, + readSortedCursor, +} from '@/app/api/v2/lib/response' import { toV2McpServer } from '@/app/api/v2/mcp-servers/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 +/** Every param that changes which MCP servers, in which order, this list returns. */ +function mcpServerCursorFilters(query: { workspaceId: string; search?: string }) { + return cursorFilterScope({ + workspaceId: query.workspaceId, + search: query.search, + }) +} + /** GET /api/v2/mcp-servers — List MCP servers in a workspace. */ export const GET = defineV2JsonRoute({ contract: v2ListMcpServersContract, @@ -30,13 +43,22 @@ export const GET = defineV2JsonRoute({ sortBy: query.sortBy, sortOrder: query.sortOrder, limit: query.limit, - cursorKeys: readSortedCursor(query.cursor, query.sortBy, query.sortOrder), + cursorKeys: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + mcpServerCursorFilters(query) + ), }), useCase: listMcpServersUseCase, - present: ({ servers, nextCursorKeys, sortBy, sortOrder }) => ({ + present: ({ servers, nextCursorKeys }, { query }) => ({ data: servers.map(toV2McpServer), nextCursor: nextCursorKeys - ? encodeSortedCursor(cursorSortKey(sortBy, sortOrder), nextCursorKeys) + ? encodeSortedCursor( + cursorSortKey(query.sortBy, query.sortOrder), + nextCursorKeys, + mcpServerCursorFilters(query) + ) : null, }), }) diff --git a/apps/sim/app/api/v2/secrets/route.ts b/apps/sim/app/api/v2/secrets/route.ts index c0b64d7f338..53fdcacd534 100644 --- a/apps/sim/app/api/v2/secrets/route.ts +++ b/apps/sim/app/api/v2/secrets/route.ts @@ -7,12 +7,26 @@ import { } from '@/lib/api/server/routes' import { secretOperations } from '@/lib/secrets/application/operations' import { listSecretsUseCase } from '@/lib/secrets/application/use-cases' -import { cursorSortKey, encodeSortedCursor, readSortedCursor } from '@/app/api/v2/lib/response' +import { + cursorFilterScope, + cursorSortKey, + encodeSortedCursor, + readSortedCursor, +} from '@/app/api/v2/lib/response' import { toV2Secret } from '@/app/api/v2/secrets/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 +/** Every param that changes which secrets, in which order, this list returns. */ +function secretCursorFilters(query: { workspaceId: string; scope?: string; search?: string }) { + return cursorFilterScope({ + workspaceId: query.workspaceId, + scope: query.scope, + search: query.search, + }) +} + /** GET /api/v2/secrets — List secret names and metadata without reading their values. */ export const GET = defineV2JsonRoute({ contract: v2ListSecretsContract, @@ -22,13 +36,22 @@ export const GET = defineV2JsonRoute({ errorPolicy: v2OrchestrationErrorPolicy, mapInput: ({ query }) => ({ ...query, - cursorKeys: readSortedCursor(query.cursor, query.sortBy, query.sortOrder), + cursorKeys: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + secretCursorFilters(query) + ), }), useCase: listSecretsUseCase, - present: ({ secrets, userId, nextCursorKeys, sortBy, sortOrder }) => ({ + present: ({ secrets, userId, nextCursorKeys }, { query }) => ({ data: secrets.map((secret) => toV2Secret(secret, userId)), nextCursor: nextCursorKeys - ? encodeSortedCursor(cursorSortKey(sortBy, sortOrder), nextCursorKeys) + ? encodeSortedCursor( + cursorSortKey(query.sortBy, query.sortOrder), + nextCursorKeys, + secretCursorFilters(query) + ) : null, }), }) diff --git a/apps/sim/app/api/v2/skills/route.test.ts b/apps/sim/app/api/v2/skills/route.test.ts index c4b30a766f8..ea26ebbebf5 100644 --- a/apps/sim/app/api/v2/skills/route.test.ts +++ b/apps/sim/app/api/v2/skills/route.test.ts @@ -50,20 +50,33 @@ vi.mock('@/lib/skills/application/use-cases', () => ({ createSkillUseCase: { operation: { id: 'skills.create' }, execute: mocks.create }, })) +import { cursorFilterScope, cursorSortKey, encodeOffsetCursor } from '@/app/api/v2/lib/response' import { GET, POST } from '@/app/api/v2/skills/route' const WORKSPACE_ID = 'workspace-1' /** - * The scope stamp the route mints for the default query. Written out rather - * than imported so the test pins the wire format a shipped cursor carries. + * A cursor exactly as this route mints one, built from the shared codec so the + * test exercises the real binding rather than a restatement of it. `search` is + * the only filter the skills list takes beyond its workspace. */ -const SCOPE = ({ - search = '', +function skillCursor({ + offset, + search, sortBy = 'createdAt', sortOrder = 'desc', -}: Record = {}) => - `search=${search}&sortBy=${sortBy}&sortOrder=${sortOrder}&workspaceId=${WORKSPACE_ID}` +}: { + offset: number + search?: string + sortBy?: string + sortOrder?: string +}): string { + return encodeOffsetCursor( + cursorSortKey(sortBy, sortOrder), + cursorFilterScope({ workspaceId: WORKSPACE_ID, search }), + offset + ) +} const PRINCIPAL = { kind: 'workspace_api_key' as const, workspaceId: WORKSPACE_ID, keyId: 'key-1' } const AUTH = { principal: PRINCIPAL, @@ -129,7 +142,6 @@ describe('/api/v2/skills', () => { limit: 50, cursor: undefined, offset: 0, - cursorScope: SCOPE(), }, request: expect.anything(), }) @@ -141,9 +153,8 @@ describe('/api/v2/skills', () => { hasMore: true, offset: 2, limit: 2, - cursorScope: SCOPE(), }) - const cursor = Buffer.from(JSON.stringify({ scope: SCOPE(), offset: 2 })).toString('base64') + const cursor = skillCursor({ offset: 2 }) const response = await GET( request( @@ -153,9 +164,7 @@ describe('/api/v2/skills', () => { ) expect(response.status).toBe(200) - expect((await response.json()).nextCursor).toBe( - Buffer.from(JSON.stringify({ scope: SCOPE(), offset: 4 })).toString('base64') - ) + expect((await response.json()).nextCursor).toBe(skillCursor({ offset: 4 })) expect(mocks.list).toHaveBeenCalledWith( expect.objectContaining({ input: expect.objectContaining({ limit: 2, offset: 2 }) }) ) @@ -166,7 +175,7 @@ describe('/api/v2/skills', () => { * cursor minted under one sort must not silently resume under another. */ it('rejects a cursor replayed under a different sort', async () => { - const cursor = Buffer.from(JSON.stringify({ scope: SCOPE(), offset: 2 })).toString('base64') + const cursor = skillCursor({ offset: 2 }) const response = await GET( request( @@ -188,7 +197,7 @@ describe('/api/v2/skills', () => { ['search', 'search=other'], ['sortOrder', 'sortOrder=asc'], ])('rejects a cursor replayed under a different %s', async (_field, param) => { - const cursor = Buffer.from(JSON.stringify({ scope: SCOPE(), offset: 2 })).toString('base64') + const cursor = skillCursor({ offset: 2 }) const response = await GET( request( @@ -208,7 +217,7 @@ describe('/api/v2/skills', () => { */ it('resumes a cursor minted under a different page size', async () => { mocks.list.mockResolvedValueOnce({ skills: [skill], hasMore: false, offset: 2, limit: 5 }) - const cursor = Buffer.from(JSON.stringify({ scope: SCOPE(), offset: 2 })).toString('base64') + const cursor = skillCursor({ offset: 2 }) const response = await GET( request( diff --git a/apps/sim/app/api/v2/skills/route.ts b/apps/sim/app/api/v2/skills/route.ts index e1356ef7ea0..d839331e2e2 100644 --- a/apps/sim/app/api/v2/skills/route.ts +++ b/apps/sim/app/api/v2/skills/route.ts @@ -9,25 +9,16 @@ import { captureServerEvent } from '@/lib/posthog/server' import { skillOperations } from '@/lib/skills/application/operations' import { createSkillUseCase, listSkillsUseCase } from '@/lib/skills/application/use-cases' import { + cursorFilterScope, + cursorSortKey, decodeOffsetCursor, encodeOffsetCursor, - offsetCursorScope, } from '@/app/api/v2/lib/response' import { toV2Skill, toV2SkillSummary } from '@/app/api/v2/skills/utils' -/** The query state a skills offset cursor is only valid within. */ -function skillCursorScope(query: { - workspaceId: string - search?: string - sortBy: string - sortOrder: string -}): string { - return offsetCursorScope({ - workspaceId: query.workspaceId, - search: query.search, - sortBy: query.sortBy, - sortOrder: query.sortOrder, - }) +/** Every param that changes which skills, in which order, this list returns. */ +function skillCursorFilters(query: { workspaceId: string; search?: string }) { + return cursorFilterScope({ workspaceId: query.workspaceId, search: query.search }) } export const dynamic = 'force-dynamic' @@ -47,17 +38,25 @@ export const GET = defineV2JsonRoute({ * re-checked here. `limit` is deliberately absent — it selects how much of * the sequence to return, not what the sequence is. */ - const scope = skillCursorScope(query) return { ...query, - offset: decodeOffsetCursor(query.cursor, scope), - cursorScope: scope, + offset: decodeOffsetCursor( + query.cursor, + cursorSortKey(query.sortBy, query.sortOrder), + skillCursorFilters(query) + ), } }, useCase: listSkillsUseCase, - present: ({ skills, hasMore, offset, limit, cursorScope }) => ({ + present: ({ skills, hasMore, offset, limit }, { query }) => ({ data: skills.map(toV2SkillSummary), - nextCursor: hasMore ? encodeOffsetCursor(cursorScope, offset + limit) : null, + nextCursor: hasMore + ? encodeOffsetCursor( + cursorSortKey(query.sortBy, query.sortOrder), + skillCursorFilters(query), + offset + limit + ) + : null, }), }) diff --git a/apps/sim/app/api/v2/tables/route.ts b/apps/sim/app/api/v2/tables/route.ts index 9099a1f51b9..c4caddbd024 100644 --- a/apps/sim/app/api/v2/tables/route.ts +++ b/apps/sim/app/api/v2/tables/route.ts @@ -3,12 +3,26 @@ import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/ import { v2TableErrorPolicies } from '@/lib/table/api' import { tableOperations } from '@/lib/table/application/operations' import { createTableUseCase, listTablesUseCase } from '@/lib/table/application/tables' -import { cursorSortKey, encodeSortedCursor, readSortedCursor } from '@/app/api/v2/lib/response' +import { + cursorFilterScope, + cursorSortKey, + encodeSortedCursor, + readSortedCursor, +} from '@/app/api/v2/lib/response' import { toApiTable, toApiTables } from '@/app/api/v2/tables/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 +/** Every param that changes which tables, in which order, this list returns. */ +function tableCursorFilters(query: { workspaceId: string; folderPath?: string; search?: string }) { + return cursorFilterScope({ + workspaceId: query.workspaceId, + folderPath: query.folderPath, + search: query.search, + }) +} + export const GET = defineV2JsonRoute({ contract: v2ListTablesContract, operation: tableOperations.list, @@ -23,11 +37,17 @@ export const GET = defineV2JsonRoute({ sortBy: query.sortBy, sortOrder: query.sortOrder, limit: query.limit, - after: readSortedCursor(query.cursor, query.sortBy, query.sortOrder), + after: readSortedCursor(query.cursor, query.sortBy, query.sortOrder, tableCursorFilters(query)), }), - present: async ({ tables, nextKeys, sortBy, sortOrder }) => ({ + present: async ({ tables, nextKeys }, { query }) => ({ data: await toApiTables(tables), - nextCursor: nextKeys ? encodeSortedCursor(cursorSortKey(sortBy, sortOrder), nextKeys) : null, + nextCursor: nextKeys + ? encodeSortedCursor( + cursorSortKey(query.sortBy, query.sortOrder), + nextKeys, + tableCursorFilters(query) + ) + : null, }), }) diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/runs/route.test.ts index de409d6a69b..bd56dece8b8 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/route.test.ts @@ -136,7 +136,7 @@ describe('GET /api/v2/workflows/[id]/runs', () => { const body = await (await callGet('?order=asc')).json() - expect(JSON.parse(Buffer.from(body.nextCursor, 'base64').toString())).toEqual({ + expect(JSON.parse(Buffer.from(body.nextCursor, 'base64').toString())).toMatchObject({ sort: 'startedAt:asc', keys: ['2026-08-05T00:01:00.000Z', 'row-1'], }) diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/route.ts b/apps/sim/app/api/v2/workflows/[id]/runs/route.ts index 6893f79f822..a4147b2737d 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/route.ts @@ -3,17 +3,37 @@ import { v2ListWorkflowRunsContract, v2WorkflowRunListStatusValueSchema, } from '@/lib/api/contracts/v2/workflows' +import { REFILTERED_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' import { INVALID_CURSOR_MESSAGE } from '@/lib/api/list-query' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { OrchestrationError } from '@/lib/core/orchestration/types' import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' import { listWorkflowRuns } from '@/lib/workflows/application/list-workflow-runs' import { workflowOperations } from '@/lib/workflows/application/operations' -import { cursorSortKey, decodeSortedCursor, encodeSortedCursor } from '@/app/api/v2/lib/response' +import { + cursorFilterScope, + cursorSortKey, + decodeSortedCursor, + encodeSortedCursor, +} from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 +/** Every param that changes which runs, in which order, this list returns. */ +function runCursorFilters( + workflowId: string, + query: { status?: string; trigger?: string; startDate?: string; endDate?: string } +) { + return cursorFilterScope({ + workflowId, + status: query.status, + trigger: query.trigger, + startDate: query.startDate, + endDate: query.endDate, + }) +} + /** List the durable runs belonging to one workflow. */ export const GET = defineV2JsonRoute({ contract: v2ListWorkflowRunsContract, @@ -24,10 +44,13 @@ export const GET = defineV2JsonRoute({ mapInput: ({ params, query }) => { const { status, trigger, startDate, endDate, limit, cursor, order } = query const sort = cursorSortKey('startedAt', order) - const decodedCursor = decodeSortedCursor(cursor, sort) + const decodedCursor = decodeSortedCursor(cursor, sort, runCursorFilters(params.id, query)) if (decodedCursor.status === 'invalid') { throw new OrchestrationError('validation', INVALID_CURSOR_MESSAGE) } + if (decodedCursor.status === 'refiltered') { + throw new OrchestrationError('validation', REFILTERED_CURSOR_MESSAGE) + } const [cursorStartedAt, cursorRowId] = decodedCursor.status === 'ok' ? decodedCursor.keys : [] const cursorDate = typeof cursorStartedAt === 'string' ? new Date(cursorStartedAt) : null if ( @@ -55,7 +78,7 @@ export const GET = defineV2JsonRoute({ } }, useCase: listWorkflowRuns, - present: (result) => { + present: (result, { params, query }) => { const data: V2WorkflowRunListItem[] = result.data.map((row) => ({ runId: row.executionId, workflowId: row.workflowId ?? result.workflowId, @@ -68,10 +91,11 @@ export const GET = defineV2JsonRoute({ })) const sort = cursorSortKey('startedAt', result.order) const nextCursor = result.nextCursor - ? encodeSortedCursor(sort, [ - result.nextCursor.startedAt.toISOString(), - result.nextCursor.rowId, - ]) + ? encodeSortedCursor( + sort, + [result.nextCursor.startedAt.toISOString(), result.nextCursor.rowId], + runCursorFilters(params.id, query) + ) : null return { data, nextCursor } }, diff --git a/apps/sim/app/api/v2/workflows/route.test.ts b/apps/sim/app/api/v2/workflows/route.test.ts index c93ae286fa8..7f765f0fd77 100644 --- a/apps/sim/app/api/v2/workflows/route.test.ts +++ b/apps/sim/app/api/v2/workflows/route.test.ts @@ -98,6 +98,71 @@ describe('/api/v2/workflows', () => { expect(mocks.listWorkflows).not.toHaveBeenCalled() }) + /** + * The reported defect: a cursor from an unfiltered page was accepted under + * `deployedOnly=true` or a changed `search`, and answered with whatever + * matched the new filter *after* the old position — every earlier match + * silently missing behind an opaque token. + */ + it.each([ + ['deployedOnly', 'deployedOnly=true'], + ['search', 'search=billing'], + ['folderPath', 'folderPath=/Ops'], + ])('refuses a cursor replayed under a different %s', async (_filter, param) => { + mocks.listWorkflows.mockResolvedValueOnce({ + workflows: [WORKFLOW], + nextCursorKeys: [1, WORKFLOW.id], + sortBy: 'position', + sortOrder: 'asc', + }) + const firstPage = await ( + await GET(new NextRequest(`http://localhost/api/v2/workflows?workspaceId=${WORKSPACE_ID}`)) + ).json() + expect(firstPage.nextCursor).toEqual(expect.any(String)) + mocks.listWorkflows.mockClear() + + const response = await GET( + new NextRequest( + `http://localhost/api/v2/workflows?workspaceId=${WORKSPACE_ID}&${param}&cursor=${encodeURIComponent(firstPage.nextCursor)}` + ) + ) + + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ + error: { code: 'BAD_REQUEST', message: expect.stringContaining('requested filters') }, + }) + expect(mocks.listWorkflows).not.toHaveBeenCalled() + }) + + it('resumes a cursor whose filters are unchanged', async () => { + mocks.listWorkflows.mockResolvedValueOnce({ + workflows: [WORKFLOW], + nextCursorKeys: [1, WORKFLOW.id], + sortBy: 'position', + sortOrder: 'asc', + }) + const firstPage = await ( + await GET( + new NextRequest( + `http://localhost/api/v2/workflows?workspaceId=${WORKSPACE_ID}&deployedOnly=true` + ) + ) + ).json() + + const response = await GET( + new NextRequest( + `http://localhost/api/v2/workflows?workspaceId=${WORKSPACE_ID}&deployedOnly=true&cursor=${encodeURIComponent(firstPage.nextCursor)}` + ) + ) + + expect(response.status).toBe(200) + expect(mocks.listWorkflows).toHaveBeenLastCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ cursorKeys: [1, WORKFLOW.id] }), + }) + ) + }) + it('lists through the workspace principal and preserves rate headers', async () => { const request = new NextRequest( `http://localhost/api/v2/workflows?workspaceId=${WORKSPACE_ID}`, diff --git a/apps/sim/app/api/v2/workflows/route.ts b/apps/sim/app/api/v2/workflows/route.ts index 8fd65da66ab..18ec3878f6f 100644 --- a/apps/sim/app/api/v2/workflows/route.ts +++ b/apps/sim/app/api/v2/workflows/route.ts @@ -9,11 +9,31 @@ import { import { createWorkflow } from '@/lib/workflows/application/create-workflow' import { listWorkflows } from '@/lib/workflows/application/list-workflows' import { workflowOperations } from '@/lib/workflows/application/operations' -import { cursorSortKey, encodeSortedCursor, readSortedCursor } from '@/app/api/v2/lib/response' +import { + cursorFilterScope, + cursorSortKey, + encodeSortedCursor, + readSortedCursor, +} from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 +/** Every param that changes which workflows, in which order, this list returns. */ +function workflowCursorFilters(query: { + workspaceId: string + folderPath?: string + deployedOnly: boolean + search?: string +}) { + return cursorFilterScope({ + workspaceId: query.workspaceId, + folderPath: query.folderPath, + deployedOnly: query.deployedOnly, + search: query.search, + }) +} + export const GET = defineV2JsonRoute({ contract: v2ListWorkflowsContract, auth: v2ApiKeyAuth, @@ -27,11 +47,16 @@ export const GET = defineV2JsonRoute({ search: query.search, sortBy: query.sortBy, sortOrder: query.sortOrder, - cursorKeys: readSortedCursor(query.cursor, query.sortBy, query.sortOrder), + cursorKeys: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + workflowCursorFilters(query) + ), limit: query.limit, }), useCase: listWorkflows, - present: ({ workflows, nextCursorKeys, sortBy, sortOrder }) => ({ + present: ({ workflows, nextCursorKeys }, { query }) => ({ data: workflows.map( (workflow): V2WorkflowListItem => ({ id: workflow.id, @@ -48,7 +73,11 @@ export const GET = defineV2JsonRoute({ }) ), nextCursor: nextCursorKeys - ? encodeSortedCursor(cursorSortKey(sortBy, sortOrder), nextCursorKeys) + ? encodeSortedCursor( + cursorSortKey(query.sortBy, query.sortOrder), + nextCursorKeys, + workflowCursorFilters(query) + ) : null, }), }) diff --git a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts index 06f92b4e5ce..d64643234a0 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts @@ -96,6 +96,116 @@ const FULL_SET_LISTS = [ 'GET /api/v2/workflows/folders', ] as const +/** + * Which of each paged list's params its cursor is bound to. + * + * A cursor names a position in ONE sequence, and every param that reorders or + * re-filters that sequence decides which sequence that is. Replay a cursor + * across a change to any of them and the reply is wrong in a way the caller + * cannot see: an offset lands at an unrelated ordinal, and a keyset — which + * stays internally coherent — silently drops every match that sorts before its + * position. So all of them are stamped into the token and re-checked on the way + * back in, and a mismatch is a 400 telling the caller to restart paging. + * + * The stamp is applied by the route through `cursorFilterScope` + + * `cursorSortKey` (`app/api/v2/lib/response.ts`), or, for the three lists whose + * token is minted by a domain codec, by wrapping it with `encodeScopedCursor`. + * The table-row lists bind inside their own codec (`lib/table/rows/cursor.ts`) + * against the same fingerprint. + * + * This map is the declaration; the tests below check it against what each + * contract actually accepts, in both directions. A list that gains a filter + * therefore fails here until someone decides whether the cursor is bound to it. + */ +const CURSOR_BINDINGS: Record = { + 'GET /api/v2/audit-logs': [ + 'organizationId', + 'includeDeparted', + 'action', + 'resourceType', + 'resourceId', + 'workspaceId', + 'actorEmail', + 'startDate', + 'endDate', + ], + 'GET /api/v2/billing/logs': ['source', 'workspaceId', 'period', 'startDate', 'endDate'], + 'GET /api/v2/credentials': ['workspaceId', 'type', 'providerId', 'search', 'sortBy', 'sortOrder'], + 'GET /api/v2/custom-tools': ['workspaceId', 'search', 'sortBy', 'sortOrder'], + 'GET /api/v2/files': ['workspaceId', 'scope', 'folderPath', 'search', 'sortBy', 'sortOrder'], + 'GET /api/v2/knowledge': ['workspaceId', 'folderPath', 'search', 'sortBy', 'sortOrder'], + 'GET /api/v2/knowledge/[id]/documents': [ + 'workspaceId', + 'enabledFilter', + 'search', + 'tagFilters', + 'sortBy', + 'sortOrder', + ], + 'GET /api/v2/logs': [ + 'workspaceId', + 'workflowIds', + 'triggers', + 'level', + 'startDate', + 'endDate', + 'runId', + 'minDurationMs', + 'maxDurationMs', + 'minCost', + 'maxCost', + 'model', + 'folderPaths', + 'order', + ], + 'GET /api/v2/mcp-servers': ['workspaceId', 'search', 'sortBy', 'sortOrder'], + 'GET /api/v2/secrets': ['workspaceId', 'scope', 'search', 'sortBy', 'sortOrder'], + 'GET /api/v2/skills': ['workspaceId', 'search', 'sortBy', 'sortOrder'], + 'GET /api/v2/tables': ['workspaceId', 'folderPath', 'search', 'sortBy', 'sortOrder'], + 'GET /api/v2/tables/[tableId]/rows': [], + 'POST /api/v2/tables/[tableId]/query': ['predicate', 'sort'], + 'GET /api/v2/workflows': [ + 'workspaceId', + 'folderPath', + 'deployedOnly', + 'search', + 'sortBy', + 'sortOrder', + ], + 'GET /api/v2/workflows/[id]/runs': ['status', 'trigger', 'startDate', 'endDate', 'order'], + 'GET /api/v2/workflows/[id]/versions': [], + 'GET /api/v2/workspaces/[workspaceId]/members': [], +} + +/** + * Params a paged list accepts that its cursor is deliberately NOT bound to, + * with the reason. Anything not listed here and not in {@link CURSOR_BINDINGS} + * fails the sweep. + * + * `limit` is excluded globally rather than per list: it selects how much of the + * sequence to return, not what the sequence is, so a caller is free to change + * page size mid-walk and binding it would strand every cursor for no + * correctness gain. + */ +const UNBOUND_PARAMS: Record> = { + 'GET /api/v2/logs': { + details: 'Selects how much of each row is rendered, not which rows are in the sequence.', + includeTraceSpans: 'Response shaping only; the row set and its order are unchanged.', + includeFinalOutput: 'Response shaping only; the row set and its order are unchanged.', + }, + 'GET /api/v2/tables/[tableId]/rows': { + workspaceId: + 'Asserted scope, not a filter: the sequence is one table, named by the path. A mismatched workspace is refused by authorization before paging.', + }, + 'POST /api/v2/tables/[tableId]/query': { + workspaceId: + 'Asserted scope, not a filter: the sequence is one table, named by the path. A mismatched workspace is refused by authorization before paging.', + }, +} + +/** Never part of a binding, on any list. */ +const NEVER_BOUND = new Set(['limit', 'cursor']) + /** * Lists that deliberately truncate a fractional `limit` instead of rejecting it. * @@ -273,6 +383,8 @@ interface V2ListContract { key: string name: string params: { any: string[]; all: string[] } + /** Every param name the contract accepts, across `query` and `body`. */ + inputKeys: string[] /** `undefined` when the contract has no `query`; `null` when it could not be introspected. */ strictQuery: boolean | null | undefined /** Whether a fractional `limit` draws a validation issue on `limit` itself. */ @@ -301,10 +413,12 @@ async function sweepV2ListContracts(): Promise { const label = `${name} (${key})` if (!isListResponse(label, value.response?.schema)) continue if (found.has(key)) continue + const variants = inputVariants(label, value) found.set(key, { key, name, - params: paginationParams(inputVariants(label, value)), + params: paginationParams(variants), + inputKeys: [...new Set(variants.flat())].sort(), strictQuery: value.query ? rejectsUnknownKeys(value.query) : undefined, rejectsFractionalLimit: rejectsFractionalLimit(value), }) @@ -405,6 +519,74 @@ describe('v2 list pagination split', () => { } }) + it('makes every paged list declare what its cursor is bound to', async () => { + const contracts = await loadV2ListContracts() + const declared = new Set(Object.keys(CURSOR_BINDINGS)) + + expect( + contracts.filter((c) => PAGED_LISTS.includes(c.key as never) && !declared.has(c.key)), + 'A paged v2 list must declare its cursor binding in CURSOR_BINDINGS. A cursor names a position in one sequence; every param that decides that sequence has to be stamped into the token, or replaying it across a filter change answers from a sequence the caller never asked for.' + ).toEqual([]) + expect([...declared].sort()).toEqual([...PAGED_LISTS].sort()) + }) + + it('binds every sequence-affecting param a paged list accepts', async () => { + const contracts = await loadV2ListContracts() + const byKey = new Map(contracts.map((c) => [c.key, c])) + + for (const key of PAGED_LISTS) { + const contract = byKey.get(key) + if (!contract) throw new Error(`${key} was not discovered by the contract sweep`) + const accounted = new Set([ + ...CURSOR_BINDINGS[key], + ...Object.keys(UNBOUND_PARAMS[key] ?? {}), + ...NEVER_BOUND, + ]) + + expect( + contract.inputKeys.filter((param) => !accounted.has(param)), + `${key} accepts a param its cursor neither binds nor exempts. Add it to CURSOR_BINDINGS and stamp it in the route, or record why it cannot change the sequence in UNBOUND_PARAMS.` + ).toEqual([]) + } + }) + + it('never declares a binding on a param the contract does not accept', async () => { + const contracts = await loadV2ListContracts() + const byKey = new Map(contracts.map((c) => [c.key, c])) + + for (const key of PAGED_LISTS) { + const accepted = new Set(byKey.get(key)?.inputKeys ?? []) + + expect( + [...CURSOR_BINDINGS[key], ...Object.keys(UNBOUND_PARAMS[key] ?? {})].filter( + (param) => !accepted.has(param) + ), + `${key} declares a cursor binding for a param it no longer accepts. A renamed filter leaves the stamp reading undefined on both sides, which silently restores the mid-walk filter change this map exists to prevent.` + ).toEqual([]) + } + }) + + /** + * The one param that must never be bound. Binding it looks harmless and + * breaks every caller that changes page size mid-walk. + */ + it('never binds the page size', () => { + for (const [key, bound] of Object.entries(CURSOR_BINDINGS)) { + expect( + bound.filter((param) => NEVER_BOUND.has(param)), + `${key} binds limit or cursor` + ).toEqual([]) + } + }) + + it('gives every unbound param a non-empty reason', () => { + for (const [key, exemptions] of Object.entries(UNBOUND_PARAMS)) { + for (const [param, reason] of Object.entries(exemptions)) { + expect(reason.trim(), `${key}.${param} is exempted without a reason`).not.toBe('') + } + } + }) + it('sees a pagination param hidden in a single union member', () => { const unionQuery = z.union([ z.object({ workspaceId: z.string(), limit: z.coerce.number().default(50) }), diff --git a/apps/sim/lib/api/contracts/v2/shared.ts b/apps/sim/lib/api/contracts/v2/shared.ts index 1a0834b0c3b..88d7f318502 100644 --- a/apps/sim/lib/api/contracts/v2/shared.ts +++ b/apps/sim/lib/api/contracts/v2/shared.ts @@ -114,20 +114,41 @@ import { FolderPathError, parseFolderPath, requireNonRootFolderPath } from '@/li * `encodeCursor({ email })` on the workspace member list, the audit-log and run-log * codecs in `lib/audit-logs/query.ts` and `lib/logs/list-logs.ts`, the table-row * codec in `lib/table/rows/cursor.ts`, and a usage-event id passed straight - * through by `GET /billing/logs`. They are opaque to a caller in exactly the same - * way, but they do not get the shared codec's sort stamp, so a new list should - * reach for one of the two shared schemes rather than adding a fourth. + * through by `GET /billing/logs`. Those tokens stay opaque and untouched, but the + * three whose sequence a caller can re-filter are wrapped in + * `encodeScopedCursor` at the surface so they carry the same query binding as + * the shared schemes. A new list should still reach for one of the two shared + * codecs rather than adding a fourth. * - * ## Sort and the opaque cursor + * ## Query binding and the opaque cursor * - * Lists using the shared keyset codec (`encodeSortedCursor` / - * `decodeSortedCursor` in `app/api/v2/lib/response.ts`) carry a cursor that is - * a keyset over the *active* sort, so its keys change when the sort does. The - * sort is therefore encoded into the cursor and re-checked on the way back in: - * replaying a cursor under a different `sortBy`/`sortOrder` is a 400, not a - * silently duplicated or skipped page. Change the sort by restarting pagination - * without a cursor. The rest delegate to their domain's own cursor codec, which - * is opaque in exactly the same way. + * A cursor names a position in ONE sequence, and a v2 list decides that + * sequence from its sort AND its filters. Every paged list therefore stamps + * both into the token it returns and re-checks them on the way back in: + * replaying a cursor under a different `sortBy`/`sortOrder`, or under a changed + * filter, is a 400 naming which half changed. Change either by restarting + * pagination without a cursor. + * + * Both failures are silent without the stamp, but they are not the same + * failure. An offset replayed against a re-filtered sequence names an unrelated + * ordinal in it. A keyset stays internally coherent — the page it returns is + * correctly ordered and duplicate-free — and is missing every match that sorts + * before the cursor's position, which a caller holding an opaque token reads as + * "almost nothing matched". Neither is recoverable by the client, so neither is + * served. + * + * `limit` is deliberately not part of the binding: it selects how much of the + * sequence to return, not what the sequence is, so a caller may change page + * size mid-walk. Params that only shape the response body (`details`, + * `includeTraceSpans`, `includeFinalOutput` on `GET /logs`) are out for the same + * reason. The authoritative per-list binding is pinned in + * `v2/__tests__/list-pagination.test.ts`, which fails when a list gains a param + * that is neither bound nor explicitly exempted. + * + * The three lists whose token is minted by a domain codec that predates the + * shared ones (`GET /logs`, `GET /audit-logs`, `GET /billing/logs`) get the same + * binding by wrapping that token — the domain cursor stays opaque and untouched + * inside a query-stamped envelope. */ /** @@ -275,7 +296,9 @@ export function v2LimitSchema(options: V2LimitOptions = {}) { * caller that accidentally forwards an empty string learns about it instead of * looping on page one. */ -export function v2CursorSchema(description = 'Opaque cursor returned by the previous page.') { +export function v2CursorSchema( + description = 'Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.' +) { return z.string().min(1, 'cursor must be a non-empty token').optional().describe(description) } diff --git a/apps/sim/lib/api/cursor-binding.test.ts b/apps/sim/lib/api/cursor-binding.test.ts new file mode 100644 index 00000000000..2048b907223 --- /dev/null +++ b/apps/sim/lib/api/cursor-binding.test.ts @@ -0,0 +1,187 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { cursorScopeKey } from '@/lib/api/cursor-binding' +import { + cursorFilterScope, + cursorSortKey, + decodeOffsetCursor, + decodeSortedCursor, + encodeOffsetCursor, + encodeScopedCursor, + encodeSortedCursor, + readScopedCursor, + readSortedCursor, +} from '@/app/api/v2/lib/response' + +/** + * A v2 cursor names a position in one exact sequence, and every v2 list decides + * that sequence from its sort AND its filters. Replay a cursor against a + * re-filtered read and the two schemes fail differently but both fail: an + * offset names an unrelated ordinal, and a keyset silently drops every match + * that sorts before its position. Neither is distinguishable from a correct + * page by a caller holding an opaque token, so both are refused. + * + * These assertions hold all three shared codecs — the offset used by + * `GET /skills` and `GET /knowledge/{id}/documents`, the keyset used by the nine + * SQL-ordered lists, and the wrapper that binds the domain-minted tokens on + * `GET /logs`, `GET /audit-logs`, and `GET /billing/logs` — to that one rule. + */ +describe('v2 cursor binding', () => { + const sort = cursorSortKey('name', 'asc') + const filters = { workspaceId: 'ws-1', search: undefined as string | undefined } + const scope = cursorFilterScope(filters) + + describe('offset cursor', () => { + it('resumes a cursor replayed under the same query state', () => { + expect(decodeOffsetCursor(encodeOffsetCursor(sort, scope, 40), sort, scope)).toBe(40) + }) + + it('rejects a cursor replayed under a different sort', () => { + const cursor = encodeOffsetCursor(sort, scope, 40) + + expect(() => decodeOffsetCursor(cursor, cursorSortKey('createdAt', 'asc'), scope)).toThrow( + /sortBy\/sortOrder/ + ) + expect(() => decodeOffsetCursor(cursor, cursorSortKey('name', 'desc'), scope)).toThrow( + /sortBy\/sortOrder/ + ) + }) + + it('rejects a cursor replayed under a different filter', () => { + const cursor = encodeOffsetCursor(sort, scope, 40) + + expect(() => + decodeOffsetCursor(cursor, sort, cursorFilterScope({ ...filters, search: 'deploy' })) + ).toThrow(/requested filters/) + expect(() => + decodeOffsetCursor(cursor, sort, cursorFilterScope({ ...filters, workspaceId: 'ws-2' })) + ).toThrow(/requested filters/) + }) + + it('treats an absent cursor as page one', () => { + expect(decodeOffsetCursor(undefined, sort, scope)).toBe(0) + }) + + it('rejects a cursor that is not valid base64-JSON', () => { + expect(() => decodeOffsetCursor('not-a-cursor', sort, scope)).toThrow() + }) + + it('rejects an offset that is not a non-negative integer', () => { + expect(() => decodeOffsetCursor(encodeOffsetCursor(sort, scope, -1), sort, scope)).toThrow( + 'Invalid cursor' + ) + expect(() => decodeOffsetCursor(encodeOffsetCursor(sort, scope, 1.5), sort, scope)).toThrow( + 'Invalid cursor' + ) + }) + }) + + describe('keyset cursor', () => { + const keys = ['notes.md', 'file-1'] + + it('resumes a cursor replayed under the same query state', () => { + expect(readSortedCursor(encodeSortedCursor(sort, keys, scope), 'name', 'asc', scope)).toEqual( + keys + ) + }) + + /** + * A keyset position stays coherent under a changed filter — that is exactly + * why it is dangerous. The page it returns is duplicate-free and correctly + * ordered, and silently missing every match that sorts before the cursor. + */ + it('rejects a cursor replayed under a different filter', () => { + const cursor = encodeSortedCursor(sort, keys, scope) + const narrowed = cursorFilterScope({ ...filters, search: 'deploy' }) + + expect(decodeSortedCursor(cursor, sort, narrowed)).toEqual({ status: 'refiltered' }) + expect(() => readSortedCursor(cursor, 'name', 'asc', narrowed)).toThrow(/requested filters/) + }) + + /** + * The two stamps are checked separately so the 400 names the half that + * actually changed, rather than telling a caller who narrowed a search term + * to go re-read the sort documentation. + */ + it('names the sort when the sort is what changed', () => { + expect(() => + readSortedCursor(encodeSortedCursor(sort, keys, scope), 'createdAt', 'asc', scope) + ).toThrow(/sortBy\/sortOrder/) + }) + + it('refuses an unfiltered cursor replayed under a filter, and the reverse', () => { + const unfiltered = encodeSortedCursor(sort, keys, undefined) + + expect(() => readSortedCursor(unfiltered, 'name', 'asc', scope)).toThrow(/requested filters/) + expect(() => + readSortedCursor(encodeSortedCursor(sort, keys, scope), 'name', 'asc', undefined) + ).toThrow(/requested filters/) + }) + + it('treats an absent cursor as page one', () => { + expect(readSortedCursor(undefined, 'name', 'asc', scope)).toBeUndefined() + }) + }) + + describe('scoped wrapper for domain-minted cursors', () => { + it('round-trips the domain token untouched', () => { + expect(readScopedCursor(encodeScopedCursor(scope, 'domain-token'), scope)).toBe( + 'domain-token' + ) + }) + + it('rejects a token replayed under different filters', () => { + const cursor = encodeScopedCursor(scope, 'domain-token') + + expect(() => + readScopedCursor(cursor, cursorFilterScope({ ...filters, search: 'deploy' })) + ).toThrow(/requested filters/) + }) + + it('treats an absent cursor as page one', () => { + expect(readScopedCursor(undefined, scope)).toBeUndefined() + }) + + it('rejects a token that is not valid base64-JSON', () => { + expect(() => readScopedCursor('not-a-cursor', scope)).toThrow() + }) + }) + + describe('scope fingerprint', () => { + /** + * `limit` selects how much of the sequence to return, not what the sequence + * is, so it is never a scope part and paging with a different page size must + * keep working. + */ + it('is unaffected by the page size', () => { + expect(decodeOffsetCursor(encodeOffsetCursor(sort, scope, 40), sort, scope)).toBe(40) + }) + + it('does not depend on the order the parts are written', () => { + expect(cursorScopeKey({ b: '2', a: '1' })).toBe(cursorScopeKey({ a: '1', b: '2' })) + }) + + it('treats an omitted part and an undefined part as the same scope', () => { + expect(cursorScopeKey({ a: '1', b: undefined })).toBe(cursorScopeKey({ a: '1' })) + }) + + it('has no fingerprint at all when nothing is filtered', () => { + expect(cursorScopeKey({ a: undefined })).toBeUndefined() + }) + + /** + * Distinct queries must not collide across part boundaries: `{a:'1',b:'2'}` + * and `{a:'1|2'}` are different reads and must fingerprint differently. + */ + it('separates parts rather than concatenating their values', () => { + expect(cursorScopeKey({ a: '1', b: '2' })).not.toBe(cursorScopeKey({ a: '1|2' })) + expect(cursorScopeKey({ a: '1' })).not.toBe(cursorScopeKey({ b: '1' })) + }) + + it('stays short enough to sit inside an opaque token', () => { + expect(cursorScopeKey({ search: 'x'.repeat(200) })).toHaveLength(22) + }) + }) +}) diff --git a/apps/sim/lib/api/cursor-binding.ts b/apps/sim/lib/api/cursor-binding.ts new file mode 100644 index 00000000000..bceb060ba35 --- /dev/null +++ b/apps/sim/lib/api/cursor-binding.ts @@ -0,0 +1,83 @@ +import { createHash } from 'node:crypto' + +/** + * The one canonicalization every paginated surface stamps its cursors with. + * + * A cursor names a position in *one* sequence. Everything that reorders or + * re-filters that sequence therefore has to travel with it, or replaying the + * token against a re-filtered read silently answers from a sequence the caller + * never asked for. `lib/table/rows/cursor.ts` and the v2 list codecs in + * `app/api/v2/lib/response.ts` both bind through this module so there is one + * fingerprint format rather than one per surface. + * + * What belongs in a binding is every param that changes *which rows, in which + * order*. What must stay out is `limit`: it selects how much of the sequence to + * return, not what the sequence is, so a caller is free to change page size + * mid-walk. Response-shaping params (whether to inline trace spans, say) stay + * out for the same reason. + */ + +/** + * Caller-facing message for a cursor replayed under different filters. Separate + * from the sort-mismatch message on purpose: both mean "restart pagination", + * but naming the half that actually changed is the difference between a caller + * finding the bug in its own code and re-reading the sort docs. + */ +export const REFILTERED_CURSOR_MESSAGE = + 'cursor does not match the requested filters. Restart pagination without a cursor after changing a filter.' + +/** A scalar a list filter can be expressed as, before canonicalization. */ +export type CursorScopePart = + | string + | number + | boolean + | Date + | readonly string[] + | null + | undefined + +/** + * Deterministic JSON: object keys sorted so two structurally equal values + * serialize identically regardless of the key order they arrived in, and + * `undefined` members dropped so an omitted param and an absent one agree. + * + * Array order is preserved — reordering an `in` list is treated as a different + * filter, which only ever costs a restart. + */ +export function canonicalJson(value: unknown): string { + if (value instanceof Date) return JSON.stringify(value.toISOString()) + if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null' + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]` + const entries = Object.entries(value as Record) + .filter(([, entry]) => entry !== undefined) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`).join(',')}}` +} + +/** + * Fingerprint of a canonical form, short enough to sit inside an opaque token. + * + * Hashed rather than embedded because the bound state can be large — a table + * predicate runs to the request-body ceiling, and a v2 `search` term to 200 + * characters — while the cursor has to stay a token a caller can put in a query + * string. SHA-256 also means a caller cannot cheaply construct a second filter + * that collides with another sequence's stamp. + */ +export function fingerprint(canonical: string): string { + return createHash('sha256').update(canonical).digest('base64url').slice(0, 22) +} + +/** + * The fingerprint of a list's sequence-affecting params, or `undefined` when + * the caller supplied none of them. + * + * `undefined` is a real state rather than an empty hash: an unstamped cursor is + * an unfiltered one, so it stays short, and replaying it under a filter still + * mismatches (`undefined !== `). Params whose value is `undefined` are + * dropped, so omitting a filter and never having sent it are the same scope. + */ +export function cursorScopeKey(parts: Record): string | undefined { + const present = Object.entries(parts).filter(([, value]) => value !== undefined) + if (present.length === 0) return undefined + return fingerprint(canonicalJson(Object.fromEntries(present))) +} diff --git a/apps/sim/lib/api/offset-cursor-scope.test.ts b/apps/sim/lib/api/offset-cursor-scope.test.ts deleted file mode 100644 index b81cfb1dffe..00000000000 --- a/apps/sim/lib/api/offset-cursor-scope.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it } from 'vitest' -import { - decodeOffsetCursor, - encodeOffsetCursor, - offsetCursorScope, -} from '@/app/api/v2/lib/response' - -/** - * An offset names a position in one exact sequence. Replay it against a - * differently filtered or sorted sequence and it names a different row — - * silently skipping rows, repeating them, or landing past the end and returning - * an empty page while `nextCursor` implied more. - * - * The keyset cursor has always been protected from this by its sort stamp - * (`decodeSortedCursor`). These assertions hold the offset cursor — used by - * `GET /skills` and `GET /knowledge/{id}/documents` — to the same rule. - */ -describe('offset cursor scope', () => { - const base = { workspaceId: 'ws-1', search: undefined, sortBy: 'name', sortOrder: 'asc' } - - it('resumes a cursor replayed under the same query state', () => { - const scope = offsetCursorScope(base) - expect(decodeOffsetCursor(encodeOffsetCursor(scope, 40), scope)).toBe(40) - }) - - it('rejects a cursor replayed under a different sort', () => { - const cursor = encodeOffsetCursor(offsetCursorScope(base), 40) - - expect(() => - decodeOffsetCursor(cursor, offsetCursorScope({ ...base, sortBy: 'createdAt' })) - ).toThrow(/does not match the requested/) - expect(() => - decodeOffsetCursor(cursor, offsetCursorScope({ ...base, sortOrder: 'desc' })) - ).toThrow(/does not match the requested/) - }) - - it('rejects a cursor replayed under a different filter', () => { - const cursor = encodeOffsetCursor(offsetCursorScope(base), 40) - - expect(() => - decodeOffsetCursor(cursor, offsetCursorScope({ ...base, search: 'deploy' })) - ).toThrow(/does not match the requested/) - expect(() => - decodeOffsetCursor(cursor, offsetCursorScope({ ...base, workspaceId: 'ws-2' })) - ).toThrow(/does not match the requested/) - }) - - it('treats an absent cursor as page one', () => { - expect(decodeOffsetCursor(undefined, offsetCursorScope(base))).toBe(0) - }) - - it('rejects a cursor that is not valid base64-JSON', () => { - expect(() => decodeOffsetCursor('not-a-cursor', offsetCursorScope(base))).toThrow() - }) - - it('rejects an offset that is not a non-negative integer', () => { - const scope = offsetCursorScope(base) - expect(() => decodeOffsetCursor(encodeOffsetCursor(scope, -1), scope)).toThrow('Invalid cursor') - expect(() => decodeOffsetCursor(encodeOffsetCursor(scope, 1.5), scope)).toThrow( - 'Invalid cursor' - ) - }) - - /** - * `limit` selects how much of the sequence to return, not what the sequence - * is, so paging with a different page size must keep working. - */ - it('is unaffected by the page size', () => { - expect(offsetCursorScope({ ...base, sortBy: 'name' })).toBe(offsetCursorScope(base)) - }) - - it('does not depend on the order the parts are written', () => { - expect(offsetCursorScope({ sortBy: 'name', workspaceId: 'ws-1', sortOrder: 'asc' })).toBe( - offsetCursorScope({ sortOrder: 'asc', workspaceId: 'ws-1', sortBy: 'name' }) - ) - }) -}) diff --git a/apps/sim/lib/api/server/routes/types.ts b/apps/sim/lib/api/server/routes/types.ts index f2411298f38..8ccc310a557 100644 --- a/apps/sim/lib/api/server/routes/types.ts +++ b/apps/sim/lib/api/server/routes/types.ts @@ -46,7 +46,21 @@ export interface JsonRouteDefinition< operation: O mapInput(input: ParsedRequest): I useCase: OperationUseCase, I, R> - present(result: R): ContractJsonResponse | Promise> + /** + * Renders the surface body. The parsed request is passed alongside the result + * so a presenter can read the request's own params without the use case + * having to carry them back out. + * + * That second argument exists for pagination: a `nextCursor` is stamped with + * the sort and filters the page was read under, and those live in the query, + * not in the domain result. Threading them through the use case instead — + * which several lists used to do — makes an application service carry an HTTP + * cursor-encoding concern purely so the presenter can see it again. + */ + present( + result: R, + request: ParsedRequest + ): ContractJsonResponse | Promise> } export type JsonNextRouteHandler = ( diff --git a/apps/sim/lib/api/server/routes/v2-json-route.ts b/apps/sim/lib/api/server/routes/v2-json-route.ts index 41f1e89b0f1..b89aed960b0 100644 --- a/apps/sim/lib/api/server/routes/v2-json-route.ts +++ b/apps/sim/lib/api/server/routes/v2-json-route.ts @@ -307,7 +307,7 @@ export function defineV2JsonRoute< input, request, }) - const body = await options.present(result) + const body = await options.present(result, parsed.data) const responseSchema = options.contract.response if (responseSchema.mode !== 'json') { throw new Error('V2 JSON route response mode changed after initialization') diff --git a/apps/sim/lib/knowledge/application/documents.ts b/apps/sim/lib/knowledge/application/documents.ts index 50cf0ce86c1..cf9e51318b6 100644 --- a/apps/sim/lib/knowledge/application/documents.ts +++ b/apps/sim/lib/knowledge/application/documents.ts @@ -87,12 +87,6 @@ export interface ListKnowledgeDocumentsInput { * document filtering and search speak one tag vocabulary. */ tagNameFilters?: KnowledgeTagNameFilter[] - /** - * The query state `offset` counts positions within, echoed back so a surface - * presenter can stamp the next cursor with it. Surface-only; the read itself - * does not use it. - */ - cursorScope?: string } export interface ReadKnowledgeDocumentInput { @@ -259,7 +253,6 @@ export const listKnowledgeDocuments = defineAuthorizedKnowledgeUseCase({ resolvedNameFilters?.definitionsByKnowledgeBase.get(context.knowledgeBaseId) ?? (await getDocumentTagDefinitions(context.knowledgeBaseId)), workspaceId: context.workspaceId, - cursorScope: input.cursorScope, } }, }) diff --git a/apps/sim/lib/skills/application/use-cases.ts b/apps/sim/lib/skills/application/use-cases.ts index 8f54b55dd69..251a2dc95b3 100644 --- a/apps/sim/lib/skills/application/use-cases.ts +++ b/apps/sim/lib/skills/application/use-cases.ts @@ -61,11 +61,6 @@ export interface ListSkillsInput { limit: number /** Position in the merged built-in + workspace list, read from the cursor. */ offset: number - /** - * The query state that position is valid within, echoed back so the presenter - * can stamp the next cursor with it. - */ - cursorScope: string } /** @@ -92,7 +87,6 @@ export const listSkillsUseCase = defineAuthorizedWorkspaceUseCase({ hasMore: page.hasMore, offset: page.offset, limit: page.limit, - cursorScope: input.cursorScope, } }, }) diff --git a/apps/sim/lib/table/rows/cursor.ts b/apps/sim/lib/table/rows/cursor.ts index 192b9f5afdd..820543e68d9 100644 --- a/apps/sim/lib/table/rows/cursor.ts +++ b/apps/sim/lib/table/rows/cursor.ts @@ -19,7 +19,7 @@ * under a different one. See {@link assertCursorQueryBinding}. */ -import { createHash } from 'node:crypto' +import { canonicalJson, fingerprint } from '@/lib/api/cursor-binding' import { TableQueryValidationError } from '@/lib/table/errors' import type { Filter, Sort, TablePredicate, TableRow, TableRowsCursor } from '@/lib/table/types' @@ -59,27 +59,11 @@ export function canonicalSortKey(sort: Sort | null | undefined): string | undefi return entries.length > 0 ? JSON.stringify(entries) : undefined } -/** - * Deterministic JSON for a filter tree: object keys sorted so two structurally - * equal filters serialize identically regardless of the key order the caller's - * JSON happened to arrive in. Array order is preserved — reordering an `in` list - * is treated as a different filter, which only ever costs a restart. - */ -function canonicalJson(value: unknown): string { - if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null' - if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]` - const entries = Object.entries(value as Record) - .filter(([, entry]) => entry !== undefined) - .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`).join(',')}}` -} - /** * Fingerprint of the filters a page was produced under, or `undefined` for an - * unfiltered read. Hashed rather than embedded: a predicate tree can be up to - * the request-body ceiling, and the cursor has to stay a short opaque token. - * SHA-256 over the canonical form, so a caller cannot cheaply construct a second - * predicate that replays another sequence's offsets. + * unfiltered read. Canonicalized and hashed through `lib/api/cursor-binding`, + * the same module the v2 list codecs bind through, so a filter stamp means the + * same thing on every paginated surface. */ export function canonicalFilterKey( scope: Pick @@ -87,8 +71,7 @@ export function canonicalFilterKey( const predicate = scope.predicate ?? undefined const filter = scope.filter && Object.keys(scope.filter).length > 0 ? scope.filter : undefined if (!predicate && !filter) return undefined - const canonical = canonicalJson(predicate ? { predicate } : { filter }) - return createHash('sha256').update(canonical).digest('base64url').slice(0, 22) + return fingerprint(canonicalJson(predicate ? { predicate } : { filter })) } /** diff --git a/apps/sim/lib/workspace-files/application/list-workspace-files.ts b/apps/sim/lib/workspace-files/application/list-workspace-files.ts index 71973e59e34..2358e52f6f5 100644 --- a/apps/sim/lib/workspace-files/application/list-workspace-files.ts +++ b/apps/sim/lib/workspace-files/application/list-workspace-files.ts @@ -27,7 +27,6 @@ export interface QueryWorkspaceFilePageInput { sortOrder: 'asc' | 'desc' limit: number after?: CursorKey[] - cursorSort: string } async function resolveListWorkspaceFileContext(workspaceId: string) { @@ -84,6 +83,6 @@ export const queryWorkspaceFilePage = defineAuthorizedWorkspaceFileUseCase({ limit: input.limit, after: input.after, }) - return { files, nextKeys, cursorSort: input.cursorSort } + return { files, nextKeys } }, }) From 44bd4428eaf9d6faddc118c4208a1936007af835 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 17:14:24 -0700 Subject: [PATCH 12/56] fix(v2): authorize HEAD probes and declare every v2 query schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ways the v2 surface answered a request it had not checked. `headSafe: false` exists so a HEAD cannot fire the side effect its GET performs — an outbound MCP discovery, a FILE_DOWNLOADED audit event, a WORKFLOW_EXPORTED audit event. The short-circuit sat between admission and parsing, so it returned a bodiless 200 before resource authorization ran at all: authorization lives inside the use case, and the use case was exactly what the short-circuit skipped. Any valid API key drew 200 for a denied principal kind, a nonexistent id, another tenant's workspace, and a request missing a required param, while the GET beside it answered 403 or 404. That is an existence oracle over MCP server ids, file ids, and workflow ids. `OperationUseCase` gains an optional `authorize()` that runs the phase before the business transaction — allowed-principal check, canonical load, asserted-scope comparison, current access check — and stops. `defineAuthorizedWorkspaceUseCase` shares one implementation between it and `execute`, so the two cannot answer differently. A HEAD on a not-head-safe route is now admitted, parsed, and authorized like the GET, rendering refusals through the route's own error policy, then answered bodiless. The builders refuse at definition time to pair `headSafe: false` with a use case that has no `authorize`, so the next such route is a boot failure rather than a silent 200. Separately, `parseRequest` validates the query slice only when the contract declares one, so an omitted `query` means "never look at the query string" rather than "takes no query params". 69 v2 contracts omitted it and accepted anything: `?bogus=1` was a 200 on `GET /workflows/{id}` and a 400 on every list. They now declare `noInputSchema`, and 8 more contracts that declared a query without `.strict()` are tightened. A sweep over the contracts tree is the enforcement — a compile-time gate on `defineRouteContract` was tried and reverted because the required intersection collapses inference of the sibling generics. Four route tests appended `?workspaceId=` to a PATCH/PUT that reads it from the body; that copy was being silently dropped and is now a 400. The generated specs are byte-identical: the OpenAPI generator learns that a slice declaring no keys publishes no parameters. --- .../api/v2/custom-tools/[id]/route.test.ts | 26 ++-- .../app/api/v2/files/[fileId]/route.test.ts | 52 +++++++ apps/sim/app/api/v2/files/[fileId]/route.ts | 11 +- apps/sim/app/api/v2/lib/response.ts | 15 +- .../app/api/v2/mcp-servers/[id]/route.test.ts | 26 ++-- .../v2/mcp-servers/[id]/tools/route.test.ts | 54 ++++++- .../api/v2/mcp-servers/[id]/tools/route.ts | 12 +- .../app/api/v2/secrets/[name]/route.test.ts | 27 ++-- apps/sim/app/api/v2/skills/[id]/route.test.ts | 26 ++-- .../v2/workflows/[id]/export/route.test.ts | 11 ++ .../app/api/v2/workflows/[id]/export/route.ts | 5 + apps/sim/lib/api/contracts/types.ts | 17 +++ apps/sim/lib/api/contracts/upload-sessions.ts | 16 +- .../contracts/v2/__tests__/contract-sweep.ts | 84 +++++++++++ .../v2/__tests__/cross-cutting.test.ts | 15 +- .../v2/__tests__/list-pagination.test.ts | 23 +-- .../v2/__tests__/query-declaration.test.ts | 100 +++++++++++++ apps/sim/lib/api/contracts/v2/custom-tools.ts | 4 +- apps/sim/lib/api/contracts/v2/files.ts | 11 ++ apps/sim/lib/api/contracts/v2/knowledge.ts | 10 +- apps/sim/lib/api/contracts/v2/logs.ts | 2 + apps/sim/lib/api/contracts/v2/mcp-servers.ts | 3 + .../api/contracts/v2/openapi/files-audit.ts | 10 ++ .../lib/api/contracts/v2/openapi/knowledge.ts | 8 + apps/sim/lib/api/contracts/v2/openapi/logs.ts | 1 + .../lib/api/contracts/v2/openapi/resources.ts | 8 + .../lib/api/contracts/v2/openapi/tables.ts | 25 ++++ .../lib/api/contracts/v2/openapi/workflows.ts | 16 ++ apps/sim/lib/api/contracts/v2/secrets.ts | 3 +- apps/sim/lib/api/contracts/v2/skills.ts | 4 +- apps/sim/lib/api/contracts/v2/tables.ts | 35 ++++- apps/sim/lib/api/contracts/v2/workflows.ts | 17 +++ apps/sim/lib/api/contracts/v2/workspaces.ts | 3 +- .../api/server/routes/v2-binary-route.test.ts | 69 ++++++++- .../lib/api/server/routes/v2-binary-route.ts | 36 ++++- .../api/server/routes/v2-json-route.test.ts | 137 +++++++++++++++++- .../lib/api/server/routes/v2-json-route.ts | 92 +++++++++++- .../authorized-workspace-use-case.ts | 66 ++++++--- apps/sim/lib/core/application/operation.ts | 22 +++ scripts/openapi/generator.ts | 20 +++ 40 files changed, 987 insertions(+), 135 deletions(-) create mode 100644 apps/sim/lib/api/contracts/v2/__tests__/contract-sweep.ts create mode 100644 apps/sim/lib/api/contracts/v2/__tests__/query-declaration.test.ts diff --git a/apps/sim/app/api/v2/custom-tools/[id]/route.test.ts b/apps/sim/app/api/v2/custom-tools/[id]/route.test.ts index 749a9086b9b..ca4784712e4 100644 --- a/apps/sim/app/api/v2/custom-tools/[id]/route.test.ts +++ b/apps/sim/app/api/v2/custom-tools/[id]/route.test.ts @@ -93,18 +93,22 @@ const tool = { } const context = { params: Promise.resolve({ id: tool.id }) } +/** + * The read and delete verbs scope themselves with `?workspaceId=`; the write + * verb carries `workspaceId` in its body. Sending the query copy on a write is + * now a 400 rather than a silently dropped key, so the helper only appends it + * where the contract declares it. + */ function request(method: 'GET' | 'PATCH' | 'DELETE', body?: unknown) { - return new NextRequest( - `http://localhost:3000/api/v2/custom-tools/${tool.id}?workspaceId=${WORKSPACE_ID}`, - { - method, - headers: { - 'x-api-key': 'key', - ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), - }, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), - } - ) + const query = method === 'PATCH' ? '' : `?workspaceId=${WORKSPACE_ID}` + return new NextRequest(`http://localhost:3000/api/v2/custom-tools/${tool.id}${query}`, { + method, + headers: { + 'x-api-key': 'key', + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }) } describe('/api/v2/custom-tools/[id]', () => { diff --git a/apps/sim/app/api/v2/files/[fileId]/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/route.test.ts index d4405e2afd0..7efbab3d18b 100644 --- a/apps/sim/app/api/v2/files/[fileId]/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/route.test.ts @@ -15,6 +15,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ download: vi.fn(), + authorizeDownload: vi.fn(), rename: vi.fn(), deleteFile: vi.fn(), getUserEmailsByIds: vi.fn(), @@ -24,6 +25,7 @@ vi.mock('@/lib/workspace-files/application/download-workspace-file', () => ({ downloadWorkspaceFileStream: { operation: { id: 'files.download', minimumRole: 'read', workspaceApiKey: 'allow' }, execute: mocks.download, + authorize: mocks.authorizeDownload, }, })) @@ -72,6 +74,12 @@ const auth = { keyType: 'workspace' as const, } +function headRequest(query: string): NextRequest { + return new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}?${query}`, { + method: 'HEAD', + }) +} + function fileRecord(overrides: Record = {}) { return { id: FILE_ID, @@ -109,6 +117,50 @@ describe('v2 single-file routes', () => { deleted: true, }) mocks.getUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) + mocks.authorizeDownload.mockResolvedValue(undefined) + }) + + /** + * The download `HEAD` short-circuit used to sit between admission and parsing, + * so it answered before the workspace-scoped file resolution that lives in the + * use case. Any valid API key therefore drew a bodiless 200 for a file id it + * cannot reach, while the `GET` for the same URL answered 404. These pin the + * probe to the answer the download gives, and to still not auditing one. + */ + it('answers an authorized HEAD bodiless without auditing a download', async () => { + const response = await GET(headRequest(`workspaceId=${WORKSPACE_ID}`), context) + + expect(response.status).toBe(200) + expect(await response.text()).toBe('') + expect(mocks.download).not.toHaveBeenCalled() + expect(mocks.authorizeDownload).toHaveBeenCalledOnce() + }) + + it('does not confirm a file the caller cannot reach', async () => { + mocks.authorizeDownload.mockRejectedValueOnce(new NoWorkspaceAccessError()) + + const response = await GET(headRequest('workspaceId=someone-elses-workspace'), context) + + expect(response.status).toBe(404) + expect(mocks.download).not.toHaveBeenCalled() + }) + + it('does not confirm a file id that does not exist', async () => { + mocks.authorizeDownload.mockRejectedValueOnce( + new OrchestrationError('not_found', 'File not found') + ) + + const response = await GET(headRequest(`workspaceId=${WORKSPACE_ID}`), context) + + expect(response.status).toBe(404) + expect(mocks.download).not.toHaveBeenCalled() + }) + + it('rejects a HEAD missing the required workspaceId instead of answering 200', async () => { + const response = await GET(headRequest(''), context) + + expect(response.status).toBe(400) + expect(mocks.authorizeDownload).not.toHaveBeenCalled() }) it('downloads bytes through the binary adapter with operation rate headers', async () => { diff --git a/apps/sim/app/api/v2/files/[fileId]/route.ts b/apps/sim/app/api/v2/files/[fileId]/route.ts index 648385c91ef..83f4ca6f350 100644 --- a/apps/sim/app/api/v2/files/[fileId]/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/route.ts @@ -32,10 +32,13 @@ export const revalidate = 0 * Downloading is not a safe read: it records a `FILE_DOWNLOADED` audit event and * pulls the bytes out of object storage. Next aliases `HEAD` onto `GET`, and RFC * 9110 §9.2.1 defines `HEAD` as safe, so this route declares itself not - * head-safe — a `HEAD` is authenticated and rate-limited, then answered bodiless - * without auditing or fetching. Without that, an uptime monitor or link checker - * walking the documented URL list would fabricate a download event on every - * probe, for a download that never happened. + * head-safe. A `HEAD` is admitted, parsed, and authorized through + * `downloadWorkspaceFileStream.authorize` — the same workspace-scoped file + * resolution and access check the `GET` performs — then answered bodiless + * without auditing or fetching. Without the not-head-safe declaration an uptime + * monitor walking the documented URL list would fabricate a download event on + * every probe; without the authorization step the probe would instead confirm a + * file id the caller has no right to know exists. */ export const GET = defineV2BinaryRoute({ contract: v2DownloadFileContract, diff --git a/apps/sim/app/api/v2/lib/response.ts b/apps/sim/app/api/v2/lib/response.ts index 8228a8ee3d5..e9df47c29ed 100644 --- a/apps/sim/app/api/v2/lib/response.ts +++ b/apps/sim/app/api/v2/lib/response.ts @@ -121,15 +121,22 @@ function successHeaders(options: V2SuccessOptions): Record { } /** - * The bodiless 200 a `HEAD` receives from a route whose `GET` is not safe. + * The bodiless 200 a `HEAD` receives from a route whose `GET` is not safe, once + * that `HEAD` has been authorized. * * RFC 9110 §9.3.2 lets Next alias `HEAD` onto `GET` only because §9.2.1 defines * `HEAD` as safe — "essentially read-only". A `GET` that opens an outbound * connection or writes a row breaks that assumption, and an uptime monitor or * link checker walking the documented URL list would drive those effects - * invisibly on every probe. Such a route answers the authorization and - * rate-limit questions and stops there. `HEAD` carries no body in any case, so - * nothing the caller can observe is fabricated. + * invisibly on every probe. Such a route runs everything the `GET` runs up to + * and including resource authorization, then stops before the business phase. + * + * The 200 here is unconditional **by construction**: the v2 route builders only + * reach this function after `useCase.authorize` has resolved, and render every + * rejection through the route's own error policy. Calling it before that check — + * as the builders originally did, straight after admission — turns it into an + * existence oracle, because a valid API key for any workspace then draws a 200 + * for a resource that same key's `GET` answers 403 or 404 for. */ export function v2HeadNoEffect(options: V2SuccessOptions = {}): NextResponse { return new NextResponse(null, { status: options.status ?? 200, headers: successHeaders(options) }) diff --git a/apps/sim/app/api/v2/mcp-servers/[id]/route.test.ts b/apps/sim/app/api/v2/mcp-servers/[id]/route.test.ts index 167c927d32f..536f5b0adb4 100644 --- a/apps/sim/app/api/v2/mcp-servers/[id]/route.test.ts +++ b/apps/sim/app/api/v2/mcp-servers/[id]/route.test.ts @@ -84,18 +84,22 @@ const server = { } as McpServerRow const context = { params: Promise.resolve({ id: server.id }) } +/** + * The read and delete verbs scope themselves with `?workspaceId=`; the write + * verb carries `workspaceId` in its body. Sending the query copy on a write is + * now a 400 rather than a silently dropped key, so the helper only appends it + * where the contract declares it. + */ function request(method: 'GET' | 'PATCH' | 'DELETE', body?: unknown) { - return new NextRequest( - `http://localhost:3000/api/v2/mcp-servers/${server.id}?workspaceId=${WORKSPACE_ID}`, - { - method, - headers: { - 'x-api-key': 'key', - ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), - }, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), - } - ) + const query = method === 'PATCH' ? '' : `?workspaceId=${WORKSPACE_ID}` + return new NextRequest(`http://localhost:3000/api/v2/mcp-servers/${server.id}${query}`, { + method, + headers: { + 'x-api-key': 'key', + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }) } describe('/api/v2/mcp-servers/[id]', () => { diff --git a/apps/sim/app/api/v2/mcp-servers/[id]/tools/route.test.ts b/apps/sim/app/api/v2/mcp-servers/[id]/tools/route.test.ts index 9a028fb0a89..13fbec01303 100644 --- a/apps/sim/app/api/v2/mcp-servers/[id]/tools/route.test.ts +++ b/apps/sim/app/api/v2/mcp-servers/[id]/tools/route.test.ts @@ -15,6 +15,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ discover: vi.fn(), + authorizeDiscover: vi.fn(), })) vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) @@ -32,10 +33,12 @@ vi.mock('@/lib/mcp/application/use-cases', () => ({ discoverMcpServerToolsUseCase: { operation: { id: 'mcp_servers.tools.discover' }, execute: mocks.discover, + authorize: mocks.authorizeDiscover, }, })) -import { WorkspaceApiKeyAuthorizationError } from '@/lib/core/application' +import { NoWorkspaceAccessError, WorkspaceApiKeyAuthorizationError } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { McpConnectionError, McpOauthAuthorizationRequiredError, @@ -82,6 +85,7 @@ describe('/api/v2/mcp-servers/[id]/tools', () => { v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.discover.mockResolvedValue({ tools: [TOOL] }) + mocks.authorizeDiscover.mockResolvedValue(undefined) }) it('returns a server tool inventory as a single page', async () => { @@ -120,6 +124,54 @@ describe('/api/v2/mcp-servers/[id]/tools', () => { expect(response.status).toBe(200) expect(await response.text()).toBe('') expect(mocks.discover).not.toHaveBeenCalled() + expect(mocks.authorizeDiscover).toHaveBeenCalledOnce() + }) + + /** + * The `HEAD` short-circuit used to sit between admission and parsing, so it + * answered before resource authorization ever ran — which lives in the use + * case. Any valid API key therefore drew a bodiless 200 for a server id in a + * workspace it cannot read, a server id that does not exist, and a principal + * kind this operation refuses outright, while the `GET` for the same URL + * answered 403 or 404. These four pin the probe to the answer the `GET` gives. + */ + it('does not confirm a server to a principal kind the operation refuses', async () => { + mocks.authorizeDiscover.mockRejectedValueOnce(new WorkspaceApiKeyAuthorizationError()) + + const response = await GET(request(`workspaceId=${WORKSPACE_ID}`, 'HEAD'), { ...context }) + + expect(response.status).toBe(403) + expect(mocks.discover).not.toHaveBeenCalled() + }) + + it('does not confirm a server id that does not exist', async () => { + mocks.authorizeDiscover.mockRejectedValueOnce( + new OrchestrationError('not_found', 'MCP server not found') + ) + + const response = await GET(request(`workspaceId=${WORKSPACE_ID}`, 'HEAD'), { ...context }) + + expect(response.status).toBe(404) + expect(mocks.discover).not.toHaveBeenCalled() + }) + + it('does not confirm a server in a workspace the caller cannot read', async () => { + mocks.authorizeDiscover.mockRejectedValueOnce(new NoWorkspaceAccessError()) + + const response = await GET(request('workspaceId=someone-elses-workspace', 'HEAD'), { + ...context, + }) + + expect(response.status).toBe(404) + expect(mocks.discover).not.toHaveBeenCalled() + }) + + it('rejects a HEAD missing the required workspaceId instead of answering 200', async () => { + const response = await GET(request('', 'HEAD'), { ...context }) + + expect(response.status).toBe(400) + expect(mocks.authorizeDiscover).not.toHaveBeenCalled() + expect(mocks.discover).not.toHaveBeenCalled() }) it('rejects a query param it does not implement', async () => { diff --git a/apps/sim/app/api/v2/mcp-servers/[id]/tools/route.ts b/apps/sim/app/api/v2/mcp-servers/[id]/tools/route.ts index 137ef0fab8e..ea4fffa2230 100644 --- a/apps/sim/app/api/v2/mcp-servers/[id]/tools/route.ts +++ b/apps/sim/app/api/v2/mcp-servers/[id]/tools/route.ts @@ -17,10 +17,14 @@ export const revalidate = 0 * Discovery is not a safe read: it opens a live connection to the registered * endpoint and records the outcome on the server row. Next aliases `HEAD` onto * `GET`, and RFC 9110 §9.2.1 defines `HEAD` as safe, so this route declares - * itself not head-safe — a `HEAD` is authenticated and rate-limited, then - * answered bodiless without connecting or writing. Without that, an uptime - * monitor or link checker walking the documented URL list would drive outbound - * third-party traffic and mutate rows on every probe. + * itself not head-safe. A `HEAD` is admitted, parsed, and authorized through + * `discoverMcpServerToolsUseCase.authorize` — the same principal-kind check, + * server resolution, and workspace access check the `GET` performs — then + * answered bodiless without connecting or writing. Without the not-head-safe + * declaration an uptime monitor walking the documented URL list would drive + * outbound third-party traffic and mutate rows on every probe; without the + * authorization step the probe would instead confirm that a server id exists in + * a workspace the caller cannot read. */ export const GET = defineV2JsonRoute({ contract: v2ListMcpServerToolsContract, diff --git a/apps/sim/app/api/v2/secrets/[name]/route.test.ts b/apps/sim/app/api/v2/secrets/[name]/route.test.ts index 189328f2217..7f49d462fb8 100644 --- a/apps/sim/app/api/v2/secrets/[name]/route.test.ts +++ b/apps/sim/app/api/v2/secrets/[name]/route.test.ts @@ -86,19 +86,22 @@ const secret = { } const context = { params: Promise.resolve({ name: SECRET_NAME }) } +/** + * The read and delete verbs scope themselves with `?workspaceId=`; the write + * verb carries `workspaceId` in its body. Sending the query copy on a write is + * now a 400 rather than a silently dropped key, so the helper only appends it + * where the contract declares it. + */ function request(method: 'PUT' | 'DELETE', body?: unknown) { - const scope = method === 'DELETE' ? '&scope=workspace' : '' - return new NextRequest( - `http://localhost:3000/api/v2/secrets/${SECRET_NAME}?workspaceId=${WORKSPACE_ID}${scope}`, - { - method, - headers: { - 'x-api-key': 'key', - ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), - }, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), - } - ) + const query = method === 'DELETE' ? `?workspaceId=${WORKSPACE_ID}&scope=workspace` : '' + return new NextRequest(`http://localhost:3000/api/v2/secrets/${SECRET_NAME}${query}`, { + method, + headers: { + 'x-api-key': 'key', + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }) } describe('/api/v2/secrets/[name]', () => { diff --git a/apps/sim/app/api/v2/skills/[id]/route.test.ts b/apps/sim/app/api/v2/skills/[id]/route.test.ts index d98298af838..734d4b7da28 100644 --- a/apps/sim/app/api/v2/skills/[id]/route.test.ts +++ b/apps/sim/app/api/v2/skills/[id]/route.test.ts @@ -86,18 +86,22 @@ const skill = { } const context = { params: Promise.resolve({ id: skill.id }) } +/** + * The read and delete verbs scope themselves with `?workspaceId=`; the write + * verb carries `workspaceId` in its body. Sending the query copy on a write is + * now a 400 rather than a silently dropped key, so the helper only appends it + * where the contract declares it. + */ function request(method: 'GET' | 'PATCH' | 'DELETE', body?: unknown) { - return new NextRequest( - `http://localhost:3000/api/v2/skills/${skill.id}?workspaceId=${WORKSPACE_ID}`, - { - method, - headers: { - 'x-api-key': 'key', - ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), - }, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), - } - ) + const query = method === 'PATCH' ? '' : `?workspaceId=${WORKSPACE_ID}` + return new NextRequest(`http://localhost:3000/api/v2/skills/${skill.id}${query}`, { + method, + headers: { + 'x-api-key': 'key', + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }) } describe('/api/v2/skills/[id]', () => { diff --git a/apps/sim/app/api/v2/workflows/[id]/export/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/export/route.test.ts index 8de47072a7a..872932e6ba1 100644 --- a/apps/sim/app/api/v2/workflows/[id]/export/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/export/route.test.ts @@ -40,4 +40,15 @@ describe('/api/v2/workflows/[id]/export route definition', () => { it('does not run the audited export for a HEAD probe', () => { expect(GET).toMatchObject({ headSafe: false }) }) + + /** + * Not running the export is only half of it. The `HEAD` must still resolve the + * workflow and check access, or the probe answers 200 for an id the caller's + * `GET` would conceal as a 404 — an existence oracle over every workspace's + * workflow ids. The builder refuses at definition time to pair + * `headSafe: false` with a use case that cannot answer that on its own. + */ + it('exposes an authorization phase the HEAD probe can run without exporting', () => { + expect(typeof exportWorkflow.authorize).toBe('function') + }) }) diff --git a/apps/sim/app/api/v2/workflows/[id]/export/route.ts b/apps/sim/app/api/v2/workflows/[id]/export/route.ts index ad3af5d0518..6eabdcf0be0 100644 --- a/apps/sim/app/api/v2/workflows/[id]/export/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/export/route.ts @@ -11,6 +11,11 @@ export const revalidate = 0 * `headSafe: false` because the use case projects a `WORKFLOW_EXPORTED` audit * event. Letting Next alias `HEAD` onto this `GET` would record an export that * handed the caller no bytes. + * + * A `HEAD` still runs `exportWorkflow.authorize`, so it resolves the workflow + * and checks access exactly as the `GET` does and renders any rejection through + * the same concealing error policy. Skipping that made the probe an existence + * oracle for workflow ids across every workspace. */ export const GET = defineV2JsonRoute({ contract: v2ExportWorkflowContract, diff --git a/apps/sim/lib/api/contracts/types.ts b/apps/sim/lib/api/contracts/types.ts index 91d756f0907..713801f75f6 100644 --- a/apps/sim/lib/api/contracts/types.ts +++ b/apps/sim/lib/api/contracts/types.ts @@ -79,6 +79,23 @@ export type AnyApiRouteContract = ApiRouteContract< ApiSchema | undefined > +/** + * A `/api/v2/` contract must always declare `query`, because `parseRequest` + * validates the query slice only when one is present — an omitted `query` means + * "never look at the query string", not "this endpoint takes no query params", + * and `?bogus=1` then answers 200 for a request the server did not honour. An + * endpoint that genuinely takes none says so with `query: noInputSchema` + * (`z.object({}).strict()`) from `./primitives`. + * + * That rule is enforced by the `query-declaration` sweep under + * `contracts/v2/__tests__`, not by this signature. Making `query` conditionally + * required on a `/api/v2/` path needs the parameter type to become an + * intersection, and the intersection collapses inference of the sibling + * generics: `TParams`, `TBody`, and `THeaders` start resolving to `undefined`, + * which breaks the OpenAPI documents that read them back off the contract. The + * sweep is also the broader guarantee — it walks every contract in the tree, + * including ones a caller never passes through this function directly. + */ export function defineRouteContract< TParams extends ApiSchema | undefined = undefined, TQuery extends ApiSchema | undefined = undefined, diff --git a/apps/sim/lib/api/contracts/upload-sessions.ts b/apps/sim/lib/api/contracts/upload-sessions.ts index 2f4fdbbe1ac..a60749ac704 100644 --- a/apps/sim/lib/api/contracts/upload-sessions.ts +++ b/apps/sim/lib/api/contracts/upload-sessions.ts @@ -1,5 +1,10 @@ import { z } from 'zod' -import { folderIdSchema, workflowIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { + folderIdSchema, + noInputSchema, + workflowIdSchema, + workspaceIdSchema, +} from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { v2FileSchema } from '@/lib/api/contracts/v2/files' import { v2DataResponse } from '@/lib/api/contracts/v2/shared' @@ -198,9 +203,11 @@ export const localUploadPartParamsSchema = z.object({ partNumber: z.coerce.number().int().min(1), }) -export const localUploadPartQuerySchema = z.object({ - token: z.string().min(1, 'token is required'), -}) +export const localUploadPartQuerySchema = z + .object({ + token: z.string().min(1, 'token is required'), + }) + .strict() export const localUploadPartContract = defineRouteContract({ method: 'PUT', @@ -213,6 +220,7 @@ export const localUploadPartContract = defineRouteContract({ export const localPutUploadContract = defineRouteContract({ method: 'PUT', path: '/api/v2/uploads/[uploadId]', + query: noInputSchema, params: internalFileUploadParamsSchema, headers: v2UploadTokenHeadersSchema, response: { mode: 'empty', status: 204 }, diff --git a/apps/sim/lib/api/contracts/v2/__tests__/contract-sweep.ts b/apps/sim/lib/api/contracts/v2/__tests__/contract-sweep.ts new file mode 100644 index 00000000000..4d6c695e410 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/__tests__/contract-sweep.ts @@ -0,0 +1,84 @@ +import { readdirSync } from 'node:fs' +import path from 'node:path' +import type { z } from 'zod' + +/** + * Shared enumeration of every route contract the v2 surface publishes. + * + * The sweeps that assert a cross-cutting v2 promise all need the same thing + * first: every contract, found by walking the tree rather than by a hand-kept + * list. A hand-kept list is what let the original fractional-`limit` defect + * survive on the one endpoint nobody remembered to add, and the same reasoning + * applies to anything else asserted "for every v2 contract". + * + * Contracts are keyed by `METHOD /path`, so a contract re-exported from a barrel + * is counted once. + */ + +const CONTRACTS_DIR = path.resolve(import.meta.dirname, '..', '..') + +export interface SweptContract { + method: string + path: string + params?: z.ZodType + query?: z.ZodType + body?: z.ZodType + headers?: z.ZodType + response?: { mode: string; schema?: z.ZodType } +} + +export interface SweptContractEntry { + /** `METHOD /path`, the identity a route is documented under. */ + key: string + /** The exported binding name, so a failure names the symbol to edit. */ + name: string + contract: SweptContract +} + +function isContract(value: unknown): value is SweptContract { + return ( + !!value && + typeof value === 'object' && + typeof (value as SweptContract).method === 'string' && + typeof (value as SweptContract).path === 'string' && + typeof (value as SweptContract).response === 'object' + ) +} + +/** Every non-test `.ts` file under `lib/api/contracts`, deterministically ordered. */ +export function listContractFiles(dir: string = CONTRACTS_DIR): 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 +} + +/** + * Every contract whose path is under `/api/v2/`, first occurrence per key. + * + * Costs a few hundred dynamic imports, so callers memoize it for the file rather + * than repeating it per test. + */ +export async function sweepV2Contracts(): Promise { + const found = new Map() + for (const file of listContractFiles()) { + const mod = (await import(file)) as Record + for (const [name, value] of Object.entries(mod)) { + if (!isContract(value)) continue + if (!value.path.startsWith('/api/v2/')) continue + const key = `${value.method.toUpperCase()} ${value.path}` + if (found.has(key)) continue + found.set(key, { key, name, contract: value }) + } + } + return [...found.values()].sort((a, b) => a.key.localeCompare(b.key)) +} diff --git a/apps/sim/lib/api/contracts/v2/__tests__/cross-cutting.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/cross-cutting.test.ts index a1155c2f66b..845f035d3d4 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/cross-cutting.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/cross-cutting.test.ts @@ -139,7 +139,7 @@ describe('tables nested strictness', () => { * Every caller-authored knowledge and files/audit request slice must reject the * keys it does not declare. * - * These two families held the last non-strict slices in v2: four single-field + * These two families held the last non-strict *body* slices in v2: four single-field * `{ workspaceId }` query objects reused across 15 operations, and the knowledge * search body. Zod strips what it does not declare, so a caller that mis-spelt a * parameter got a 200 for a request the server never honoured — and on @@ -151,6 +151,11 @@ describe('tables nested strictness', () => { * Only `query` and `body` are swept. `params` are produced by the router from * the path pattern and `headers` are projected from the schema's own keys, so * neither carries a key the caller chose and neither can strip one. + * + * The `query` half is now also covered surface-wide by the query-declaration + * sweep, which walks the contracts tree rather than these two OpenAPI documents. + * This one stays because it is the only sweep over `body`, and because it is + * scoped to the families whose strictness regressed. */ describe('knowledge and files request-slice strictness', () => { const documents = [ @@ -172,8 +177,14 @@ describe('knowledge and files request-slice strictness', () => { ) ) + /** + * A count, so a document that stopped listing its routes cannot make every + * assertion below pass vacuously. It rises when a route gains a slice: it went + * 45 → 63 when the knowledge and files endpoints that take no query params + * started saying so with `noInputSchema` instead of omitting `query`. + */ it('sweeps every documented query and body slice', () => { - expect(slices.length).toBe(45) + expect(slices.length).toBe(63) }) it.each(slices)('%s rejects an undeclared key', (_name, schema) => { diff --git a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts index 06f92b4e5ce..c6b4dd40554 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts @@ -1,10 +1,9 @@ /** * @vitest-environment node */ -import { readdirSync } from 'node:fs' -import path from 'node:path' import { describe, expect, it } from 'vitest' import { z } from 'zod' +import { listContractFiles } from '@/lib/api/contracts/v2/__tests__/contract-sweep' import { MAX_SCHEMA_DEPTH, rejectsUnknownKeys, @@ -44,8 +43,6 @@ import { * no JSON envelope to classify. */ -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', @@ -253,22 +250,6 @@ function rejectsFractionalLimit(contract: ContractLike): boolean { return false } -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 @@ -291,7 +272,7 @@ function loadV2ListContracts(): Promise { async function sweepV2ListContracts(): Promise { const found = new Map() - for (const file of listContractFiles(CONTRACTS_DIR)) { + for (const file of listContractFiles()) { const mod = (await import(file)) as Record for (const [name, value] of Object.entries(mod)) { if (!isContract(value)) continue diff --git a/apps/sim/lib/api/contracts/v2/__tests__/query-declaration.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/query-declaration.test.ts new file mode 100644 index 00000000000..d053463c45e --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/__tests__/query-declaration.test.ts @@ -0,0 +1,100 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + type SweptContractEntry, + sweepV2Contracts, +} from '@/lib/api/contracts/v2/__tests__/contract-sweep' +import { rejectsUnknownKeys } from '@/lib/api/contracts/v2/__tests__/schema-introspection' + +/** + * Every v2 contract must declare a `query` schema, and it must be `.strict()`. + * + * `parseRequest` validates the query slice only when the contract declares one + * (`contract.query ? validate : skip`). A contract with no `query` therefore + * never validates the query string at all: `GET /api/v2/workflows/{id}?bogus=1` + * answered 200 while every v2 list answered 400 for the same shape. The caller + * learns nothing about the param the server ignored, which is the failure the + * lists' `.strict()` rule already exists to prevent — a request the server did + * not honour must not come back 200. + * + * The endpoints that take no query say so with `noInputSchema` + * (`z.object({}).strict()`) rather than by omission, because omission and "takes + * nothing" were indistinguishable — which is exactly how 69 of them ended up + * unvalidated without anyone deciding they should be. + * + * This sweep is the enforcement, not the 69-contract edit that accompanied it. A + * one-time edit leaves number 70 free to regress; walking the tree means a new + * contract fails here the moment it is written, and names itself in the failure. + * + * A compile-time gate on `defineRouteContract` was tried first and rejected: + * making `query` conditionally required on a `/api/v2/` path turns the parameter + * into an intersection, and the intersection collapses inference of `TParams`, + * `TBody`, and `THeaders` to `undefined`, breaking every OpenAPI document that + * reads them back off the contract. The sweep is also the wider net — it sees + * every contract in the tree, including any a type on that one function would + * never be asked about. + */ + +const SWEEP_TIMEOUT_MS = 60_000 + +let contractsPromise: Promise | null = null +function loadContracts(): Promise { + contractsPromise ??= sweepV2Contracts() + return contractsPromise +} + +describe('v2 query declaration', () => { + it( + 'declares a query schema on every contract, so no v2 endpoint skips query validation', + async () => { + const contracts = await loadContracts() + expect(contracts.length).toBeGreaterThan(100) + + const undeclared = contracts + .filter((entry) => !entry.contract.query) + .map((entry) => `${entry.key} (${entry.name})`) + + expect( + undeclared, + 'A v2 contract without a `query` schema accepts any query param silently: parseRequest skips the slice entirely when the contract declares none. Use `noInputSchema` from @/lib/api/contracts/primitives when the endpoint takes no query params. See .agents/skills/v2-api-conventions/SKILL.md.' + ).toEqual([]) + }, + SWEEP_TIMEOUT_MS + ) + + it('makes every declared v2 query reject a param it does not implement', async () => { + const contracts = await loadContracts() + + const nonStrict = contracts + .filter((entry) => entry.contract.query && rejectsUnknownKeys(entry.contract.query) !== true) + .map((entry) => `${entry.key} (${entry.name})`) + + expect( + nonStrict, + 'Zod strips unknown keys by default, so a non-strict query answers 200 for a request the server did not honour. Declare the query object `.strict()`. A `null` from the walk means the schema could not be introspected, which must fail rather than pass silently.' + ).toEqual([]) + }) + + /** + * The endpoints that take no query must accept a bare request and reject a + * decorated one. Both halves matter: a schema that rejected the empty query + * would break every existing caller, and one that accepted an unknown key + * would be the omission this rule replaced, just spelled out. + */ + it('lets an endpoint that takes no query accept none and refuse an invented one', async () => { + const contracts = await loadContracts() + const takesNoQuery = contracts.filter( + (entry) => entry.contract.query?.safeParse({}).success === true + ) + + expect(takesNoQuery.length).toBeGreaterThan(0) + for (const entry of takesNoQuery) { + expect( + entry.contract.query?.safeParse({ bogus: '1' }).success, + `${entry.key} accepts an undeclared query param instead of rejecting it` + ).toBe(false) + } + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/custom-tools.ts b/apps/sim/lib/api/contracts/v2/custom-tools.ts index 81bfcbaee5e..6311c3f9366 100644 --- a/apps/sim/lib/api/contracts/v2/custom-tools.ts +++ b/apps/sim/lib/api/contracts/v2/custom-tools.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { noInputSchema, nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { customToolFunctionParametersSchema, customToolSchemaSchema, @@ -182,6 +182,7 @@ export const v2ListCustomToolsContract = defineRouteContract({ export const v2CreateCustomToolContract = defineRouteContract({ method: 'POST', path: '/api/v2/custom-tools', + query: noInputSchema, body: v2CreateCustomToolBodySchema, response: { mode: 'json', @@ -204,6 +205,7 @@ export const v2GetCustomToolContract = defineRouteContract({ export const v2UpdateCustomToolContract = defineRouteContract({ method: 'PATCH', path: '/api/v2/custom-tools/[id]', + query: noInputSchema, params: v2CustomToolParamsSchema, body: v2UpdateCustomToolBodySchema, response: { diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts index 745b0ddf2db..9d27d322321 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { isCanonicalBase64, + noInputSchema, workspaceFileIdSchema, workspaceFileNameSchema, workspaceIdSchema, @@ -438,6 +439,7 @@ export const v2ListFileFoldersContract = defineRouteContract({ export const v2CreateFileFolderContract = defineRouteContract({ method: 'POST', path: '/api/v2/files/folders', + query: noInputSchema, body: v2CreateFolderBodySchema, response: { mode: 'json', schema: v2DataResponse(v2FolderSchema), status: 201 }, }) @@ -445,6 +447,7 @@ export const v2CreateFileFolderContract = defineRouteContract({ export const v2RelocateFileFolderContract = defineRouteContract({ method: 'PATCH', path: '/api/v2/files/folders', + query: noInputSchema, body: v2RelocateFolderBodySchema, response: { mode: 'json', schema: v2DataResponse(v2FolderSchema) }, }) @@ -535,6 +538,7 @@ export const v2ListFilesContract = defineRouteContract({ export const v2CreateFileContract = defineRouteContract({ method: 'POST', path: '/api/v2/files', + query: noInputSchema, body: v2CreateFileBodySchema, response: { mode: 'json', @@ -546,6 +550,7 @@ export const v2CreateFileContract = defineRouteContract({ export const v2CreateFileUploadContract = defineRouteContract({ method: 'POST', path: '/api/v2/files/uploads', + query: noInputSchema, body: v2CreateFileUploadBodySchema, response: { mode: 'json', schema: v2DataResponse(v2CreateFileUploadDataSchema), status: 201 }, }) @@ -602,6 +607,7 @@ export const v2GetFileContract = defineRouteContract({ export const v2RenameFileContract = defineRouteContract({ method: 'PATCH', path: '/api/v2/files/[fileId]', + query: noInputSchema, params: v2FileParamsSchema, body: v2RenameFileBodySchema, response: { @@ -624,6 +630,7 @@ export const v2DeleteFileContract = defineRouteContract({ export const v2RestoreFileContract = defineRouteContract({ method: 'POST', path: '/api/v2/files/[fileId]/restore', + query: noInputSchema, params: v2FileParamsSchema, body: v2RestoreFileBodySchema, response: { @@ -635,6 +642,7 @@ export const v2RestoreFileContract = defineRouteContract({ export const v2MoveFileItemsContract = defineRouteContract({ method: 'POST', path: '/api/v2/files/move', + query: noInputSchema, body: v2MoveFileItemsBodySchema, response: { mode: 'json', @@ -645,6 +653,7 @@ export const v2MoveFileItemsContract = defineRouteContract({ export const v2BulkDeleteFilesContract = defineRouteContract({ method: 'POST', path: '/api/v2/files/bulk-delete', + query: noInputSchema, body: v2BulkDeleteFilesBodySchema, response: { mode: 'json', @@ -676,6 +685,7 @@ export const v2GetFileShareContract = defineRouteContract({ export const v2UpsertFileShareContract = defineRouteContract({ method: 'PATCH', path: '/api/v2/files/[fileId]/share', + query: noInputSchema, params: v2FileParamsSchema, body: v2UpsertFileShareBodySchema, response: { @@ -687,6 +697,7 @@ export const v2UpsertFileShareContract = defineRouteContract({ export const v2UpdateFileContentContract = defineRouteContract({ method: 'PUT', path: '/api/v2/files/[fileId]/content', + query: noInputSchema, params: v2FileParamsSchema, body: v2UpdateFileContentBodySchema, response: { diff --git a/apps/sim/lib/api/contracts/v2/knowledge.ts b/apps/sim/lib/api/contracts/v2/knowledge.ts index bd10b93f731..356095f8304 100644 --- a/apps/sim/lib/api/contracts/v2/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/knowledge.ts @@ -6,7 +6,7 @@ import { knowledgeDocumentParamsSchema, nullableWireDateSchema, } from '@/lib/api/contracts/knowledge/shared' -import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { noInputSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { v1ChunkingConfigSchema, @@ -660,6 +660,7 @@ export const v2ListKnowledgeBasesContract = defineRouteContract({ export const v2CreateKnowledgeBaseContract = defineRouteContract({ method: 'POST', path: '/api/v2/knowledge', + query: noInputSchema, body: v2CreateKnowledgeBaseBodySchema, response: { mode: 'json', @@ -692,6 +693,7 @@ export const v2GetKnowledgeBaseContract = defineRouteContract({ export const v2UpdateKnowledgeBaseContract = defineRouteContract({ method: 'PATCH', path: '/api/v2/knowledge/[id]', + query: noInputSchema, params: v2KnowledgeBaseParamsSchema, body: v2UpdateKnowledgeBaseBodySchema, response: { @@ -748,6 +750,7 @@ export const v2ListKnowledgeFoldersContract = defineRouteContract({ export const v2CreateKnowledgeFolderContract = defineRouteContract({ method: 'POST', path: '/api/v2/knowledge/folders', + query: noInputSchema, body: v2CreateFolderBodySchema, response: { mode: 'json', schema: v2DataResponse(v2FolderSchema), status: 201 }, }) @@ -755,6 +758,7 @@ export const v2CreateKnowledgeFolderContract = defineRouteContract({ export const v2RelocateKnowledgeFolderContract = defineRouteContract({ method: 'PATCH', path: '/api/v2/knowledge/folders', + query: noInputSchema, body: v2RelocateFolderBodySchema, response: { mode: 'json', schema: v2DataResponse(v2FolderSchema) }, }) @@ -843,6 +847,7 @@ export type V2KnowledgeSearchBody = z.input export const v2SearchKnowledgeContract = defineRouteContract({ method: 'POST', path: '/api/v2/knowledge/search', + query: noInputSchema, body: v2KnowledgeSearchBodySchema, response: { mode: 'json', @@ -962,6 +967,7 @@ export const v2UploadKnowledgeDocumentContract = defineRouteContract({ export const v2CreateKnowledgeDocumentUploadContract = defineRouteContract({ method: 'POST', path: '/api/v2/knowledge/[id]/documents/uploads', + query: noInputSchema, params: v2KnowledgeBaseParamsSchema, body: v2CreateKnowledgeDocumentUploadBodySchema, response: { @@ -1236,6 +1242,7 @@ const v2UpdateKnowledgeDocumentDataSchema = z.union([ export const v2UpdateKnowledgeDocumentContract = defineRouteContract({ method: 'PATCH', path: '/api/v2/knowledge/[id]/documents/[documentId]', + query: noInputSchema, params: v2KnowledgeDocumentParamsSchema, body: v2UpdateKnowledgeDocumentBodySchema, response: { @@ -1338,6 +1345,7 @@ export const v2BulkKnowledgeDocumentsDataSchema = z export const v2BulkUpdateKnowledgeDocumentsContract = defineRouteContract({ method: 'PATCH', path: '/api/v2/knowledge/[id]/documents', + query: noInputSchema, params: v2KnowledgeBaseParamsSchema, body: v2BulkKnowledgeDocumentsBodySchema, response: { diff --git a/apps/sim/lib/api/contracts/v2/logs.ts b/apps/sim/lib/api/contracts/v2/logs.ts index a5f554e085a..6d388c424e0 100644 --- a/apps/sim/lib/api/contracts/v2/logs.ts +++ b/apps/sim/lib/api/contracts/v2/logs.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { traceSpansSchema } from '@/lib/api/contracts/logs' import { booleanQueryFlagSchema, + noInputSchema, runIdSchema, workspaceIdSchema, } from '@/lib/api/contracts/primitives' @@ -308,6 +309,7 @@ export const v2ListLogsContract = defineRouteContract({ export const v2GetLogContract = defineRouteContract({ method: 'GET', path: '/api/v2/logs/[runId]', + query: noInputSchema, params: v2LogParamsSchema, response: { mode: 'json', diff --git a/apps/sim/lib/api/contracts/v2/mcp-servers.ts b/apps/sim/lib/api/contracts/v2/mcp-servers.ts index 52015f9750b..4d80caa50e8 100644 --- a/apps/sim/lib/api/contracts/v2/mcp-servers.ts +++ b/apps/sim/lib/api/contracts/v2/mcp-servers.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { mcpAuthTypeSchema, mcpServerSchema, mcpTransportSchema } from '@/lib/api/contracts/mcp' import { booleanQueryFlagSchema, + noInputSchema, nonEmptyIdSchema, workspaceIdSchema, } from '@/lib/api/contracts/primitives' @@ -369,6 +370,7 @@ export const v2ListMcpServersContract = defineRouteContract({ export const v2CreateMcpServerContract = defineRouteContract({ method: 'POST', path: '/api/v2/mcp-servers', + query: noInputSchema, body: v2CreateMcpServerBodySchema, response: { mode: 'json', @@ -391,6 +393,7 @@ export const v2GetMcpServerContract = defineRouteContract({ export const v2UpdateMcpServerContract = defineRouteContract({ method: 'PATCH', path: '/api/v2/mcp-servers/[id]', + query: noInputSchema, params: v2McpServerParamsSchema, body: v2UpdateMcpServerBodySchema, response: { diff --git a/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts b/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts index dd697dcd2b3..53eafb881b7 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts @@ -153,6 +153,7 @@ const routes = [ success: { description: 'The created file.' }, }), { + query: v2CreateFileContract.query, body: documentedSchema( v2CreateFileContract.body, 'CreateFileRequest', @@ -186,6 +187,7 @@ const routes = [ success: { description: 'The created upload session and transfer instructions.' }, }), { + query: v2CreateFileUploadContract.query, body: documentedSchema( v2CreateFileUploadContract.body, 'CreateFileUploadRequest', @@ -394,6 +396,7 @@ const routes = [ success: { description: 'The renamed file.' }, }), { + query: v2RenameFileContract.query, params: documentedSchema( v2RenameFileContract.params, 'RenameFileParams', @@ -432,6 +435,7 @@ const routes = [ success: { description: 'The file as it exists after the restore.' }, }), { + query: v2RestoreFileContract.query, params: documentedSchema( v2RestoreFileContract.params, 'RestoreFileParams', @@ -554,6 +558,7 @@ const routes = [ success: { description: 'Count of moved files.' }, }), { + query: v2MoveFileItemsContract.query, body: documentedSchema( v2MoveFileItemsContract.body, 'MoveFileItemsRequest', @@ -618,6 +623,7 @@ const routes = [ success: { description: 'The updated file share.' }, }), { + query: v2UpsertFileShareContract.query, params: documentedSchema( v2UpsertFileShareContract.params, 'UpsertFileShareParams', @@ -660,6 +666,7 @@ const routes = [ success: { description: 'The updated file.' }, }), { + query: v2UpdateFileContentContract.query, params: documentedSchema( v2UpdateFileContentContract.params, 'UpdateFileContentParams', @@ -697,6 +704,7 @@ const routes = [ success: { description: 'Count of deleted files.' }, }), { + query: v2BulkDeleteFilesContract.query, body: documentedSchema( v2BulkDeleteFilesContract.body, 'BulkDeleteFilesRequest', @@ -752,6 +760,7 @@ const routes = [ success: { description: 'The created folder.' }, }), { + query: v2CreateFileFolderContract.query, body: documentedSchema( v2CreateFileFolderContract.body, 'CreateFileFolderRequest', @@ -782,6 +791,7 @@ const routes = [ success: { description: 'The relocated folder.' }, }), { + query: v2RelocateFileFolderContract.query, body: documentedSchema( v2RelocateFileFolderContract.body, 'RelocateFileFolderRequest', diff --git a/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts b/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts index 3503cddf005..cae5b55e3cc 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts @@ -99,6 +99,7 @@ const routes = [ success: { description: 'The created knowledge base.' }, }), { + query: v2CreateKnowledgeBaseContract.query, body: documentedSchema( v2CreateKnowledgeBaseContract.body, 'CreateKnowledgeBaseRequest', @@ -154,6 +155,7 @@ const routes = [ success: { description: 'The updated knowledge base.' }, }), { + query: v2UpdateKnowledgeBaseContract.query, params: documentedSchema( v2UpdateKnowledgeBaseContract.params, 'UpdateKnowledgeBaseParams', @@ -216,6 +218,7 @@ const routes = [ success: { description: 'Matching document chunks ordered by relevance.' }, }), { + query: v2SearchKnowledgeContract.query, body: documentedSchema( v2SearchKnowledgeContract.body, 'SearchKnowledgeRequest', @@ -309,6 +312,7 @@ const routes = [ success: { description: 'The number and identifiers of the documents that changed.' }, }), { + query: v2BulkUpdateKnowledgeDocumentsContract.query, params: documentedSchema( v2BulkUpdateKnowledgeDocumentsContract.params, 'BulkUpdateKnowledgeDocumentsParams', @@ -399,6 +403,7 @@ const routes = [ success: { description: 'The created upload session and transfer instructions.' }, }), { + query: v2CreateKnowledgeDocumentUploadContract.query, params: documentedSchema( v2CreateKnowledgeDocumentUploadContract.params, 'CreateKnowledgeDocumentUploadParams', @@ -583,6 +588,7 @@ const routes = [ success: { description: 'The updated document, or the requeue acknowledgement.' }, }), { + query: v2UpdateKnowledgeDocumentContract.query, params: documentedSchema( v2UpdateKnowledgeDocumentContract.params, 'UpdateKnowledgeDocumentParams', @@ -669,6 +675,7 @@ const routes = [ success: { description: 'The created knowledge-base folder.' }, }), { + query: v2CreateKnowledgeFolderContract.query, body: documentedSchema( v2CreateKnowledgeFolderContract.body, 'CreateKnowledgeFolderRequest', @@ -694,6 +701,7 @@ const routes = [ success: { description: 'The relocated knowledge-base folder.' }, }), { + query: v2RelocateKnowledgeFolderContract.query, body: documentedSchema( v2RelocateKnowledgeFolderContract.body, 'RelocateKnowledgeFolderRequest', diff --git a/apps/sim/lib/api/contracts/v2/openapi/logs.ts b/apps/sim/lib/api/contracts/v2/openapi/logs.ts index b0cbc32eb83..88e487f454f 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/logs.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/logs.ts @@ -126,6 +126,7 @@ const routes = [ success: { description: 'The requested diagnostic log representation.' }, }), { + query: v2GetLogContract.query, params: documentedSchema( v2GetLogContract.params, 'GetLogParams', diff --git a/apps/sim/lib/api/contracts/v2/openapi/resources.ts b/apps/sim/lib/api/contracts/v2/openapi/resources.ts index 5d0843332f3..6dd119f1541 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/resources.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/resources.ts @@ -215,6 +215,7 @@ const routes = [ success: { description: 'Public workspace metadata.' }, }), { + query: v2GetWorkspaceContract.query, params: documentedSchema( v2GetWorkspaceContract.params, 'GetWorkspaceParams', @@ -299,6 +300,7 @@ const routes = [ success: { description: 'The MCP server was registered.' }, }), { + query: v2CreateMcpServerContract.query, body: documentedSchema( v2CreateMcpServerContract.body, 'CreateMcpServerRequest', @@ -366,6 +368,7 @@ const routes = [ success: { description: 'The updated MCP server.' }, }), { + query: v2UpdateMcpServerContract.query, params: documentedSchema( v2UpdateMcpServerContract.params, 'UpdateMcpServerParams', @@ -488,6 +491,7 @@ const routes = [ success: { description: 'The skill was created.' }, }), { + query: v2CreateSkillContract.query, body: documentedSchema( v2CreateSkillContract.body, 'CreateSkillRequest', @@ -553,6 +557,7 @@ const routes = [ success: { description: 'The updated skill.' }, }), { + query: v2UpdateSkillContract.query, params: documentedSchema( v2UpdateSkillContract.params, 'UpdateSkillParams', @@ -643,6 +648,7 @@ const routes = [ success: { description: 'The custom tool was created.' }, }), { + query: v2CreateCustomToolContract.query, body: documentedSchema( v2CreateCustomToolContract.body, 'CreateCustomToolRequest', @@ -708,6 +714,7 @@ const routes = [ success: { description: 'The updated custom tool.' }, }), { + query: v2UpdateCustomToolContract.query, params: documentedSchema( v2UpdateCustomToolContract.params, 'UpdateCustomToolParams', @@ -828,6 +835,7 @@ const routes = [ }, }), { + query: v2SetSecretContract.query, params: documentedSchema( v2SetSecretContract.params, 'SetSecretParams', diff --git a/apps/sim/lib/api/contracts/v2/openapi/tables.ts b/apps/sim/lib/api/contracts/v2/openapi/tables.ts index 03d5ee6d2b4..38fefdf73c1 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/tables.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/tables.ts @@ -156,6 +156,7 @@ const declaredRoutes = [ success: { description: 'The created table.' }, }), { + query: v2CreateTableContract.query, body: documentedSchema( v2CreateTableContract.body, 'CreateTableRequest', @@ -253,6 +254,7 @@ const declaredRoutes = [ success: { description: 'The updated table.' }, }), { + query: v2UpdateTableContract.query, params: documentedSchema( v2UpdateTableContract.params, 'UpdateTableParams', @@ -284,6 +286,7 @@ const declaredRoutes = [ success: { description: 'The updated table columns.' }, }), { + query: v2AddTableColumnContract.query, params: documentedSchema( v2AddTableColumnContract.params, 'AddTableColumnParams', @@ -315,6 +318,7 @@ const declaredRoutes = [ success: { description: 'The updated table columns.' }, }), { + query: v2UpdateTableColumnContract.query, params: documentedSchema( v2UpdateTableColumnContract.params, 'UpdateTableColumnParams', @@ -346,6 +350,7 @@ const declaredRoutes = [ success: { description: 'The surviving table columns.' }, }), { + query: v2DeleteTableColumnContract.query, params: documentedSchema( v2DeleteTableColumnContract.params, 'DeleteTableColumnParams', @@ -409,6 +414,7 @@ const declaredRoutes = [ success: { description: 'The inserted row or rows.' }, }), { + query: v2CreateTableRowsContract.query, params: documentedSchema( v2CreateTableRowsContract.params, 'CreateTableRowsParams', @@ -440,6 +446,7 @@ const declaredRoutes = [ success: { description: 'The bulk update result.' }, }), { + query: v2UpdateRowsByFilterContract.query, params: documentedSchema( v2UpdateRowsByFilterContract.params, 'UpdateTableRowsParams', @@ -478,6 +485,7 @@ const declaredRoutes = [ success: { description: 'The bulk deletion result.' }, }), { + query: v2DeleteTableRowsContract.query, params: documentedSchema( v2DeleteTableRowsContract.params, 'DeleteTableRowsParams', @@ -539,6 +547,7 @@ const declaredRoutes = [ success: { description: 'The updated table row.' }, }), { + query: v2UpdateTableRowContract.query, params: documentedSchema( v2UpdateTableRowContract.params, 'UpdateTableRowParams', @@ -601,6 +610,7 @@ const declaredRoutes = [ success: { description: 'The upserted row and operation performed.' }, }), { + query: v2UpsertTableRowContract.query, params: documentedSchema( v2UpsertTableRowContract.params, 'UpsertTableRowParams', @@ -639,6 +649,7 @@ const declaredRoutes = [ success: { description: 'A page of matching table rows.' }, }), { + query: v2QueryRowsContract.query, params: documentedSchema( v2QueryRowsContract.params, 'QueryTableRowsParams', @@ -678,6 +689,7 @@ const declaredRoutes = [ success: { description: 'The number of matching table rows.' }, }), { + query: v2QueryRowsCountContract.query, params: documentedSchema( v2QueryRowsCountContract.params, 'CountTableRowsParams', @@ -744,6 +756,7 @@ const declaredRoutes = [ success: { description: 'The created table view.' }, }), { + query: v2CreateTableViewContract.query, params: documentedSchema( v2CreateTableViewContract.params, 'CreateTableViewParams', @@ -816,6 +829,7 @@ const declaredRoutes = [ success: { description: 'The updated table view.' }, }), { + query: v2UpdateTableViewContract.query, params: documentedSchema( v2UpdateTableViewContract.params, 'UpdateTableViewParams', @@ -908,6 +922,7 @@ const declaredRoutes = [ success: { description: 'The created workflow group and resulting columns.' }, }), { + query: v2AddWorkflowGroupContract.query, params: documentedSchema( v2AddWorkflowGroupContract.params, 'AddTableWorkflowGroupParams', @@ -950,6 +965,7 @@ const declaredRoutes = [ success: { description: 'The updated workflow group and resulting columns.' }, }), { + query: v2UpdateWorkflowGroupContract.query, params: documentedSchema( v2UpdateWorkflowGroupContract.params, 'UpdateTableWorkflowGroupParams', @@ -981,6 +997,7 @@ const declaredRoutes = [ success: { description: 'Workflow-group deletion acknowledgement and surviving columns.' }, }), { + query: v2DeleteWorkflowGroupContract.query, params: documentedSchema( v2DeleteWorkflowGroupContract.params, 'DeleteTableWorkflowGroupParams', @@ -1013,6 +1030,7 @@ const declaredRoutes = [ success: { description: 'The accepted table-column dispatch.' }, }), { + query: v2RunTableColumnContract.query, params: documentedSchema( v2RunTableColumnContract.params, 'RunTableColumnsParams', @@ -1044,6 +1062,7 @@ const declaredRoutes = [ success: { description: 'The accepted row enrichment dispatch.' }, }), { + query: v2RunRowEnrichmentContract.query, params: documentedSchema( v2RunRowEnrichmentContract.params, 'RunRowEnrichmentParams', @@ -1076,6 +1095,7 @@ const declaredRoutes = [ success: { description: 'The matching table cells.' }, }), { + query: v2FindTableRowsContract.query, params: documentedSchema( v2FindTableRowsContract.params, 'FindTableRowsParams', @@ -1114,6 +1134,7 @@ const declaredRoutes = [ success: { description: 'The created table import and optional transfer instructions.' }, }), { + query: v2CreateTableImportContract.query, body: documentedSchema( v2CreateTableImportContract.body, 'CreateTableImportRequest', @@ -1303,6 +1324,7 @@ const declaredRoutes = [ success: { description: 'The created table export.' }, }), { + query: v2CreateTableExportContract.query, params: documentedSchema( v2CreateTableExportContract.params, 'CreateTableExportParams', @@ -1427,6 +1449,7 @@ const declaredRoutes = [ success: { description: 'The number of canceled cell runs.' }, }), { + query: v2CancelTableRunsContract.query, params: documentedSchema( v2CancelTableRunsContract.params, 'CancelTableRunsParams', @@ -1482,6 +1505,7 @@ const declaredRoutes = [ success: { description: 'The created table folder.' }, }), { + query: v2CreateTableFolderContract.query, body: documentedSchema( v2CreateTableFolderContract.body, 'CreateTableFolderRequest', @@ -1507,6 +1531,7 @@ const declaredRoutes = [ success: { description: 'The relocated table folder.' }, }), { + query: v2RelocateTableFolderContract.query, body: documentedSchema( v2RelocateTableFolderContract.body, 'RelocateTableFolderRequest', diff --git a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts index c2600ca27f6..d1c66143ddd 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts @@ -187,6 +187,7 @@ const routes = [ success: jsonSuccess('The created workflow.'), }), { + query: v2CreateWorkflowContract.query, body: v2CreateWorkflowContract.body, response: documentedSchema( v2CreateWorkflowContract.response.schema, @@ -208,6 +209,7 @@ const routes = [ }), { params: v2GetWorkflowContract.params, + query: v2GetWorkflowContract.query, response: documentedSchema( v2GetWorkflowContract.response.schema, 'WorkflowDetailResponse', @@ -227,6 +229,7 @@ const routes = [ success: jsonSuccess('The updated workflow.'), }), { + query: v2UpdateWorkflowContract.query, params: v2UpdateWorkflowContract.params, body: v2UpdateWorkflowContract.body, response: documentedSchema( @@ -248,6 +251,7 @@ const routes = [ success: jsonSuccess('The workflow was deleted.'), }), { + query: v2DeleteWorkflowContract.query, params: v2DeleteWorkflowContract.params, response: documentedSchema( v2DeleteWorkflowContract.response.schema, @@ -289,6 +293,7 @@ const routes = [ success: jsonSuccess('The requested deployment version.'), }), { + query: v2GetWorkflowVersionContract.query, params: v2GetWorkflowVersionContract.params, response: documentedSchema( v2GetWorkflowVersionContract.response.schema, @@ -322,6 +327,7 @@ const routes = [ success: jsonSuccess('The current deployment state.'), }), { + query: v2GetWorkflowDeploymentContract.query, params: v2GetWorkflowDeploymentContract.params, response: documentedSchema( v2GetWorkflowDeploymentContract.response.schema, @@ -369,6 +375,7 @@ const routes = [ success: jsonSuccess('The accepted deployment attempt.'), }), { + query: v2DeployWorkflowContract.query, params: v2DeployWorkflowContract.params, body: v2DeployWorkflowContract.body, response: documentedSchema( @@ -413,6 +420,7 @@ const routes = [ success: jsonSuccess('The workflow was undeployed.'), }), { + query: v2UndeployWorkflowContract.query, params: v2UndeployWorkflowContract.params, response: documentedSchema( v2UndeployWorkflowContract.response.schema, @@ -444,6 +452,7 @@ const routes = [ success: jsonSuccess('The accepted rollback attempt.'), }), { + query: v2RollbackWorkflowContract.query, params: v2RollbackWorkflowContract.params, body: v2RollbackWorkflowContract.body, response: documentedSchema( @@ -488,6 +497,7 @@ const routes = [ success: jsonSuccess('The workflow export payload.'), }), { + query: v2ExportWorkflowContract.query, params: v2ExportWorkflowContract.params, response: documentedSchema( v2ExportWorkflowContract.response.schema, @@ -523,6 +533,7 @@ const routes = [ success: jsonSuccess('The imported workflow.'), }), { + query: v2ImportWorkflowContract.query, body: v2ImportWorkflowContract.body, response: documentedSchema( v2ImportWorkflowContract.response.schema, @@ -580,6 +591,7 @@ const routes = [ }, }), { + query: v2ExecuteWorkflowContract.query, params: v2ExecuteWorkflowContract.params, headers: v2ExecuteWorkflowContract.headers, body: v2ExecuteWorkflowContract.body, @@ -685,6 +697,7 @@ const routes = [ }, }), { + query: v2ResumeWorkflowContract.query, params: v2ResumeWorkflowContract.params, body: v2ResumeWorkflowContract.body, response: v2ResumeWorkflowContract.response.schema, @@ -702,6 +715,7 @@ const routes = [ success: jsonSuccess('The cancellation outcome.'), }), { + query: v2CancelWorkflowRunContract.query, params: v2CancelWorkflowRunContract.params, response: documentedSchema( v2CancelWorkflowRunContract.response.schema, @@ -759,6 +773,7 @@ const routes = [ success: jsonSuccess('The created workflow folder.'), }), { + query: v2CreateWorkflowFolderContract.query, body: documentedSchema( v2CreateWorkflowFolderContract.body, 'CreateWorkflowFolderRequest', @@ -785,6 +800,7 @@ const routes = [ success: jsonSuccess('The relocated workflow folder.'), }), { + query: v2RelocateWorkflowFolderContract.query, body: documentedSchema( v2RelocateWorkflowFolderContract.body, 'RelocateWorkflowFolderRequest', diff --git a/apps/sim/lib/api/contracts/v2/secrets.ts b/apps/sim/lib/api/contracts/v2/secrets.ts index 80df2e68f73..a4177e8b4df 100644 --- a/apps/sim/lib/api/contracts/v2/secrets.ts +++ b/apps/sim/lib/api/contracts/v2/secrets.ts @@ -1,6 +1,6 @@ import { z } from 'zod' import { workspaceCredentialRoleSchema } from '@/lib/api/contracts/credentials' -import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { noInputSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { v2CursorListResponse, @@ -114,6 +114,7 @@ export const v2ListSecretsContract = defineRouteContract({ export const v2SetSecretContract = defineRouteContract({ method: 'PUT', path: '/api/v2/secrets/[name]', + query: noInputSchema, params: v2SecretParamsSchema, body: v2SetSecretBodySchema, response: { diff --git a/apps/sim/lib/api/contracts/v2/skills.ts b/apps/sim/lib/api/contracts/v2/skills.ts index 48a3d29bd03..ea2f67bf2a1 100644 --- a/apps/sim/lib/api/contracts/v2/skills.ts +++ b/apps/sim/lib/api/contracts/v2/skills.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { noInputSchema, nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { skillContentSchema, skillDescriptionSchema, @@ -168,6 +168,7 @@ export const v2ListSkillsContract = defineRouteContract({ export const v2CreateSkillContract = defineRouteContract({ method: 'POST', path: '/api/v2/skills', + query: noInputSchema, body: v2CreateSkillBodySchema, response: { mode: 'json', @@ -190,6 +191,7 @@ export const v2GetSkillContract = defineRouteContract({ export const v2UpdateSkillContract = defineRouteContract({ method: 'PATCH', path: '/api/v2/skills/[id]', + query: noInputSchema, params: v2SkillParamsSchema, body: v2UpdateSkillBodySchema, response: { diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index 323cd7d512c..ee662fcd5b3 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { noInputSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { addWorkflowGroupBodySchema, cancelTableRunsBodyBaseSchema, @@ -441,6 +441,7 @@ export const v2ListTablesContract = defineRouteContract({ export const v2CreateTableContract = defineRouteContract({ method: 'POST', path: '/api/v2/tables', + query: noInputSchema, body: v2CreateTableBodySchema, response: { mode: 'json', @@ -501,6 +502,7 @@ export const v2UpdateTableBodySchema = z export const v2UpdateTableContract = defineRouteContract({ method: 'PATCH', path: '/api/v2/tables/[tableId]', + query: noInputSchema, params: tableIdParamsSchema, body: v2UpdateTableBodySchema, response: { @@ -537,6 +539,7 @@ export const v2ListTableFoldersContract = defineRouteContract({ export const v2CreateTableFolderContract = defineRouteContract({ method: 'POST', path: '/api/v2/tables/folders', + query: noInputSchema, body: v2CreateFolderBodySchema, response: { mode: 'json', schema: v2DataResponse(v2FolderSchema), status: 201 }, }) @@ -544,6 +547,7 @@ export const v2CreateTableFolderContract = defineRouteContract({ export const v2RelocateTableFolderContract = defineRouteContract({ method: 'PATCH', path: '/api/v2/tables/folders', + query: noInputSchema, body: v2RelocateFolderBodySchema, response: { mode: 'json', schema: v2DataResponse(v2FolderSchema) }, }) @@ -623,6 +627,7 @@ export type V2UpdateTableColumnBody = z.input export const v2QueryRowsContract = defineRouteContract({ method: 'POST', path: '/api/v2/tables/[tableId]/query', + query: noInputSchema, params: tableIdParamsSchema, body: v2QueryRowsBodySchema, response: { @@ -806,6 +814,7 @@ export const v2QueryRowsContract = defineRouteContract({ export const v2QueryRowsCountContract = defineRouteContract({ method: 'POST', path: '/api/v2/tables/[tableId]/query/count', + query: noInputSchema, params: tableIdParamsSchema, body: v2QueryRowsCountBodySchema, response: { @@ -864,6 +873,7 @@ export const v2CreateTableRowsBodySchema = z.union( export const v2CreateTableRowsContract = defineRouteContract({ method: 'POST', path: '/api/v2/tables/[tableId]/rows', + query: noInputSchema, params: tableIdParamsSchema, body: v2CreateTableRowsBodySchema, response: { @@ -891,6 +901,7 @@ export type V2UpdateRowsByPredicateBody = z.input export const v2DeleteTableRowsContract = defineRouteContract({ method: 'DELETE', path: '/api/v2/tables/[tableId]/rows', + query: noInputSchema, params: tableIdParamsSchema, body: v2DeleteTableRowsBodySchema, response: { @@ -978,6 +990,7 @@ export const v2GetTableRowContract = defineRouteContract({ export const v2UpdateTableRowContract = defineRouteContract({ method: 'PATCH', path: '/api/v2/tables/[tableId]/rows/[rowId]', + query: noInputSchema, params: tableRowParamsSchema, body: v2UpdateTableRowBodySchema, response: { @@ -1000,6 +1013,7 @@ export const v2DeleteTableRowContract = defineRouteContract({ export const v2UpsertTableRowContract = defineRouteContract({ method: 'POST', path: '/api/v2/tables/[tableId]/rows/upsert', + query: noInputSchema, params: tableIdParamsSchema, body: v2UpsertTableRowBodySchema, response: { @@ -1126,6 +1140,7 @@ export type V2UpdateTableViewBody = z.input export const v2CreateTableViewContract = defineRouteContract({ method: 'POST', path: '/api/v2/tables/[tableId]/views', + query: noInputSchema, params: tableIdParamsSchema, body: v2CreateTableViewBodySchema, response: { @@ -1149,6 +1164,7 @@ export const v2GetTableViewContract = defineRouteContract({ export const v2UpdateTableViewContract = defineRouteContract({ method: 'PATCH', path: '/api/v2/tables/[tableId]/views/[viewId]', + query: noInputSchema, params: tableViewParamsSchema, body: v2UpdateTableViewBodySchema, response: { @@ -1362,6 +1378,7 @@ export type V2DeleteWorkflowGroupData = z.output export const v2RunTableColumnContract = defineRouteContract({ method: 'POST', path: '/api/v2/tables/[tableId]/columns/run', + query: noInputSchema, params: tableIdParamsSchema, body: v2RunColumnBodySchema, response: { @@ -1453,6 +1473,7 @@ export type V2RowEnrichmentParams = z.output export const v2RunRowEnrichmentContract = defineRouteContract({ method: 'POST', path: '/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]', + query: noInputSchema, params: v2RowEnrichmentParamsSchema, body: v2WorkspaceScopedBodySchema, response: { @@ -1526,6 +1547,7 @@ export type V2FindRowsData = z.output export const v2FindTableRowsContract = defineRouteContract({ method: 'POST', path: '/api/v2/tables/[tableId]/rows/find', + query: noInputSchema, params: tableIdParamsSchema, body: v2FindRowsBodySchema, response: { @@ -1540,9 +1562,11 @@ export const v2TableImportParamsSchema = z.object({ export const v2TableExportParamsSchema = z.object({ exportId: z.string().min(1).describe('Unique table-export identifier.'), }) -export const v2TableTransferWorkspaceQuerySchema = z.object({ - workspaceId: workspaceIdSchema.describe('Workspace that owns the transfer resource.'), -}) +export const v2TableTransferWorkspaceQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the transfer resource.'), + }) + .strict() export const v2TableOptionalUploadTokenHeadersSchema = v2OptionalUploadTokenHeadersSchema.extend({ 'upload-token': v2OptionalUploadTokenHeadersSchema.shape['upload-token'].describe( @@ -1780,6 +1804,7 @@ export type V2CreateTableImportData = z.output export const v2CancelTableRunsContract = defineRouteContract({ method: 'POST', path: '/api/v2/tables/[tableId]/cancel-runs', + query: noInputSchema, params: tableIdParamsSchema, body: v2CancelTableRunsBodySchema, response: { diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index aa834c90874..70624709ae5 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -9,6 +9,7 @@ import { } from '@/lib/api/contracts/deployments' import { booleanQueryFlagSchema, + noInputSchema, runIdSchema, workspaceIdSchema, } from '@/lib/api/contracts/primitives' @@ -358,6 +359,7 @@ export const v2ListWorkflowsContract = defineRouteContract({ export const v2GetWorkflowContract = defineRouteContract({ method: 'GET', path: '/api/v2/workflows/[id]', + query: noInputSchema, params: v2WorkflowIdParamsSchema, response: { mode: 'json', @@ -461,6 +463,7 @@ export type V2DeleteWorkflowData = z.output export const v2CreateWorkflowContract = defineRouteContract({ method: 'POST', path: '/api/v2/workflows', + query: noInputSchema, body: v2CreateWorkflowBodySchema, response: { mode: 'json', @@ -472,6 +475,7 @@ export const v2CreateWorkflowContract = defineRouteContract({ export const v2UpdateWorkflowContract = defineRouteContract({ method: 'PATCH', path: '/api/v2/workflows/[id]', + query: noInputSchema, params: v2WorkflowIdParamsSchema, body: v2UpdateWorkflowBodySchema, response: { @@ -483,6 +487,7 @@ export const v2UpdateWorkflowContract = defineRouteContract({ export const v2DeleteWorkflowContract = defineRouteContract({ method: 'DELETE', path: '/api/v2/workflows/[id]', + query: noInputSchema, params: v2WorkflowIdParamsSchema, response: { mode: 'json', @@ -526,6 +531,7 @@ export const v2ListWorkflowFoldersContract = defineRouteContract({ export const v2CreateWorkflowFolderContract = defineRouteContract({ method: 'POST', path: '/api/v2/workflows/folders', + query: noInputSchema, body: v2CreateFolderBodySchema, response: { mode: 'json', schema: v2DataResponse(v2WorkflowFolderSchema), status: 201 }, }) @@ -533,6 +539,7 @@ export const v2CreateWorkflowFolderContract = defineRouteContract({ export const v2RelocateWorkflowFolderContract = defineRouteContract({ method: 'PATCH', path: '/api/v2/workflows/folders', + query: noInputSchema, body: v2RelocateFolderBodySchema, response: { mode: 'json', schema: v2DataResponse(v2WorkflowFolderSchema) }, }) @@ -656,6 +663,7 @@ export const v2ListWorkflowVersionsContract = defineRouteContract({ export const v2GetWorkflowVersionContract = defineRouteContract({ method: 'GET', path: '/api/v2/workflows/[id]/versions/[version]', + query: noInputSchema, params: v2DeploymentVersionParamsSchema, response: { mode: 'json', @@ -666,6 +674,7 @@ export const v2GetWorkflowVersionContract = defineRouteContract({ export const v2GetWorkflowDeploymentContract = defineRouteContract({ method: 'GET', path: '/api/v2/workflows/[id]/deployment', + query: noInputSchema, params: v2WorkflowIdParamsSchema, response: { mode: 'json', @@ -676,6 +685,7 @@ export const v2GetWorkflowDeploymentContract = defineRouteContract({ export const v2DeployWorkflowContract = defineRouteContract({ method: 'POST', path: '/api/v2/workflows/[id]/deploy', + query: noInputSchema, params: v2WorkflowIdParamsSchema, body: v1DeployWorkflowBodySchema .extend({ @@ -706,6 +716,7 @@ export const v2DeployWorkflowContract = defineRouteContract({ export const v2UndeployWorkflowContract = defineRouteContract({ method: 'DELETE', path: '/api/v2/workflows/[id]/deploy', + query: noInputSchema, params: v2WorkflowIdParamsSchema, response: { mode: 'json', @@ -724,6 +735,7 @@ export const v2UndeployWorkflowContract = defineRouteContract({ export const v2RollbackWorkflowContract = defineRouteContract({ method: 'POST', path: '/api/v2/workflows/[id]/rollback', + query: noInputSchema, params: v2WorkflowIdParamsSchema, body: v1RollbackWorkflowBodySchema .extend({ @@ -943,6 +955,7 @@ export const v2ExecuteWorkflowSuccessSchema = z export const v2ExecuteWorkflowContract = defineRouteContract({ method: 'POST', path: '/api/v2/workflows/[id]/execute', + query: noInputSchema, params: v2WorkflowIdParamsSchema, headers: v2ExecuteWorkflowHeadersSchema, body: v2ExecuteWorkflowBodySchema, @@ -1006,6 +1019,7 @@ export type V2ResumeWorkflowResponse = z.output v2ApiKeyAuthModuleMock) vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) @@ -42,6 +43,7 @@ const contract = defineRouteContract({ method: 'GET', path: '/api/v2/widgets/[widgetId]', params: z.object({ widgetId: z.string() }), + query: z.object({ workspaceId: z.string().min(1) }).strict(), response: { mode: 'binary' }, }) @@ -53,10 +55,16 @@ interface Result { bytes: string } -function createHandler(options: { headSafe?: boolean; execute: () => Promise }) { +function createHandler(options: { + headSafe?: boolean + execute: () => Promise + authorize?: () => Promise + omitAuthorize?: boolean +}) { const useCase: OperationUseCase = { operation, execute: options.execute, + authorize: options.omitAuthorize ? undefined : (options.authorize ?? (async () => {})), } return defineV2BinaryRoute({ contract, @@ -71,8 +79,8 @@ function createHandler(options: { headSafe?: boolean; execute: () => Promise { expect(v2RouteMocks.authenticate).toHaveBeenCalledOnce() expect(v2RouteMocks.operationRate).toHaveBeenCalled() }) + + /** + * A download `HEAD` used to answer 200 from admission alone, so any valid API + * key could enumerate file ids across every workspace — the `GET` beside it + * answered 403. It now runs the use case's authorization phase and renders the + * refusal through the route's error policy, so the probe never says more than + * the download would. + */ + it('answers a denied HEAD with the status its GET would produce', async () => { + const execute = vi.fn(async () => ({ bytes: 'payload' })) + const response = await createHandler({ + headSafe: false, + execute, + authorize: async () => { + throw new NoWorkspaceAccessError() + }, + })(request('HEAD'), context) + + expect(response.status).toBe(403) + expect(execute).not.toHaveBeenCalled() + }) + + it('answers a HEAD for a nonexistent resource with 404, not 200', async () => { + const execute = vi.fn(async () => ({ bytes: 'payload' })) + const response = await createHandler({ + headSafe: false, + execute, + authorize: async () => { + throw new OrchestrationError('not_found', 'Widget not found') + }, + })(request('HEAD'), context) + + expect(response.status).toBe(404) + expect(execute).not.toHaveBeenCalled() + }) + + it('rejects a HEAD missing a required param instead of answering 200', async () => { + const execute = vi.fn(async () => ({ bytes: 'payload' })) + const authorize = vi.fn(async () => {}) + const response = await createHandler({ headSafe: false, execute, authorize })( + request('HEAD', ''), + context + ) + + expect(response.status).toBe(400) + expect(authorize).not.toHaveBeenCalled() + }) + + it('refuses at definition time to build a not-head-safe route that cannot authorize', () => { + expect(() => + createHandler({ headSafe: false, omitAuthorize: true, execute: async () => ({ bytes: '' }) }) + ).toThrow(/authorize/) + }) }) diff --git a/apps/sim/lib/api/server/routes/v2-binary-route.ts b/apps/sim/lib/api/server/routes/v2-binary-route.ts index b9d2aa6ac10..54be43a8a25 100644 --- a/apps/sim/lib/api/server/routes/v2-binary-route.ts +++ b/apps/sim/lib/api/server/routes/v2-binary-route.ts @@ -11,11 +11,13 @@ import type { } from '@/lib/api/server/routes/types' import { admitV2Request, + requireHeadAuthorizableUseCase, V2_PARSE_DEFAULTS, type V2ErrorPolicy, type V2RateLimitPolicy, V2RouteInfrastructureError, type v2ApiKeyAuth, + v2HeadAuthorizationResponse, } from '@/lib/api/server/routes/v2-json-route' import { parseRequest } from '@/lib/api/server/validation' import type { ApplicationOperation } from '@/lib/core/application' @@ -35,13 +37,16 @@ interface V2BinaryRouteOptions< * Whether this route's `GET` is safe enough for Next's `HEAD`→`GET` aliasing * to run it. Defaults to `true`, which is correct for a read. * - * Set `false` when the `GET` opens an outbound connection or writes a row. - * Such a route still authenticates and rate-limits a `HEAD`, then answers a - * bodiless 200 without executing the use case — see {@link v2HeadNoEffect}. + * Set `false` when the `GET` opens an outbound connection or writes a row. A + * `HEAD` on such a route is admitted, parsed, and **authorized** exactly as + * the `GET` would be, then answered bodiless without running the use case's + * business phase — see {@link v2HeadNoEffect}. * * A binary `GET` is a download, and a download is the archetypal read that * records that it happened, so this matters here at least as much as on the - * JSON builder it mirrors. + * JSON builder it mirrors — including the part that made it a leak: a `HEAD` + * answered from admission alone confirmed a file id to a caller whose `GET` + * for the same id would have answered 404. */ headSafe?: boolean } @@ -57,6 +62,7 @@ export function defineV2BinaryRoute< options.operation, options.useCase.operation ) + requireHeadAuthorizableUseCase(options.contract, options.headSafe, options.useCase) const wrapped = withRouteHandler( async (request: NextRequest, context) => { @@ -74,16 +80,30 @@ export function defineV2BinaryRoute< ) if (!admission.success) return admission.response - if (request.method === 'HEAD' && options.headSafe === false) { - return v2HeadNoEffect() - } - const parsed = await parseRequest(options.contract, request, context ?? {}, { ...V2_PARSE_DEFAULTS, validationErrorResponse: v2ValidationError, }) if (!parsed.success) return parsed.response + if (request.method === 'HEAD' && options.headSafe === false) { + let input: I + try { + input = options.mapInput(parsed.data) + } catch (error) { + const response = options.errorPolicy.render(error) + if (response) return response + throw error + } + return v2HeadAuthorizationResponse({ + useCase: options.useCase, + principal: admission.auth.principal, + input, + request, + errorPolicy: options.errorPolicy, + }) + } + try { const result = await options.useCase.execute({ principal: admission.auth.principal, diff --git a/apps/sim/lib/api/server/routes/v2-json-route.test.ts b/apps/sim/lib/api/server/routes/v2-json-route.test.ts index 909418135e5..00994330a11 100644 --- a/apps/sim/lib/api/server/routes/v2-json-route.test.ts +++ b/apps/sim/lib/api/server/routes/v2-json-route.test.ts @@ -14,7 +14,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { z } from 'zod' import { defineRouteContract } from '@/lib/api/contracts' import type { ParsedRequest, ParseRequestOptions } from '@/lib/api/server/validation' -import type { OperationUseCase } from '@/lib/core/application' +import { + NoWorkspaceAccessError, + type OperationUseCase, + PrincipalKindAuthorizationError, +} from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { HttpError } from '@/lib/core/utils/http-error' @@ -489,3 +493,134 @@ describe('defineV2JsonRoute', () => { }) }) }) + +/** + * A `HEAD` on a route whose `GET` is not safe must answer the question the `GET` + * would answer, minus the effect — not merely the question admission can answer. + * + * The builder used to return {@link v2HeadNoEffect} straight after + * authenticate + rate-limit, so any valid API key drew a bodiless 200 for a + * denied principal kind, a nonexistent id, another tenant's workspace, and even + * a request missing a required param — while the `GET` beside it answered 403. + * That is an existence oracle: the probe reveals what the caller is not + * authorized to know. The fix runs the use case's authorization phase and stops + * before its business phase. + */ +describe('defineV2JsonRoute HEAD on a route that is not head-safe', () => { + const headContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/widgets/[widgetId]', + params: z.object({ widgetId: z.string() }).strict(), + query: z.object({ workspaceId: z.string().min(1) }).strict(), + response: { mode: 'json', schema: z.object({ data: z.object({ value: z.string() }) }) }, + }) + + type HeadInput = { widgetId: string; workspaceId: string } + + function createHeadHandler(overrides: { + authorize?: (args: { input: HeadInput }) => Promise + execute?: () => Promise + omitAuthorize?: boolean + }) { + const useCase: OperationUseCase = { + operation, + execute: overrides.execute ?? (async () => ({ value: 'ok' })), + authorize: overrides.omitAuthorize ? undefined : (overrides.authorize ?? (async () => {})), + } + return defineV2JsonRoute({ + contract: headContract, + auth: v2ApiKeyAuth, + operation, + headSafe: false, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ widgetId: params.widgetId, ...query }), + useCase, + present: (result) => ({ data: result }), + }) + } + + const headContext = { params: Promise.resolve({ widgetId: 'widget-1' }) } + + function headRequest(query = 'workspaceId=workspace-1'): NextRequest { + return new NextRequest(`http://localhost/api/v2/widgets/widget-1?${query}`, { + method: 'HEAD', + headers: { 'x-api-key': 'secret' }, + }) + } + + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue({ allowed: true, remaining: 599, resetAt }) + v2RouteMocks.operationRate.mockResolvedValue(allowedRate) + }) + + it('answers a denied principal kind with the status its GET would produce', async () => { + const execute = vi.fn(async () => ({ value: 'ok' })) + const response = await createHeadHandler({ + execute, + authorize: async () => { + throw new PrincipalKindAuthorizationError('workspace_api_key', operation.id) + }, + })(headRequest(), headContext) + + expect(response.status).toBe(403) + expect(execute).not.toHaveBeenCalled() + }) + + it('answers a nonexistent resource with 404 rather than confirming it exists', async () => { + const execute = vi.fn(async () => ({ value: 'ok' })) + const response = await createHeadHandler({ + execute, + authorize: async () => { + throw new OrchestrationError('not_found', 'Widget not found') + }, + })(headRequest(), headContext) + + expect(response.status).toBe(404) + expect(execute).not.toHaveBeenCalled() + }) + + it('answers an unauthorized workspace with the GET`s own refusal status', async () => { + const execute = vi.fn(async () => ({ value: 'ok' })) + const response = await createHeadHandler({ + execute, + authorize: async () => { + throw new NoWorkspaceAccessError() + }, + })(headRequest('workspaceId=someone-elses-workspace'), headContext) + + expect(response.status).toBe(403) + expect(execute).not.toHaveBeenCalled() + }) + + it('rejects a missing required param instead of answering 200', async () => { + const authorize = vi.fn(async () => {}) + const response = await createHeadHandler({ authorize })(headRequest(''), headContext) + + expect(response.status).toBe(400) + expect(authorize).not.toHaveBeenCalled() + }) + + it('answers an authorized probe bodiless without running the business phase', async () => { + const execute = vi.fn(async () => ({ value: 'ok' })) + const authorize = vi.fn(async () => {}) + const response = await createHeadHandler({ execute, authorize })(headRequest(), headContext) + + expect(response.status).toBe(200) + expect(await response.text()).toBe('') + expect(authorize).toHaveBeenCalledWith( + expect.objectContaining({ + principal, + input: { widgetId: 'widget-1', workspaceId: 'workspace-1' }, + }) + ) + expect(execute).not.toHaveBeenCalled() + }) + + it('refuses at definition time to build the route when the use case cannot authorize', () => { + expect(() => createHeadHandler({ omitAuthorize: true })).toThrow(/authorize/) + }) +}) diff --git a/apps/sim/lib/api/server/routes/v2-json-route.ts b/apps/sim/lib/api/server/routes/v2-json-route.ts index 41f1e89b0f1..b4ab67b1bb3 100644 --- a/apps/sim/lib/api/server/routes/v2-json-route.ts +++ b/apps/sim/lib/api/server/routes/v2-json-route.ts @@ -17,7 +17,7 @@ import { V2ApiKeyUnauthenticatedError, } from '@/lib/api/server/routes/v2-api-key-auth' import { type ParseRequestOptions, parseRequest } from '@/lib/api/server/validation' -import type { ApplicationOperation } from '@/lib/core/application' +import type { ApplicationOperation, OperationUseCase } from '@/lib/core/application' import { getRateLimit, RateLimiter, type SubscriptionPlan } from '@/lib/core/rate-limiter' import { getClientIp } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -146,6 +146,59 @@ export interface V2ErrorPolicy { render(error: unknown): NextResponse | null } +/** + * Refuses at module load to build a `headSafe: false` route whose use case + * cannot answer the authorization question on its own. + * + * Such a route must decide a `HEAD` without executing the use case, and the only + * honest way to do that is to run the use case's authorization phase alone. A + * use case that does not expose one leaves the builder with nothing but + * admission to answer from, which is precisely the existence oracle + * `headSafe: false` used to ship. Failing here turns the next occurrence into a + * boot failure instead of a silent 200. + */ +export function requireHeadAuthorizableUseCase( + contract: { method: string; path: string }, + headSafe: boolean | undefined, + useCase: Pick, 'authorize'> +): void { + if (headSafe !== false) return + if (typeof useCase.authorize === 'function') return + throw new Error( + `V2 route ${contract.method} ${contract.path} declares headSafe: false but its use case has no authorize(); a HEAD would have to answer from authentication alone and would leak the resource's existence.` + ) +} + +/** + * The bodiless answer a `HEAD` gets on a route whose `GET` is not safe. + * + * Authorization runs first and its failures render through the route's own error + * policy, so the status a caller sees is the status their `GET` would have + * produced — 400, 401, 403, 404, 429 — and only an authorized caller reaches the + * 200. What a `HEAD` never reaches is the use case's business phase, so the + * outbound connection, the row write, and the audit event stay unfired. + */ +export async function v2HeadAuthorizationResponse(args: { + useCase: Pick, 'authorize'> + principal: V2ApiKeyAuthContext['principal'] + input: unknown + request: NextRequest + errorPolicy: V2ErrorPolicy +}): Promise { + try { + await args.useCase.authorize?.({ + principal: args.principal, + input: args.input, + request: args.request, + }) + } catch (error) { + const response = args.errorPolicy.render(error) + if (response) return response + throw error + } + return v2HeadNoEffect() +} + export const v2OrchestrationErrorPolicy = { render(error) { return v2CaughtOrchestrationError(error) @@ -230,9 +283,17 @@ interface V2JsonRouteOptions @@ -260,6 +321,7 @@ export function defineV2JsonRoute< options.operation, options.useCase.operation ) + requireHeadAuthorizableUseCase(options.contract, options.headSafe, options.useCase) const wrapped = withRouteHandler( async (request, context) => { @@ -278,10 +340,6 @@ export function defineV2JsonRoute< if (!admission.success) return admission.response const { auth } = admission - if (request.method === 'HEAD' && options.headSafe === false) { - return v2HeadNoEffect() - } - if (options.beforeParse) { const rawParams = context?.params ? await context.params : {} try { @@ -300,6 +358,24 @@ export function defineV2JsonRoute< }) if (!parsed.success) return parsed.response + if (request.method === 'HEAD' && options.headSafe === false) { + let input: I + try { + input = options.mapInput(parsed.data) + } catch (error) { + const response = options.errorPolicy.render(error) + if (response) return response + throw error + } + return v2HeadAuthorizationResponse({ + useCase: options.useCase, + principal: auth.principal, + input, + request, + errorPolicy: options.errorPolicy, + }) + } + try { const input = options.mapInput(parsed.data) const result = await options.useCase.execute({ diff --git a/apps/sim/lib/core/application/authorized-workspace-use-case.ts b/apps/sim/lib/core/application/authorized-workspace-use-case.ts index bc3d20c14bb..a3286535edb 100644 --- a/apps/sim/lib/core/application/authorized-workspace-use-case.ts +++ b/apps/sim/lib/core/application/authorized-workspace-use-case.ts @@ -1,5 +1,5 @@ import { type AuditActionType, type AuditResourceTypeValue, recordAudit } from '@sim/audit' -import type { PrincipalAuditAttribution } from '@sim/auth/principal' +import type { Principal, PrincipalAuditAttribution } from '@sim/auth/principal' import { resolvePrincipalAuditAttribution } from '@sim/auth/principal' import type { OperationUseCase } from '@/lib/core/application/operation' import { @@ -109,27 +109,53 @@ export function defineAuthorizedWorkspaceUseCase< C extends WorkspaceAuthorizationContext, R, >(definition: AuthorizedWorkspaceUseCaseDefinition): OperationUseCase { + /** + * Everything that runs before the business transaction: allowed-principal + * check, canonical load, asserted-scope comparison, current access check. + * + * `execute` and `authorize` share it rather than each spelling it out, so a + * `HEAD` probe cannot answer a different question from the `GET` it stands + * for. It hands back the context it already loaded so the two phases together + * cost the same reads `execute` alone used to. + */ + async function authorizePhase({ + principal, + input, + request, + }: { + principal: Principal + input: I + request?: OrchestrationRequestContext + }): Promise> { + requireAllowedWorkspacePrincipal(principal, definition.operation) + const context = await definition.resolveContext({ principal, input }) + const executionContext: AuthorizedWorkspaceUseCaseContext = { + principal, + input, + context, + request, + } + const authorizationOptions = isAuthorizationOptionsResolver(definition.authorizationOptions) + ? await definition.authorizationOptions(executionContext) + : definition.authorizationOptions + + await authorizeWorkspaceOperation( + principal, + definition.operation, + context, + authorizationOptions + ) + return executionContext + } + return { operation: definition.operation, - async execute({ principal, input, request }) { - requireAllowedWorkspacePrincipal(principal, definition.operation) - const context = await definition.resolveContext({ principal, input }) - const executionContext: AuthorizedWorkspaceUseCaseContext = { - principal, - input, - context, - request, - } - const authorizationOptions = isAuthorizationOptionsResolver(definition.authorizationOptions) - ? await definition.authorizationOptions(executionContext) - : definition.authorizationOptions - - await authorizeWorkspaceOperation( - principal, - definition.operation, - context, - authorizationOptions - ) + async authorize(args) { + await authorizePhase(args) + }, + async execute(args) { + const executionContext = await authorizePhase(args) + const { principal, context, request } = executionContext const result = await definition.execute(executionContext) const resultContext = { ...executionContext, result } const projectedAudit = definition.projectAudit?.(resultContext) diff --git a/apps/sim/lib/core/application/operation.ts b/apps/sim/lib/core/application/operation.ts index 618a0aa3757..7614fe6e7a3 100644 --- a/apps/sim/lib/core/application/operation.ts +++ b/apps/sim/lib/core/application/operation.ts @@ -12,4 +12,26 @@ export interface OperationUseCase { input: I request?: OrchestrationRequestContext }): Promise + /** + * Runs everything {@link execute} does up to and including resource + * authorization, then stops — allowed-principal check, canonical load, + * asserted-scope comparison, current access check — but not the business + * transaction, the audit projection, or the after-success effects. + * + * It exists for one caller: a surface that must answer *"would this principal + * be allowed?"* without causing what the answer would cause. `HEAD` on a route + * whose `GET` is not safe is that surface — see the `headSafe` option on the + * v2 route builders. Answering such a probe from admission alone leaks an + * existence oracle, because admission only proves the caller holds *a* valid + * key, not that the key reaches *this* resource. + * + * Optional because most use cases have no such caller. The v2 builders reject + * a `headSafe: false` route whose use case omits it at definition time, so the + * gap is a boot failure rather than a silent 200. + */ + authorize?(args: { + principal: Principal + input: I + request?: OrchestrationRequestContext + }): Promise } diff --git a/scripts/openapi/generator.ts b/scripts/openapi/generator.ts index 5c5843d34a1..16968910b54 100644 --- a/scripts/openapi/generator.ts +++ b/scripts/openapi/generator.ts @@ -241,6 +241,20 @@ function objectProperties( } } +/** + * Whether a request slice declares no keys at all, i.e. `z.object({}).strict()`. + * + * A v2 contract states that an endpoint takes no query params by declaring + * `query: noInputSchema` rather than by omitting `query`, because an omitted + * slice is the one `parseRequest` skips validating entirely. That distinction is + * load-bearing at runtime and invisible to the spec: either way the operation + * publishes zero parameters. + */ +function declaresNoKeys(schema: ApiSchema): boolean { + const def = (schema as { def?: { type?: string; shape?: Record } }).def + return def?.type === 'object' && Object.keys(def.shape ?? {}).length === 0 +} + function parametersFor( schema: ApiSchema | undefined, location: 'path' | 'query' | 'header', @@ -248,6 +262,12 @@ function parametersFor( label: string ): JsonObject[] { if (!schema) return [] + /** + * Short-circuited ahead of `objectProperties`, which would otherwise demand + * the `.meta({ id })` and the non-empty `properties` an empty schema has + * nothing to supply, and would register a component no operation references. + */ + if (declaresNoKeys(schema)) return [] const { properties, required } = objectProperties(schema, components, label) return Object.entries(properties).map(([name, property]) => { invariant(property && typeof property === 'object', `${label}.${name} is not a schema`) From 05e696b1c8d090be533a4b71b90b4290127b24e8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 17:14:47 -0700 Subject: [PATCH 13/56] test(tables): pin the multiselect paste on the refusal, not the silent empty cleanCellValue runs the same registry coercion the server does, so tightening multiselect on the server changed this helper too. The case asserting an empty array was pinning the silent-drop the tightening removed. --- .../workspace/[workspaceId]/tables/[tableId]/utils.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.test.ts index 5945be6c388..f6805133548 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.test.ts @@ -155,7 +155,10 @@ describe('cleanCellValue', () => { expect(cleanCellValue('Bug, Docs', column)).toEqual(['opt_a', 'opt_b']) expect(cleanCellValue(['opt_b'], column)).toEqual(['opt_b']) expect(cleanCellValue('Bug, Bug', column)).toEqual(['opt_a']) - expect(cleanCellValue('Nope', column)).toEqual([]) + // A part matching no option is refused rather than silently dropped: this helper + // runs the same registry coercion the server does, and the server now rejects it. + expect(cleanCellValue('Nope', column)).toBeNull() + expect(cleanCellValue('Bug, Nope', column)).toBeNull() }) }) From 6c8904fe5b199c2d91d7c40f23c9db53ac622877 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 17:34:09 -0700 Subject: [PATCH 14/56] docs(v2): make the API-key security description render as plain prose The description was already published on every spec but did not appear in the rendered Authorization block. It carried a raw > and backticks, which the markdown pass in the docs renderer does not survive; the operation description on the same page renders fine. Reworded to plain prose with the same substance. --- apps/docs/openapi-v2-billing.json | 2 +- apps/docs/openapi-v2-files-audit.json | 2 +- apps/docs/openapi-v2-knowledge.json | 2 +- apps/docs/openapi-v2-logs.json | 2 +- apps/docs/openapi-v2-resources.json | 2 +- apps/docs/openapi-v2-tables.json | 2 +- apps/docs/openapi-v2-workflows.json | 2 +- apps/sim/lib/api/contracts/v2/openapi/shared.ts | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/docs/openapi-v2-billing.json b/apps/docs/openapi-v2-billing.json index 41ec2d85a5e..0f68b310ce7 100644 --- a/apps/docs/openapi-v2-billing.json +++ b/apps/docs/openapi-v2-billing.json @@ -250,7 +250,7 @@ "type": "apiKey", "in": "header", "name": "X-API-Key", - "description": "Your Sim API key, personal or workspace-scoped. Generate one from the Sim dashboard under Settings > API Keys. A workspace API key is not accepted everywhere: operations that act on behalf of a specific human — administrative reads, secret access, and irreversible or governance-affecting writes — always reject it, whatever role the key carries. Each such operation says so in its own description, and the rejection surfaces as `403` unless the operation conceals unauthorized resources, in which case it is reported as `404`. Use a personal API key for those." + "description": "Your Sim API key, sent on every request. Create one in the Sim dashboard under Settings, then API Keys. Keys are either personal or workspace-scoped. A workspace-scoped key is refused by operations that act on behalf of a specific person — administrative reads, secret access, and irreversible or governance-affecting writes — no matter what role the key carries; each of those operations says so in its own description. Such a refusal is a 403, or a 404 where the operation conceals resources the caller cannot reach. Use a personal API key for those." } }, "headers": { diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 35a7e68ad82..1049d2ff2e7 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -1984,7 +1984,7 @@ "type": "apiKey", "in": "header", "name": "X-API-Key", - "description": "Your Sim API key, personal or workspace-scoped. Generate one from the Sim dashboard under Settings > API Keys. A workspace API key is not accepted everywhere: operations that act on behalf of a specific human — administrative reads, secret access, and irreversible or governance-affecting writes — always reject it, whatever role the key carries. Each such operation says so in its own description, and the rejection surfaces as `403` unless the operation conceals unauthorized resources, in which case it is reported as `404`. Use a personal API key for those." + "description": "Your Sim API key, sent on every request. Create one in the Sim dashboard under Settings, then API Keys. Keys are either personal or workspace-scoped. A workspace-scoped key is refused by operations that act on behalf of a specific person — administrative reads, secret access, and irreversible or governance-affecting writes — no matter what role the key carries; each of those operations says so in its own description. Such a refusal is a 403, or a 404 where the operation conceals resources the caller cannot reach. Use a personal API key for those." } }, "headers": { diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index 258ffff0616..fe78caea7b3 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -1965,7 +1965,7 @@ "type": "apiKey", "in": "header", "name": "X-API-Key", - "description": "Your Sim API key, personal or workspace-scoped. Generate one from the Sim dashboard under Settings > API Keys. A workspace API key is not accepted everywhere: operations that act on behalf of a specific human — administrative reads, secret access, and irreversible or governance-affecting writes — always reject it, whatever role the key carries. Each such operation says so in its own description, and the rejection surfaces as `403` unless the operation conceals unauthorized resources, in which case it is reported as `404`. Use a personal API key for those." + "description": "Your Sim API key, sent on every request. Create one in the Sim dashboard under Settings, then API Keys. Keys are either personal or workspace-scoped. A workspace-scoped key is refused by operations that act on behalf of a specific person — administrative reads, secret access, and irreversible or governance-affecting writes — no matter what role the key carries; each of those operations says so in its own description. Such a refusal is a 403, or a 404 where the operation conceals resources the caller cannot reach. Use a personal API key for those." } }, "headers": { diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index 0cf4da1ad0e..8e9a7366c17 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -367,7 +367,7 @@ "type": "apiKey", "in": "header", "name": "X-API-Key", - "description": "Your Sim API key, personal or workspace-scoped. Generate one from the Sim dashboard under Settings > API Keys. A workspace API key is not accepted everywhere: operations that act on behalf of a specific human — administrative reads, secret access, and irreversible or governance-affecting writes — always reject it, whatever role the key carries. Each such operation says so in its own description, and the rejection surfaces as `403` unless the operation conceals unauthorized resources, in which case it is reported as `404`. Use a personal API key for those." + "description": "Your Sim API key, sent on every request. Create one in the Sim dashboard under Settings, then API Keys. Keys are either personal or workspace-scoped. A workspace-scoped key is refused by operations that act on behalf of a specific person — administrative reads, secret access, and irreversible or governance-affecting writes — no matter what role the key carries; each of those operations says so in its own description. Such a refusal is a 403, or a 404 where the operation conceals resources the caller cannot reach. Use a personal API key for those." } }, "headers": { diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index cd29f9bff49..27d4caef8ea 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -2037,7 +2037,7 @@ "type": "apiKey", "in": "header", "name": "X-API-Key", - "description": "Your Sim API key, personal or workspace-scoped. Generate one from the Sim dashboard under Settings > API Keys. A workspace API key is not accepted everywhere: operations that act on behalf of a specific human — administrative reads, secret access, and irreversible or governance-affecting writes — always reject it, whatever role the key carries. Each such operation says so in its own description, and the rejection surfaces as `403` unless the operation conceals unauthorized resources, in which case it is reported as `404`. Use a personal API key for those." + "description": "Your Sim API key, sent on every request. Create one in the Sim dashboard under Settings, then API Keys. Keys are either personal or workspace-scoped. A workspace-scoped key is refused by operations that act on behalf of a specific person — administrative reads, secret access, and irreversible or governance-affecting writes — no matter what role the key carries; each of those operations says so in its own description. Such a refusal is a 403, or a 404 where the operation conceals resources the caller cannot reach. Use a personal API key for those." } }, "headers": { diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index b9d1d29080c..c1fed07cd90 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -3737,7 +3737,7 @@ "type": "apiKey", "in": "header", "name": "X-API-Key", - "description": "Your Sim API key, personal or workspace-scoped. Generate one from the Sim dashboard under Settings > API Keys. A workspace API key is not accepted everywhere: operations that act on behalf of a specific human — administrative reads, secret access, and irreversible or governance-affecting writes — always reject it, whatever role the key carries. Each such operation says so in its own description, and the rejection surfaces as `403` unless the operation conceals unauthorized resources, in which case it is reported as `404`. Use a personal API key for those." + "description": "Your Sim API key, sent on every request. Create one in the Sim dashboard under Settings, then API Keys. Keys are either personal or workspace-scoped. A workspace-scoped key is refused by operations that act on behalf of a specific person — administrative reads, secret access, and irreversible or governance-affecting writes — no matter what role the key carries; each of those operations says so in its own description. Such a refusal is a 403, or a 404 where the operation conceals resources the caller cannot reach. Use a personal API key for those." } }, "headers": { diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 6a601a640d3..1976d9d4138 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -2051,7 +2051,7 @@ "type": "apiKey", "in": "header", "name": "X-API-Key", - "description": "Your Sim API key, personal or workspace-scoped. Generate one from the Sim dashboard under Settings > API Keys. A workspace API key is not accepted everywhere: operations that act on behalf of a specific human — administrative reads, secret access, and irreversible or governance-affecting writes — always reject it, whatever role the key carries. Each such operation says so in its own description, and the rejection surfaces as `403` unless the operation conceals unauthorized resources, in which case it is reported as `404`. Use a personal API key for those." + "description": "Your Sim API key, sent on every request. Create one in the Sim dashboard under Settings, then API Keys. Keys are either personal or workspace-scoped. A workspace-scoped key is refused by operations that act on behalf of a specific person — administrative reads, secret access, and irreversible or governance-affecting writes — no matter what role the key carries; each of those operations says so in its own description. Such a refusal is a 403, or a 404 where the operation conceals resources the caller cannot reach. Use a personal API key for those." } }, "headers": { diff --git a/apps/sim/lib/api/contracts/v2/openapi/shared.ts b/apps/sim/lib/api/contracts/v2/openapi/shared.ts index ae6bf88525f..2c394b33ad1 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/shared.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/shared.ts @@ -194,7 +194,7 @@ export const V2_API_KEY_SECURITY_SCHEMES = { in: 'header', name: 'X-API-Key', description: - 'Your Sim API key, personal or workspace-scoped. Generate one from the Sim dashboard under Settings > API Keys. A workspace API key is not accepted everywhere: operations that act on behalf of a specific human — administrative reads, secret access, and irreversible or governance-affecting writes — always reject it, whatever role the key carries. Each such operation says so in its own description, and the rejection surfaces as `403` unless the operation conceals unauthorized resources, in which case it is reported as `404`. Use a personal API key for those.', + 'Your Sim API key, sent on every request. Create one in the Sim dashboard under Settings, then API Keys. Keys are either personal or workspace-scoped. A workspace-scoped key is refused by operations that act on behalf of a specific person — administrative reads, secret access, and irreversible or governance-affecting writes — no matter what role the key carries; each of those operations says so in its own description. Such a refusal is a 403, or a 404 where the operation conceals resources the caller cannot reach. Use a personal API key for those.', }, } as const satisfies Readonly> From a480a8ebc84105900327b9166cdfa312bbd84a9f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 18:07:11 -0700 Subject: [PATCH 15/56] fix(v2): bind the query cursor to its filter on every shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two agents each fixed half of this: the shared list codecs gained filter binding, and the table codec gained a fingerprint, but the pure-keyset shape stamped it on neither encode nor decode. A keyset position is absolute in (order_key, id), which is why it was left unbound — but absolute ordering is not completeness. Replaying the cursor under a wider filter silently omits every match sorting before it, so paging predicate A then B returned rows 7,9 where the full B sequence is 1,3,5,7,9. Also answers a lost create race with the conflict it already documents, and shortens three descriptions that dwarfed their siblings — the forbidden-code catalogue now lives on the error envelope's details field, published once per document instead of on all 135 operations. --- apps/docs/openapi-v2-billing.json | 10 ++-- apps/docs/openapi-v2-files-audit.json | 18 +++--- apps/docs/openapi-v2-knowledge.json | 20 +++---- apps/docs/openapi-v2-logs.json | 10 ++-- apps/docs/openapi-v2-resources.json | 42 +++++++------- apps/docs/openapi-v2-tables.json | 20 +++---- apps/docs/openapi-v2-workflows.json | 24 ++++---- .../v2/__tests__/cross-cutting.test.ts | 11 ++-- .../lib/api/contracts/v2/openapi/shared.ts | 13 +---- apps/sim/lib/api/contracts/v2/shared.ts | 20 ++++++- apps/sim/lib/table/rows/cursor.test.ts | 16 +++++- apps/sim/lib/table/rows/cursor.ts | 6 +- .../orchestration/workflow-lifecycle.ts | 56 ++++++++++++------- 13 files changed, 150 insertions(+), 116 deletions(-) diff --git a/apps/docs/openapi-v2-billing.json b/apps/docs/openapi-v2-billing.json index 0f68b310ce7..9f465cd766d 100644 --- a/apps/docs/openapi-v2-billing.json +++ b/apps/docs/openapi-v2-billing.json @@ -189,9 +189,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "schema": { - "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "type": "string", "minLength": 1 } @@ -336,7 +336,7 @@ } }, "Forbidden": { - "description": "The caller lacks the rights this operation requires. Where the cause is one a caller can act on, `error.details.code` names it, drawn from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\nA resource in a workspace the caller cannot reach at all answers `404`, not `403`, so absence and denial are indistinguishable to a caller who was never entitled to tell them apart.", + "description": "The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.", "content": { "application/json": { "schema": { @@ -477,7 +477,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Optional structured error details." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." } }, "required": ["code", "message"], @@ -746,7 +746,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 1049d2ff2e7..df6f9abbeb1 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -129,9 +129,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "schema": { - "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "type": "string", "minLength": 1 } @@ -1119,9 +1119,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "schema": { - "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "type": "string", "minLength": 1 } @@ -2095,7 +2095,7 @@ } }, "Forbidden": { - "description": "The caller lacks the rights this operation requires. Where the cause is one a caller can act on, `error.details.code` names it, drawn from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\nA resource in a workspace the caller cannot reach at all answers `404`, not `403`, so absence and denial are indistinguishable to a caller who was never entitled to tell them apart.", + "description": "The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.", "content": { "application/json": { "schema": { @@ -2236,7 +2236,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Optional structured error details." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." } }, "required": ["code", "message"], @@ -2358,7 +2358,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], @@ -3218,7 +3218,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], @@ -3627,7 +3627,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index fe78caea7b3..cfc45fab919 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -113,9 +113,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "schema": { - "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "type": "string", "minLength": 1 } @@ -717,9 +717,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "schema": { - "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "type": "string", "minLength": 1 } @@ -2051,7 +2051,7 @@ } }, "Forbidden": { - "description": "The caller lacks the rights this operation requires. Where the cause is one a caller can act on, `error.details.code` names it, drawn from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\nA resource in a workspace the caller cannot reach at all answers `404`, not `403`, so absence and denial are indistinguishable to a caller who was never entitled to tell them apart.", + "description": "The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.", "content": { "application/json": { "schema": { @@ -2192,7 +2192,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Optional structured error details." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." } }, "required": ["code", "message"], @@ -2389,7 +2389,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], @@ -2880,7 +2880,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], @@ -3023,7 +3023,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], @@ -4038,7 +4038,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index 8e9a7366c17..42f7acb51bc 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -204,9 +204,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "schema": { - "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "type": "string", "minLength": 1 } @@ -453,7 +453,7 @@ } }, "Forbidden": { - "description": "The caller lacks the rights this operation requires. Where the cause is one a caller can act on, `error.details.code` names it, drawn from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\nA resource in a workspace the caller cannot reach at all answers `404`, not `403`, so absence and denial are indistinguishable to a caller who was never entitled to tell them apart.", + "description": "The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.", "content": { "application/json": { "schema": { @@ -594,7 +594,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Optional structured error details." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." } }, "required": ["code", "message"], @@ -981,7 +981,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 27d4caef8ea..06632c2ce70 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -152,9 +152,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "schema": { - "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "type": "string", "minLength": 1 } @@ -277,9 +277,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "schema": { - "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "type": "string", "minLength": 1 } @@ -785,9 +785,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "schema": { - "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "type": "string", "minLength": 1 } @@ -1207,9 +1207,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "schema": { - "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "type": "string", "minLength": 1 } @@ -1651,9 +1651,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "schema": { - "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "type": "string", "minLength": 1 } @@ -1787,9 +1787,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "schema": { - "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "type": "string", "minLength": 1 } @@ -2123,7 +2123,7 @@ } }, "Forbidden": { - "description": "The caller lacks the rights this operation requires. Where the cause is one a caller can act on, `error.details.code` names it, drawn from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\nA resource in a workspace the caller cannot reach at all answers `404`, not `403`, so absence and denial are indistinguishable to a caller who was never entitled to tell them apart.", + "description": "The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.", "content": { "application/json": { "schema": { @@ -2264,7 +2264,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Optional structured error details." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." } }, "required": ["code", "message"], @@ -2426,7 +2426,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], @@ -2590,7 +2590,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], @@ -3069,7 +3069,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], @@ -3156,7 +3156,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], @@ -3534,7 +3534,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], @@ -4046,7 +4046,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], @@ -4130,7 +4130,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index c1fed07cd90..1c73f7e22a4 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -113,9 +113,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "schema": { - "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "type": "string", "minLength": 1 } @@ -3823,7 +3823,7 @@ } }, "Forbidden": { - "description": "The caller lacks the rights this operation requires. Where the cause is one a caller can act on, `error.details.code` names it, drawn from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\nA resource in a workspace the caller cannot reach at all answers `404`, not `403`, so absence and denial are indistinguishable to a caller who was never entitled to tell them apart.", + "description": "The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.", "content": { "application/json": { "schema": { @@ -3964,7 +3964,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Optional structured error details." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." } }, "required": ["code", "message"], @@ -4245,7 +4245,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], @@ -4840,7 +4840,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], @@ -5264,7 +5264,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], @@ -5566,7 +5566,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], @@ -6031,7 +6031,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], @@ -8027,7 +8027,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 1976d9d4138..a6e0fa1f5ae 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -91,9 +91,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "schema": { - "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "type": "string", "minLength": 1 } @@ -510,9 +510,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "schema": { - "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "type": "string", "minLength": 1 } @@ -1321,9 +1321,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "schema": { - "description": "Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "type": "string", "minLength": 1 } @@ -2137,7 +2137,7 @@ } }, "Forbidden": { - "description": "The caller lacks the rights this operation requires. Where the cause is one a caller can act on, `error.details.code` names it, drawn from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\nA resource in a workspace the caller cannot reach at all answers `404`, not `403`, so absence and denial are indistinguishable to a caller who was never entitled to tell them apart.", + "description": "The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.", "content": { "application/json": { "schema": { @@ -2278,7 +2278,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Optional structured error details." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." } }, "required": ["code", "message"], @@ -2413,7 +2413,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], @@ -2865,7 +2865,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], @@ -4200,7 +4200,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], @@ -4748,7 +4748,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], diff --git a/apps/sim/lib/api/contracts/v2/__tests__/cross-cutting.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/cross-cutting.test.ts index 845f035d3d4..c80d5e66491 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/cross-cutting.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/cross-cutting.test.ts @@ -8,6 +8,7 @@ import { v2ListAuditLogsContract } from '@/lib/api/contracts/v2/audit-logs' import { filesAuditOpenApiDocument } from '@/lib/api/contracts/v2/openapi/files-audit' import { knowledgeOpenApiDocument } from '@/lib/api/contracts/v2/openapi/knowledge' import { ERROR_RESPONSES } from '@/lib/api/contracts/v2/openapi/shared' +import { v2ErrorResponseSchema } from '@/lib/api/contracts/v2/shared' import { v2CreateTableViewContract, v2QueryRowsBodySchema } from '@/lib/api/contracts/v2/tables' import { v2GetWorkflowRunContract } from '@/lib/api/contracts/v2/workflows' import { @@ -20,12 +21,12 @@ import { * therefore have nowhere else to be asserted. */ describe('v2 403 cause codes', () => { - it('publishes every code in the generated OpenAPI 403 description', () => { + it("publishes every code on the error envelope's details field", () => { + const details = v2ErrorResponseSchema.shape.error.shape.details + const published = details.description ?? '' for (const code of FORBIDDEN_DETAIL_CODES) { - expect(ERROR_RESPONSES.Forbidden.description).toContain(code) - expect(ERROR_RESPONSES.Forbidden.description).toContain( - FORBIDDEN_DETAIL_CODE_DESCRIPTIONS[code] - ) + expect(published).toContain(code) + expect(published).toContain(FORBIDDEN_DETAIL_CODE_DESCRIPTIONS[code]) } }) diff --git a/apps/sim/lib/api/contracts/v2/openapi/shared.ts b/apps/sim/lib/api/contracts/v2/openapi/shared.ts index 2c394b33ad1..e29103601c1 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/shared.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/shared.ts @@ -5,10 +5,6 @@ import type { OpenApiHeader, OpenApiSecurityScheme, } from '@/lib/api/openapi/types' -import { - FORBIDDEN_DETAIL_CODE_DESCRIPTIONS, - FORBIDDEN_DETAIL_CODES, -} from '@/lib/core/application/forbidden' export const RATE_LIMIT_HEADERS = [ 'X-RateLimit-Limit', @@ -45,13 +41,8 @@ export const WORKSPACE_ERRORS = [ * cause": the audit that produced these codes found the claim false, and it will * be false again the moment a domain adds a refusal without one. */ -const FORBIDDEN_DESCRIPTION = [ - 'The caller lacks the rights this operation requires. Where the cause is one a caller can act on, `error.details.code` names it, drawn from a closed set:', - ...FORBIDDEN_DETAIL_CODES.map( - (code) => `- \`${code}\` — ${FORBIDDEN_DETAIL_CODE_DESCRIPTIONS[code]}` - ), - 'A resource in a workspace the caller cannot reach at all answers `404`, not `403`, so absence and denial are indistinguishable to a caller who was never entitled to tell them apart.', -].join('\n') +const FORBIDDEN_DESCRIPTION = + 'The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.' export const ERROR_RESPONSES = { BadRequest: { status: 400, description: 'The request is invalid.' }, diff --git a/apps/sim/lib/api/contracts/v2/shared.ts b/apps/sim/lib/api/contracts/v2/shared.ts index 88d7f318502..47fc81fd2f4 100644 --- a/apps/sim/lib/api/contracts/v2/shared.ts +++ b/apps/sim/lib/api/contracts/v2/shared.ts @@ -1,6 +1,10 @@ import { z } from 'zod' import { workspaceIdSchema } from '@/lib/api/contracts/primitives' import { LIST_SORT_ORDERS, type ListSortOrder } from '@/lib/api/list-query' +import { + FORBIDDEN_DETAIL_CODE_DESCRIPTIONS, + FORBIDDEN_DETAIL_CODES, +} from '@/lib/core/application/forbidden' import { FolderPathError, parseFolderPath, requireNonRootFolderPath } from '@/lib/folders/paths' /** @@ -180,7 +184,17 @@ export const v2ErrorResponseSchema = z.object({ .object({ code: z.string().describe('Stable machine-readable error code.'), message: z.string().describe('Human-readable explanation of the error.'), - details: z.unknown().optional().describe('Optional structured error details.'), + details: z + .unknown() + .optional() + .describe( + [ + 'Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:', + ...FORBIDDEN_DETAIL_CODES.map( + (code) => `- \`${code}\` — ${FORBIDDEN_DETAIL_CODE_DESCRIPTIONS[code]}` + ), + ].join('\n') + ), }) .describe('Canonical error details.'), }) @@ -199,7 +213,7 @@ export const v2CursorListResponse = (itemSchema: T) => .string() .nullable() .describe( - 'Opaque cursor for the next page: send it back as `cursor` to continue, and stop when it is null. Most v2 lists page, so null means the last page was reached. A few are full-set lists that return their whole bounded result in one response and therefore always report null; those say so in the operation description. Either way, null means there is nothing further to fetch — never construct a cursor yourself.' + 'Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself.' ), }) @@ -297,7 +311,7 @@ export function v2LimitSchema(options: V2LimitOptions = {}) { * looping on page one. */ export function v2CursorSchema( - description = 'Opaque cursor returned by the previous page. It is bound to the sort and filters the page was read under: send it back with the same params, and restart pagination without a cursor after changing any of them. Only `limit` may change mid-walk.' + description = 'Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.' ) { return z.string().min(1, 'cursor must be a non-empty token').optional().describe(description) } diff --git a/apps/sim/lib/table/rows/cursor.test.ts b/apps/sim/lib/table/rows/cursor.test.ts index 419be0837c1..3429fb3e39f 100644 --- a/apps/sim/lib/table/rows/cursor.test.ts +++ b/apps/sim/lib/table/rows/cursor.test.ts @@ -166,10 +166,20 @@ describe('cursor↔filter binding', () => { expect(() => assertCursorQueryBinding(decoded, { predicate: ACTIVE })).not.toThrow() }) - it('leaves a pure keyset cursor unbound — it names an absolute position', () => { - const decoded = decodeCursor(encodeCursor({ lastRow: ROW, keysetValid: true, nextOffset: 10 })) - expect(decoded.filterKey).toBeUndefined() + it('binds a pure keyset cursor to its filter too', () => { + /** + * A keyset position is absolute in `(order_key, id)`, which is why this was + * once left unbound. Absolute ordering is not the same as completeness: + * replaying the cursor under a wider filter silently omits every match that + * sorts before it, and the caller reads the short page as the end of the + * sequence rather than as an error. + */ + const decoded = decodeCursor( + encodeCursor({ lastRow: ROW, keysetValid: true, nextOffset: 10, predicate: ACTIVE }) + ) + expect(decoded.filterKey).toBe(canonicalFilterKey({ predicate: ACTIVE })) expect(() => assertCursorQueryBinding(decoded, { predicate: ACTIVE })).not.toThrow() + expect(() => assertCursorQueryBinding(decoded, {})).toThrow(/different filter/i) }) it('fingerprints structurally equal predicates identically, key order aside', () => { diff --git a/apps/sim/lib/table/rows/cursor.ts b/apps/sim/lib/table/rows/cursor.ts index 820543e68d9..3688a04c1b7 100644 --- a/apps/sim/lib/table/rows/cursor.ts +++ b/apps/sim/lib/table/rows/cursor.ts @@ -111,7 +111,7 @@ export function assertCursorQueryBinding( 'CURSOR_SORT_CONFLICT' ) } - if (decoded.offset !== undefined && decoded.filterKey !== canonicalFilterKey(scope)) { + if (decoded.filterKey !== canonicalFilterKey(scope)) { throw new TableQueryValidationError( 'Cursor was created under a different filter. Restart paging without the cursor.', 'CURSOR_FILTER_CONFLICT' @@ -180,7 +180,7 @@ export function encodeCursor(args: { ...('k' in body || sortKey === undefined ? {} : { s: sortKey }), // Every offset — whole-view or offset-from-anchor — counts filtered rows, so // both the pure-offset and compound shapes carry the filter stamp. - ...('o' in body && filterKey !== undefined ? { p: filterKey } : {}), + ...(filterKey !== undefined ? { p: filterKey } : {}), v: CURSOR_VERSION, } return toBase64Url(JSON.stringify(payload)) @@ -220,7 +220,7 @@ export function decodeCursor(token: string): { } } if (hasKeyset) { - return { after: { orderKey: record.k as string, id: record.i as string } } + return { after: { orderKey: record.k as string, id: record.i as string }, ...filterBinding } } if (hasOffset) { return { diff --git a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts index 18210c81e8a..14829656be1 100644 --- a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts @@ -3,7 +3,7 @@ import { db } from '@sim/db' import { folder as folderTable, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { isFolderInWorkspace } from '@sim/platform-authz/workflow' -import { toError } from '@sim/utils/errors' +import { getPostgresErrorCode, toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, isNull, min, ne } from 'drizzle-orm' import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' @@ -258,25 +258,43 @@ export async function performCreateWorkflowTransition( const now = new Date() const { workflowState, subBlockValues, startBlockId } = buildDefaultWorkflowArtifacts() - await db.transaction(async (tx) => { - await tx.insert(workflow).values({ - id: workflowId, - userId: params.userId, - workspaceId: params.workspaceId, - folderId, - sortOrder, - name, - description: params.description, - lastSynced: now, - createdAt: now, - updatedAt: now, - isDeployed: false, - runCount: 0, - variables: {}, - }) + try { + await db.transaction(async (tx) => { + await tx.insert(workflow).values({ + id: workflowId, + userId: params.userId, + workspaceId: params.workspaceId, + folderId, + sortOrder, + name, + description: params.description, + lastSynced: now, + createdAt: now, + updatedAt: now, + isDeployed: false, + runCount: 0, + variables: {}, + }) - await saveWorkflowToNormalizedTables(workflowId, workflowState, tx) - }) + await saveWorkflowToNormalizedTables(workflowId, workflowState, tx) + }) + } catch (error) { + /** + * The name pre-check above is a `SELECT`, so two concurrent creates of the same + * name both pass it and the loser is rejected by + * `workflow_workspace_folder_name_active_unique` as a raw Postgres `23505`. + * Reported as the conflict the pre-check already raises, so a caller sees one + * answer whether it lost the race or simply arrived second. + */ + if (getPostgresErrorCode(error) === '23505') { + return { + success: false, + error: `A workflow named "${name}" already exists in this folder`, + errorCode: 'conflict', + } + } + throw error + } logger.info(`[${requestId}] Successfully created workflow ${workflowId}`) From 43e5817adfd6fb613755dca4d5ad31b526010d88 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 18:26:09 -0700 Subject: [PATCH 16/56] fix(tables): make a saved view's column references survive the write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A view config stores every column reference as a stable column id, but two things wrote it in different vocabularies and nothing translated between them. `config.sort` was pruned on read against the live column ID set while the contract defines `sort[].field` as a column NAME, so every name-keyed sort — the only kind the v2 surface can express — pruned to nothing and the view came back with `sort: null`, on both create and PATCH, with no warning. The same prune dropped a sort on `createdAt`/`updatedAt`/`id`, which are sortable row columns that simply are not in `schema.columns`. `config.filter` had the opposite failure: it was stored verbatim, so a predicate naming a column that does not exist saved happily and then 400'd on every `/query`, `/query/count`, and `/rows/find` that tried to use it. The write path now canonicalizes a config before storing it: every column reference (layout keys, `sort[].field`, each `filter` leaf `field`) is resolved to the column's stable id, and `filter`/`sort` are validated against the live schema so a reference that can never resolve is refused instead of saved. The v2 read presents the config back keyed by column name, matching `presentV2WorkflowGroup` and every other v2 row/data surface — a caller never sees a `col_…` id, and what it wrote is what it reads. Resolution is a lookup with pass-through, so the id-keyed first-party UI is unaffected. Column LAYOUT stays unvalidated on write and pruned on read: it auto-saves as the user drags, so racing a column delete must self-heal, not fail the drag. The read path still never prunes a predicate, for the reason already documented there — a pruned condition silently widens the view's row set. --- apps/docs/openapi-v2-tables.json | 32 ++--- .../[tableId]/views/[viewId]/route.test.ts | 28 +++- .../tables/[tableId]/views/[viewId]/route.ts | 13 +- .../v2/tables/[tableId]/views/route.test.ts | 9 +- .../api/v2/tables/[tableId]/views/route.ts | 13 +- apps/sim/app/api/v2/tables/utils.ts | 28 +++- apps/sim/lib/api/contracts/tables.ts | 26 +++- apps/sim/lib/api/contracts/v2/tables.ts | 14 ++ .../lib/table/__tests__/column-keys.test.ts | 53 +++++++ apps/sim/lib/table/application/views.ts | 30 ++-- apps/sim/lib/table/column-keys.ts | 55 +++++++- apps/sim/lib/table/query-builder/validate.ts | 26 ++++ apps/sim/lib/table/views/service.test.ts | 133 ++++++++++++++++++ apps/sim/lib/table/views/service.ts | 70 +++++++-- 14 files changed, 461 insertions(+), 69 deletions(-) diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 1c73f7e22a4..65b6c700829 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -5470,7 +5470,7 @@ "type": "object", "properties": { "columnWidths": { - "description": "Column widths keyed by stable column identifier.", + "description": "Column widths keyed by column name.", "type": "object", "propertyNames": { "type": "string" @@ -5481,21 +5481,21 @@ } }, "columnOrder": { - "description": "Stable column identifiers in display order.", + "description": "Column names in display order.", "type": "array", "items": { "type": "string" } }, "pinnedColumns": { - "description": "Stable identifiers of pinned columns.", + "description": "Names of pinned columns.", "type": "array", "items": { "type": "string" } }, "hiddenColumns": { - "description": "Stable identifiers of hidden columns.", + "description": "Names of hidden columns.", "type": "array", "items": { "type": "string" @@ -5604,7 +5604,7 @@ "type": "object", "properties": { "columnWidths": { - "description": "Column widths keyed by stable column identifier.", + "description": "Column widths keyed by column name or stable column identifier.", "type": "object", "propertyNames": { "type": "string" @@ -5615,21 +5615,21 @@ } }, "columnOrder": { - "description": "Stable column identifiers in display order.", + "description": "Columns in display order, by name or stable identifier.", "type": "array", "items": { "type": "string" } }, "pinnedColumns": { - "description": "Stable identifiers of pinned columns.", + "description": "Pinned columns, by name or stable identifier.", "type": "array", "items": { "type": "string" } }, "hiddenColumns": { - "description": "Stable identifiers of hidden columns.", + "description": "Hidden columns, by name or stable identifier.", "type": "array", "items": { "type": "string" @@ -5717,7 +5717,7 @@ "type": "object", "properties": { "columnWidths": { - "description": "Column widths keyed by stable column identifier.", + "description": "Column widths keyed by column name or stable column identifier.", "type": "object", "propertyNames": { "type": "string" @@ -5728,21 +5728,21 @@ } }, "columnOrder": { - "description": "Stable column identifiers in display order.", + "description": "Columns in display order, by name or stable identifier.", "type": "array", "items": { "type": "string" } }, "pinnedColumns": { - "description": "Stable identifiers of pinned columns.", + "description": "Pinned columns, by name or stable identifier.", "type": "array", "items": { "type": "string" } }, "hiddenColumns": { - "description": "Stable identifiers of hidden columns.", + "description": "Hidden columns, by name or stable identifier.", "type": "array", "items": { "type": "string" @@ -5797,7 +5797,7 @@ "type": "object", "properties": { "columnWidths": { - "description": "Column widths keyed by stable column identifier.", + "description": "Column widths keyed by column name or stable column identifier.", "type": "object", "propertyNames": { "type": "string" @@ -5808,21 +5808,21 @@ } }, "columnOrder": { - "description": "Stable column identifiers in display order.", + "description": "Columns in display order, by name or stable identifier.", "type": "array", "items": { "type": "string" } }, "pinnedColumns": { - "description": "Stable identifiers of pinned columns.", + "description": "Pinned columns, by name or stable identifier.", "type": "array", "items": { "type": "string" } }, "hiddenColumns": { - "description": "Stable identifiers of hidden columns.", + "description": "Hidden columns, by name or stable identifier.", "type": "array", "items": { "type": "string" diff --git a/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.test.ts index 9fdecfd9985..c680afffa9c 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.test.ts @@ -46,11 +46,19 @@ const auth = { rateLimitSubscription: null, keyType: 'workspace' as const, } +const columns = [ + { id: 'col_a', name: 'Status', type: 'text' as const }, + { id: 'col_b', name: 'Email', type: 'text' as const }, +] const view = { id: 'view-1', tableId: 'table-1', name: 'Active', - config: {}, + config: { + hiddenColumns: ['col_b'], + sort: [{ field: 'col_a', direction: 'desc' as const }], + filter: { all: [{ field: 'col_a', op: 'eq' as const, value: 'open' }] }, + }, isDefault: true, createdBy: 'user-1', createdAt: new Date('2026-01-01T00:00:00.000Z'), @@ -76,8 +84,8 @@ describe('/api/v2/tables/[tableId]/views/[viewId]', () => { v2RouteMocks.gate.mockResolvedValue(null) v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) - mocks.read.mockResolvedValue({ view }) - mocks.update.mockResolvedValue({ view, changed: false }) + mocks.read.mockResolvedValue({ view, columns }) + mocks.update.mockResolvedValue({ view, columns, changed: false }) mocks.remove.mockResolvedValue({ viewId: 'view-1' }) mocks.email.mockResolvedValue('user@example.com') }) @@ -95,6 +103,20 @@ describe('/api/v2/tables/[tableId]/views/[viewId]', () => { }) }) + /** + * Storage keys on stable column ids; this surface reads and writes column + * names, so a `col_…` id must never reach the caller. + */ + it('presents the saved config keyed by column name', async () => { + const response = await GET(request('GET'), context) + + expect((await response.json()).data.config).toEqual({ + hiddenColumns: ['Email'], + sort: [{ field: 'Status', direction: 'desc' }], + filter: { all: [{ field: 'Status', op: 'eq', value: 'open' }] }, + }) + }) + it('preserves no-op PATCH response compatibility', async () => { const response = await PATCH( request('PATCH', { workspaceId: WORKSPACE_ID, name: 'Active' }), diff --git a/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.ts index 9f9ccada5c4..e7d4a2eaed2 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.ts @@ -17,10 +17,17 @@ import { toApiView } from '@/app/api/v2/tables/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 -async function presentView(result: { view: Parameters[0] }) { - const { view } = result +async function presentView(result: { + view: Parameters[0] + columns: Parameters[2] +}) { + const { view, columns } = result return { - data: toApiView(view, view.createdBy ? await getRequiredUserEmail(view.createdBy) : null), + data: toApiView( + view, + view.createdBy ? await getRequiredUserEmail(view.createdBy) : null, + columns + ), } } diff --git a/apps/sim/app/api/v2/tables/[tableId]/views/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/views/route.test.ts index e89fe4a4fa2..006383eac23 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/views/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/views/route.test.ts @@ -49,11 +49,12 @@ const auth = { rateLimitSubscription: null, keyType: 'workspace' as const, } +const columns = [{ id: 'col_a', name: 'Status', type: 'text' as const }] const view = { id: 'view-1', tableId: 'table-1', name: 'Active', - config: {}, + config: { sort: [{ field: 'col_a', direction: 'desc' as const }] }, isDefault: false, createdBy: 'user-1', createdAt: new Date('2026-01-01T00:00:00.000Z'), @@ -68,8 +69,8 @@ describe('/api/v2/tables/[tableId]/views', () => { v2RouteMocks.gate.mockResolvedValue(null) v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) - mocks.list.mockResolvedValue({ views: [view] }) - mocks.create.mockResolvedValue({ view }) + mocks.list.mockResolvedValue({ views: [view], columns }) + mocks.create.mockResolvedValue({ view, columns }) mocks.emails.mockResolvedValue(new Map([['user-1', 'user@example.com']])) mocks.email.mockResolvedValue('user@example.com') }) @@ -87,7 +88,7 @@ describe('/api/v2/tables/[tableId]/views', () => { id: 'view-1', tableId: 'table-1', name: 'Active', - config: {}, + config: { sort: [{ field: 'Status', direction: 'desc' }] }, isDefault: false, createdByEmail: 'user@example.com', createdAt: '2026-01-01T00:00:00.000Z', diff --git a/apps/sim/app/api/v2/tables/[tableId]/views/route.ts b/apps/sim/app/api/v2/tables/[tableId]/views/route.ts index e4eb50539e9..0daa6815561 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/views/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/views/route.ts @@ -21,7 +21,7 @@ export const GET = defineV2JsonRoute({ rateLimit: v2RateLimits.publicApi, errorPolicy: v2TableErrorPolicies.concealTableAuthorization, mapInput: ({ params, query }) => ({ tableId: params.tableId, workspaceId: query.workspaceId }), - present: async ({ views }) => { + present: async ({ views, columns }) => { const emailByUserId = await getUserEmailsByIds( views.flatMap((view) => (view.createdBy ? [view.createdBy] : [])) ) @@ -29,7 +29,8 @@ export const GET = defineV2JsonRoute({ data: views.map((view) => toApiView( view, - view.createdBy ? requireResolvedUserEmail(emailByUserId, view.createdBy) : null + view.createdBy ? requireResolvedUserEmail(emailByUserId, view.createdBy) : null, + columns ) ), nextCursor: null, @@ -45,7 +46,11 @@ export const POST = defineV2JsonRoute({ rateLimit: v2RateLimits.publicApi, errorPolicy: v2TableErrorPolicies.concealTableAuthorization, mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), - present: async ({ view }) => ({ - data: toApiView(view, view.createdBy ? await getRequiredUserEmail(view.createdBy) : null), + present: async ({ view, columns }) => ({ + data: toApiView( + view, + view.createdBy ? await getRequiredUserEmail(view.createdBy) : null, + columns + ), }), }) diff --git a/apps/sim/app/api/v2/tables/utils.ts b/apps/sim/app/api/v2/tables/utils.ts index 04d02c3264e..bcada019744 100644 --- a/apps/sim/app/api/v2/tables/utils.ts +++ b/apps/sim/app/api/v2/tables/utils.ts @@ -4,7 +4,11 @@ import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' import type { MultipartError } from '@/lib/core/utils/multipart' import type { RowData, TableDefinition, TablePredicate, TableSchema } from '@/lib/table' import { getMaxRowsPerTable } from '@/lib/table/billing' -import { getColumnId } from '@/lib/table/column-keys' +import { + buildColumnNameById, + getColumnId, + remapViewConfigColumnRefs, +} from '@/lib/table/column-keys' import { TableLockedError } from '@/lib/table/mutation-locks' import { predicateToFilter } from '@/lib/table/query-builder/converters' import { @@ -12,7 +16,7 @@ import { validateStoragePredicate, } from '@/lib/table/query-builder/validate' import { predicateToStorage } from '@/lib/table/select-values' -import type { Filter, TableLockKind } from '@/lib/table/types' +import type { ColumnDefinition, Filter, TableLockKind } from '@/lib/table/types' import type { TableView } from '@/lib/table/views/service' import { getUserEmailsByIds, requireResolvedUserEmail } from '@/lib/users/queries' import { CSV_IMPORT_PROXY_BODY_CAP_BYTES, normalizeColumn } from '@/app/api/table/utils' @@ -143,15 +147,27 @@ export async function toApiTables( } /** - * Normalized public view shape. Identical to the stored view except that the - * timestamps are ISO strings, matching every other v2 payload. + * Normalized public view shape: ISO timestamps, and a `config` whose column + * references are presented as column **names**. + * + * A view stores every column reference as a stable id so a rename cannot orphan + * it — but the v2 surface is name-keyed everywhere else (row `data`, query + * predicates, sort fields, and workflow groups via `presentV2WorkflowGroup`), + * and a caller who never sees a `col_…` id cannot round-trip a config it reads + * back into a create. The write path translates in the other direction, so the + * pair is symmetric. A ref naming no current column (a since-deleted column in + * a saved filter) is left as-is. */ -export function toApiView(view: TableView, createdByEmail: string | null) { +export function toApiView( + view: TableView, + createdByEmail: string | null, + columns: ColumnDefinition[] +) { return { id: view.id, tableId: view.tableId, name: view.name, - config: view.config, + config: remapViewConfigColumnRefs(view.config, buildColumnNameById(columns)), isDefault: view.isDefault, createdByEmail, createdAt: toIso(view.createdAt), diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index 4289e226cc9..f8936361d5e 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -1912,11 +1912,33 @@ export const tableEventStreamContract = defineRouteContract({ /** * A saved view's stored shape: `TableMetadata`'s column layout plus the row - * predicate and sort. Every column reference is a stable column id, so a rename - * never invalidates a view. + * predicate and sort. + * + * Every column reference is STORED as a stable column id, so a rename never + * invalidates a view — but a write may reference a column either way, and + * `normalizeViewConfigForStorage` resolves a name to its id before the config is + * persisted. That is what lets the name-keyed v2 surface and the id-keyed + * first-party UI write the same blob. A read is presented in the reading + * surface's own vocabulary. */ export const tableViewConfigSchema = tableMetadataSchema .extend({ + columnWidths: z + .record(z.string(), z.number().positive()) + .optional() + .describe('Column widths keyed by column name or stable column identifier.'), + columnOrder: z + .array(z.string()) + .optional() + .describe('Columns in display order, by name or stable identifier.'), + pinnedColumns: z + .array(z.string()) + .optional() + .describe('Pinned columns, by name or stable identifier.'), + hiddenColumns: z + .array(z.string()) + .optional() + .describe('Hidden columns, by name or stable identifier.'), // The v2 predicate/sort grammar — same wire as the query routes, so a saved // view gets the same strictness and depth bounds as a live filter, and its // config can later feed the v2 surfaces without conversion. diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index 9d8187d45fc..9c14af8cd84 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -1043,8 +1043,22 @@ const v2TableViewPredicateOutputSchema = z 'Recursive saved predicate tree. Runtime validation uses the canonical predicate schema.' ) as z.ZodType> +/** + * Every column reference in a v2 view config — the layout keys, `sort[].field`, + * and each `filter` leaf `field` — is a column **NAME**, like row `data`, query + * predicates, and workflow groups on this surface. The stored blob keys on + * stable column ids so a rename cannot orphan a view; the route translates in + * both directions, so a config reads back in the vocabulary it was written in. + */ export const v2TableViewConfigSchema = tableMetadataSchema .extend({ + columnWidths: z + .record(z.string(), z.number().positive()) + .optional() + .describe('Column widths keyed by column name.'), + columnOrder: z.array(z.string()).optional().describe('Column names in display order.'), + pinnedColumns: z.array(z.string()).optional().describe('Names of pinned columns.'), + hiddenColumns: z.array(z.string()).optional().describe('Names of hidden columns.'), filter: v2TableViewPredicateOutputSchema .nullable() .optional() diff --git a/apps/sim/lib/table/__tests__/column-keys.test.ts b/apps/sim/lib/table/__tests__/column-keys.test.ts index 493858e68f7..7ee8f621486 100644 --- a/apps/sim/lib/table/__tests__/column-keys.test.ts +++ b/apps/sim/lib/table/__tests__/column-keys.test.ts @@ -19,6 +19,7 @@ import { generateColumnId, getColumnId, remapGroupColumnRefs, + remapViewConfigColumnRefs, rowDataNameToId, sortNamesToIds, withGeneratedColumnIds, @@ -161,3 +162,55 @@ describe('remapGroupColumnRefs', () => { expect(out.dependencies!.columns).toEqual(['col_existing']) }) }) + +describe('remapViewConfigColumnRefs', () => { + const idByName = new Map([ + ['Name', 'col_a'], + ['Email', 'col_b'], + ]) + const config = { + columnOrder: ['Email', 'col_a'], + pinnedColumns: ['Email'], + hiddenColumns: ['Name'], + columnWidths: { Name: 180, col_b: 240 }, + sort: [{ field: 'Name', direction: 'asc' as const }], + filter: { all: [{ field: 'Email', op: 'eq' as const, value: 'x' }] }, + } + + it('rewrites every column reference and leaves an already-mapped ref alone', () => { + expect(remapViewConfigColumnRefs(config, idByName)).toEqual({ + columnOrder: ['col_b', 'col_a'], + pinnedColumns: ['col_b'], + hiddenColumns: ['col_a'], + columnWidths: { col_a: 180, col_b: 240 }, + sort: [{ field: 'col_a', direction: 'asc' }], + filter: { all: [{ field: 'col_b', op: 'eq', value: 'x' }] }, + }) + }) + + it('inverts cleanly, which is what makes the write/read pair symmetric', () => { + const nameById = new Map([...idByName].map(([name, id]) => [id, name])) + const stored = remapViewConfigColumnRefs(config, idByName) + expect(remapViewConfigColumnRefs(stored, nameById)).toEqual({ + columnOrder: ['Email', 'Name'], + pinnedColumns: ['Email'], + hiddenColumns: ['Name'], + columnWidths: { Name: 180, Email: 240 }, + sort: [{ field: 'Name', direction: 'asc' }], + filter: { all: [{ field: 'Email', op: 'eq', value: 'x' }] }, + }) + }) + + it('leaves a system row column and a since-deleted ref untouched', () => { + const out = remapViewConfigColumnRefs( + { sort: [{ field: 'createdAt', direction: 'desc' }], hiddenColumns: ['col_gone'] }, + idByName + ) + expect(out.sort).toEqual([{ field: 'createdAt', direction: 'desc' }]) + expect(out.hiddenColumns).toEqual(['col_gone']) + }) + + it('leaves absent keys absent rather than materializing empty ones', () => { + expect(remapViewConfigColumnRefs({}, idByName)).toEqual({}) + }) +}) diff --git a/apps/sim/lib/table/application/views.ts b/apps/sim/lib/table/application/views.ts index 1df29d7cc95..b5568e6b237 100644 --- a/apps/sim/lib/table/application/views.ts +++ b/apps/sim/lib/table/application/views.ts @@ -38,12 +38,9 @@ export const listTableViewsUseCase = defineAuthorizedTableUseCase({ assertedWorkspaceId: input.workspaceId, }), async execute({ context }) { - const views = await listTableViews( - context.table.id, - (context.table.schema as TableSchema).columns, - context.workspaceId - ) - return { views } + const columns = (context.table.schema as TableSchema).columns + const views = await listTableViews(context.table.id, columns, context.workspaceId) + return { views, columns } }, }) @@ -55,14 +52,10 @@ export const readTableViewUseCase = defineAuthorizedTableUseCase({ assertedWorkspaceId: input.workspaceId, }), async execute({ input, context }) { - const view = await getTableView( - input.viewId, - context.table.id, - (context.table.schema as TableSchema).columns, - context.workspaceId - ) + const columns = (context.table.schema as TableSchema).columns + const view = await getTableView(input.viewId, context.table.id, columns, context.workspaceId) if (!view) throw new OrchestrationError('not_found', 'View not found') - return { view } + return { view, columns } }, }) @@ -82,6 +75,7 @@ export const createTableViewUseCase = defineAuthorizedTableUseCase({ const attribution = resolvePrincipalAttribution(principal, { workspaceBillingOwnerUserId: context.billedAccountUserId, }) + const columns = (context.table.schema as TableSchema).columns try { const view = await createTableView({ tableId: context.table.id, @@ -89,9 +83,9 @@ export const createTableViewUseCase = defineAuthorizedTableUseCase({ name: input.name, config: input.config, userId: attribution.attributedUserId, - columns: (context.table.schema as TableSchema).columns, + columns, }) - return { view, table: context.table } + return { view, table: context.table, columns } } catch (error) { rethrowViewError(error) } @@ -123,11 +117,12 @@ export const updateTableViewUseCase = defineAuthorizedTableUseCase({ assertedWorkspaceId: input.workspaceId, }), async execute({ input, context }) { + const columns = (context.table.schema as TableSchema).columns try { const existing = await getTableView( input.viewId, context.table.id, - (context.table.schema as TableSchema).columns, + columns, context.workspaceId ) if (!existing) throw new OrchestrationError('not_found', 'View not found') @@ -139,12 +134,13 @@ export const updateTableViewUseCase = defineAuthorizedTableUseCase({ config: input.config, configPatch: input.configPatch, isDefault: input.isDefault, - columns: (context.table.schema as TableSchema).columns, + columns, }) if (!view) throw new OrchestrationError('not_found', 'View not found') return { view, table: context.table, + columns, changed: existing.name !== view.name || existing.isDefault !== view.isDefault || diff --git a/apps/sim/lib/table/column-keys.ts b/apps/sim/lib/table/column-keys.ts index 9fa67433e84..fc92ac7e9fb 100644 --- a/apps/sim/lib/table/column-keys.ts +++ b/apps/sim/lib/table/column-keys.ts @@ -18,6 +18,7 @@ import type { SortSpec, TablePredicate, TableSchema, + TableViewConfig, WorkflowGroup, } from '@/lib/table/types' @@ -122,20 +123,62 @@ export function remapGroupColumnRefs( } } -/** `name → id` for translating inbound wire data (v1 / mothership / CSV import). */ -export function buildIdByName(schema: TableSchema): Map { +/** `name → id` over a bare column list, for callers that hold no full schema. */ +export function buildColumnIdByName(columns: readonly ColumnDefinition[]): Map { const map = new Map() - for (const col of schema.columns) map.set(col.name, getColumnId(col)) + for (const col of columns) map.set(col.name, getColumnId(col)) return map } -/** `id → name` for translating outbound wire data (v1 / mothership / CSV export). */ -export function buildNameById(schema: TableSchema): Map { +/** `id → name` over a bare column list, for callers that hold no full schema. */ +export function buildColumnNameById(columns: readonly ColumnDefinition[]): Map { const map = new Map() - for (const col of schema.columns) map.set(getColumnId(col), col.name) + for (const col of columns) map.set(getColumnId(col), col.name) return map } +/** `name → id` for translating inbound wire data (v1 / mothership / CSV import). */ +export function buildIdByName(schema: TableSchema): Map { + return buildColumnIdByName(schema.columns) +} + +/** `id → name` for translating outbound wire data (v1 / mothership / CSV export). */ +export function buildNameById(schema: TableSchema): Map { + return buildColumnNameById(schema.columns) +} + +/** + * Rewrites every column reference in a saved-view config through `refMap`: the + * layout keys (`columnOrder`, `pinnedColumns`, `hiddenColumns`, and the keys of + * `columnWidths`), each `sort[].field`, and each `filter` leaf `field`. + * + * A ref the map does not know is left as-is, so the rewrite is safe in both + * directions and both vocabularies: call it with {@link buildColumnIdByName} to + * store a name-keyed config, and with {@link buildColumnNameById} to present a + * stored one. Pass-through is what keeps an already-id-keyed config (the + * first-party UI), a system row column (`createdAt`), and a ref to a + * since-deleted column intact. The saved-view analogue of + * {@link remapGroupColumnRefs}. + */ +export function remapViewConfigColumnRefs( + config: TableViewConfig, + refMap: ReadonlyMap +): TableViewConfig { + const remap = (ref: string) => refMap.get(ref) ?? ref + const next: TableViewConfig = { ...config } + if (config.columnOrder) next.columnOrder = config.columnOrder.map(remap) + if (config.pinnedColumns) next.pinnedColumns = config.pinnedColumns.map(remap) + if (config.hiddenColumns) next.hiddenColumns = config.hiddenColumns.map(remap) + if (config.columnWidths) { + const widths: Record = {} + for (const [ref, width] of Object.entries(config.columnWidths)) widths[remap(ref)] = width + next.columnWidths = widths + } + if (config.sort) next.sort = sortSpecNamesToIds(config.sort, refMap) + if (config.filter) next.filter = predicateNamesToIds(config.filter, refMap) + return next +} + /** * Remaps a wire row keyed by column **name** to the stored **id** keying. Used * at the name-translating boundaries on the way in. Keys not matching a known diff --git a/apps/sim/lib/table/query-builder/validate.ts b/apps/sim/lib/table/query-builder/validate.ts index 39422ac1f05..264e082d9c3 100644 --- a/apps/sim/lib/table/query-builder/validate.ts +++ b/apps/sim/lib/table/query-builder/validate.ts @@ -47,6 +47,15 @@ const SYSTEM_COLUMN_TYPES: ReadonlyArray<[string, ColumnType]> = [ ['id', 'string'], ] +/** + * The system column names as a membership set, for the surfaces that decide + * whether a stored field still refers to something real — a check that would + * otherwise drop `createdAt` for the crime of not being in `schema.columns`. + */ +export const SYSTEM_COLUMN_FIELDS: ReadonlySet = new Set( + SYSTEM_COLUMN_TYPES.map(([name]) => name) +) + function buildTypeByName(columns: ColumnDefinition[]): Map { const typeByName = new Map(columns.map((c) => [c.name, c.type])) for (const [name, type] of SYSTEM_COLUMN_TYPES) typeByName.set(name, type) @@ -237,3 +246,20 @@ export function validateStoragePredicate( for (const [name, type] of SYSTEM_COLUMN_TYPES) typeById.set(name, type) validateNode(predicate, typeById) } + +/** + * Validates a STORAGE-keyed sort spec — fields are column ids (plus the system + * columns, which keep their names). The sort counterpart of + * {@link validateStoragePredicate}, for the same reason: after wire translation + * an unresolved field is a typo, and a typo must be refused rather than silently + * ordering by nothing. + */ +export function validateStorageSortSpec(spec: SortSpec, columns: ColumnDefinition[]): void { + const ids = new Set(columns.map(getColumnId)) + for (const { field } of spec) { + validateFieldName(field) + if (!ids.has(field) && !SYSTEM_COLUMN_FIELDS.has(field)) { + throw new TableQueryValidationError(`Unknown sort column "${field}"`, 'INVALID_ORDER') + } + } +} diff --git a/apps/sim/lib/table/views/service.test.ts b/apps/sim/lib/table/views/service.test.ts index 8dd7694cca3..fd0c21d3c01 100644 --- a/apps/sim/lib/table/views/service.test.ts +++ b/apps/sim/lib/table/views/service.test.ts @@ -320,3 +320,136 @@ describe('saved-view ceiling', () => { await expect(create()).resolves.toMatchObject({ id: 'view-100' }) }) }) + +/** + * A saved config's column references are stored as stable column ids, but the + * v2 wire is column-NAME-keyed like every other v2 row/data surface. The write + * path translates; anything it cannot resolve is a caller mistake and must be + * refused rather than stored and quietly dropped on the next read. + */ +describe('view config column-reference normalization', () => { + const columns: ColumnDefinition[] = [ + { id: 'col_a', name: 'Name', type: 'text' }, + { id: 'col_b', name: 'Email', type: 'text' }, + ] + const storedRow = { + id: 'view-1', + tableId: 'table-1', + workspaceId: 'ws-1', + name: 'My View', + config: {}, + isDefault: false, + createdBy: 'user-1', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + } + + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockWithLockedTable.mockImplementation( + async (_tableId: string, mutate: (table: unknown, trx: unknown) => unknown) => + mutate({ id: 'table-1' }, db) + ) + }) + + function insertedConfig(): TableViewConfig { + const [values] = dbChainMockFns.values.mock.calls.at(-1) as [{ config: TableViewConfig }] + return values.config + } + + function create(config: TableViewConfig) { + return createTableView({ + tableId: 'table-1', + workspaceId: 'ws-1', + name: 'My View', + config, + userId: 'user-1', + columns, + }) + } + + it('stores a name-keyed sort as column ids instead of discarding it', async () => { + queueTableRows(tableViews, [{ total: 0 }]) + dbChainMockFns.returning.mockResolvedValueOnce([storedRow]) + + await create({ sort: [{ field: 'Name', direction: 'desc' }] }) + + expect(insertedConfig().sort).toEqual([{ field: 'col_a', direction: 'desc' }]) + }) + + it('stores a name-keyed filter and layout as column ids', async () => { + queueTableRows(tableViews, [{ total: 0 }]) + dbChainMockFns.returning.mockResolvedValueOnce([storedRow]) + + await create({ + filter: { all: [{ field: 'Email', op: 'eq', value: 'x@example.com' }] }, + columnOrder: ['Email', 'Name'], + hiddenColumns: ['Name'], + pinnedColumns: ['Email'], + columnWidths: { Name: 200 }, + }) + + expect(insertedConfig()).toEqual({ + filter: { all: [{ field: 'col_b', op: 'eq', value: 'x@example.com' }] }, + columnOrder: ['col_b', 'col_a'], + hiddenColumns: ['col_a'], + pinnedColumns: ['col_b'], + columnWidths: { col_a: 200 }, + }) + }) + + it('leaves an already id-keyed config untouched', async () => { + queueTableRows(tableViews, [{ total: 0 }]) + dbChainMockFns.returning.mockResolvedValueOnce([storedRow]) + + await create({ + sort: [{ field: 'col_b', direction: 'asc' }], + filter: { all: [{ field: 'col_a', op: 'eq', value: 'x' }] }, + }) + + expect(insertedConfig()).toEqual({ + sort: [{ field: 'col_b', direction: 'asc' }], + filter: { all: [{ field: 'col_a', op: 'eq', value: 'x' }] }, + }) + }) + + it('refuses a filter on a column that does not exist', async () => { + queueTableRows(tableViews, [{ total: 0 }]) + dbChainMockFns.returning.mockResolvedValueOnce([storedRow]) + + await expect( + create({ filter: { all: [{ field: 'ghost', op: 'eq', value: 'x' }] } }) + ).rejects.toMatchObject({ name: 'TableViewValidationError' }) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) + + it('refuses a sort on a column that does not exist', async () => { + queueTableRows(tableViews, [{ total: 0 }]) + dbChainMockFns.returning.mockResolvedValueOnce([storedRow]) + + await expect(create({ sort: [{ field: 'ghost', direction: 'asc' }] })).rejects.toMatchObject({ + name: 'TableViewValidationError', + }) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) + + it('refuses a nonexistent filter column on a configPatch too', async () => { + queueTableRows(tableViews, [{ id: 'view-1' }]) + + await expect( + updateTableView({ + viewId: 'view-1', + tableId: 'table-1', + configPatch: { filter: { all: [{ field: 'ghost', op: 'eq', value: 'x' }] } }, + columns, + }) + ).rejects.toMatchObject({ name: 'TableViewValidationError' }) + }) + + it('keeps a sort on a system row column, which is sortable but not in schema.columns', () => { + expect( + pruneViewConfig({ sort: [{ field: 'createdAt', direction: 'desc' }] }, columns).sort + ).toEqual([{ field: 'createdAt', direction: 'desc' }]) + }) +}) diff --git a/apps/sim/lib/table/views/service.ts b/apps/sim/lib/table/views/service.ts index 5896c31e961..ae17f361115 100644 --- a/apps/sim/lib/table/views/service.ts +++ b/apps/sim/lib/table/views/service.ts @@ -14,10 +14,20 @@ import { tableViews } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, asc, count, eq, ne, sql } from 'drizzle-orm' -import { getColumnId } from '@/lib/table/column-keys' +import { + buildColumnIdByName, + getColumnId, + remapViewConfigColumnRefs, +} from '@/lib/table/column-keys' import { NAME_PATTERN, TABLE_LIMITS } from '@/lib/table/constants' +import { TableQueryValidationError } from '@/lib/table/errors' import { signalTableViewsChanged } from '@/lib/table/events' import { filterRulesToPredicate, filterToRules } from '@/lib/table/query-builder/converters' +import { + SYSTEM_COLUMN_FIELDS, + validateStoragePredicate, + validateStorageSortSpec, +} from '@/lib/table/query-builder/validate' import { withLockedTable } from '@/lib/table/service' import type { ColumnDefinition, @@ -79,13 +89,50 @@ export function pruneViewConfig( pruned.columnWidths = widths } if (config.sort) { - const sort = config.sort.filter((s) => live.has(s.field)) + // `live` holds column ids only. `createdAt`/`updatedAt`/`id` are sortable + // row-level columns that are not in `schema.columns`, so without this a view + // sorted by one of them would prune to "no sort at all" on every read. + const sort = config.sort.filter((s) => live.has(s.field) || SYSTEM_COLUMN_FIELDS.has(s.field)) pruned.sort = sort.length > 0 ? sort : null } return pruned } +/** + * Canonicalizes a caller-supplied config for storage: every column reference is + * rewritten to the column's stable **id**, then the row-selecting parts are + * validated against the live schema. + * + * Two vocabularies reach this write. The first-party UI authors ids; the v2 + * public surface is column-NAME-keyed like every other v2 row/data surface (see + * `presentV2WorkflowGroup`, which converts the same way on the way out). The + * rewrite is a lookup with pass-through, so an id, a system column, and a name + * all land on the one keying `pruneViewConfig` and the query layer read. + * + * `filter` and `sort` are then validated: a predicate or sort naming no column + * is one the query routes answer 400 for, so storing it would save a view that + * can never load. Column LAYOUT is deliberately not validated — it auto-saves as + * the user drags, and racing a concurrent column delete must self-heal through + * {@link pruneViewConfig}, not fail the drag. + */ +export function normalizeViewConfigForStorage( + config: TableViewConfig, + columns: ColumnDefinition[] +): TableViewConfig { + const stored = remapViewConfigColumnRefs(config, buildColumnIdByName(columns)) + try { + if (stored.filter) validateStoragePredicate(stored.filter, columns) + if (stored.sort) validateStorageSortSpec(stored.sort, columns) + } catch (error) { + if (error instanceof TableQueryValidationError) { + throw new TableViewValidationError(error.message) + } + throw error + } + return stored +} + /** * Migrates a config stored before the grammar switch. The feature never * released, so legacy-shaped rows exist only from pre-refactor testing: a @@ -239,6 +286,7 @@ export interface CreateTableViewData { */ export async function createTableView(data: CreateTableViewData): Promise { const name = normalizeName(data.name) + const config = normalizeViewConfigForStorage(data.config, data.columns) const row = await withLockedTable(data.tableId, async (_table, trx) => { const [existing] = await trx @@ -261,7 +309,7 @@ export async function createTableView(data: CreateTableViewData): Promise { + const config = + data.config === undefined ? undefined : normalizeViewConfigForStorage(data.config, data.columns) + const configPatch = + data.configPatch === undefined + ? undefined + : normalizeViewConfigForStorage(data.configPatch, data.columns) + const patch: Partial = { updatedAt: new Date() } if (data.name !== undefined) patch.name = normalizeName(data.name) - if (data.config !== undefined) patch.config = data.config - if (data.configPatch !== undefined) { - patch.config = sql`${tableViews.config} || ${JSON.stringify(data.configPatch)}::jsonb` + if (config !== undefined) patch.config = config + if (configPatch !== undefined) { + patch.config = sql`${tableViews.config} || ${JSON.stringify(configPatch)}::jsonb` } if (data.isDefault !== undefined) patch.isDefault = data.isDefault @@ -325,8 +380,7 @@ export async function updateTableView(data: UpdateTableViewData): Promise Date: Wed, 12 Aug 2026 18:29:23 -0700 Subject: [PATCH 17/56] fix(storage): validate at the decode and multipart boundaries, bound derived keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four caller-reachable 500s shared one shape: input passed boundary validation, then failed in the storage/key layer. Each is fixed at the boundary that owns the transformation, not at the call sites. Percent-encoded NUL in a canonical folder path. `parseRequest`'s NUL scan sees `%00` as three ordinary characters; the NUL only exists after `parseFolderPath` decodes it. Reads survived as 404s, writers carried the decoded name into an INSERT and the driver threw. The rejection now lives in `encodeFolderPathSegment`, the single chokepoint both building and parsing funnel through, so it covers every escape a caller can spell. NUL in a multipart field. A multipart route declares no body contract, so its fields never reach contract validation at all — the knowledge-document key was sanitized while `original_name` was not, and the object landed in storage before the insert threw. `readFormDataWithLimit` is the shared multipart reader every such route already funnels through, so the scan goes there and runs before a caller holds a File to upload, which removes the orphan rather than cleaning it up. Storage-key overflow at 225 characters. Every generator embedded the file name in a path component it also prefixed with a timestamp and a uniquifier, so the effective limit was 255 minus that prefix while the contract advertised 255 — a 225-character name produced a 256-byte component and ENAMETOOLONG from local storage, and the upload session handed out a transfer URL that could never succeed. `buildStorageKeySegment` reserves the prefix out of the component's budget, making the key independent of name length and the declared limit honest. The NUL predicate is now shared from `@sim/utils/string` by all three boundaries instead of being restated at each. --- apps/sim/app/api/help/route.ts | 4 ++ apps/sim/app/api/v1/files/route.ts | 4 ++ .../api/v1/knowledge/[id]/documents/route.ts | 4 ++ .../app/api/v2/files/folders/route.test.ts | 25 +++++++ .../v2/knowledge/[id]/documents/route.test.ts | 22 ++++++ .../api/v2/knowledge/[id]/documents/route.ts | 4 ++ apps/sim/lib/api/server/nul-bytes.ts | 15 ++-- .../tools/handlers/deployment/custom-block.ts | 4 +- apps/sim/lib/core/utils/stream-limits.test.ts | 33 +++++++++ apps/sim/lib/core/utils/stream-limits.ts | 68 +++++++++++++++++- apps/sim/lib/folders/paths.test.ts | 13 ++++ apps/sim/lib/folders/paths.ts | 21 +++++- .../lib/uploads/contexts/execution/utils.ts | 12 ++-- .../knowledge-base-file-manager.ts | 10 ++- .../workspace/workspace-file-manager.test.ts | 12 ++++ .../workspace/workspace-file-manager.ts | 11 ++- apps/sim/lib/uploads/core/storage-key.test.ts | 67 ++++++++++++++++++ apps/sim/lib/uploads/core/storage-key.ts | 69 +++++++++++++++++++ packages/utils/src/string.ts | 15 ++++ 19 files changed, 392 insertions(+), 21 deletions(-) create mode 100644 apps/sim/lib/uploads/core/storage-key.test.ts create mode 100644 apps/sim/lib/uploads/core/storage-key.ts diff --git a/apps/sim/app/api/help/route.ts b/apps/sim/app/api/help/route.ts index b5c25a9c5c3..3bbb7fef636 100644 --- a/apps/sim/app/api/help/route.ts +++ b/apps/sim/app/api/help/route.ts @@ -6,6 +6,7 @@ import { validationErrorResponse } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { + isMultipartFieldValidationError, isPayloadSizeLimitError, MAX_MULTIPART_OVERHEAD_BYTES, readFormDataWithLimit, @@ -145,6 +146,9 @@ ${message} { status: 200 } ) } catch (error) { + if (isMultipartFieldValidationError(error)) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } if (isPayloadSizeLimitError(error)) { logger.warn(`[${requestId}] Help request form data too large`, { message: error.message }) return NextResponse.json( diff --git a/apps/sim/app/api/v1/files/route.ts b/apps/sim/app/api/v1/files/route.ts index 7cc3f0fbd51..36adcb392e4 100644 --- a/apps/sim/app/api/v1/files/route.ts +++ b/apps/sim/app/api/v1/files/route.ts @@ -6,6 +6,7 @@ import { v1ListFilesContract, v1UploadFileFormFieldsSchema } from '@/lib/api/con import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { + isMultipartFieldValidationError, isPayloadSizeLimitError, MAX_MULTIPART_OVERHEAD_BYTES, readFileToBufferWithLimit, @@ -106,6 +107,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (isPayloadSizeLimitError(error)) { return NextResponse.json({ error: error.message }, { status: 413 }) } + if (isMultipartFieldValidationError(error)) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } return NextResponse.json( { error: 'Request body must be valid multipart form data' }, { status: 400 } diff --git a/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts b/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts index 94999266c7a..2a5ddb92f2c 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts @@ -14,6 +14,7 @@ import { statusForOrchestrationError, } from '@/lib/core/orchestration/types' import { + isMultipartFieldValidationError, isPayloadSizeLimitError, MAX_MULTIPART_OVERHEAD_BYTES, readFormDataWithLimit, @@ -117,6 +118,9 @@ export const POST = withRouteHandler( if (isPayloadSizeLimitError(error)) { return NextResponse.json({ error: error.message }, { status: 413 }) } + if (isMultipartFieldValidationError(error)) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } return NextResponse.json( { error: 'Request body must be valid multipart form data' }, { status: 400 } diff --git a/apps/sim/app/api/v2/files/folders/route.test.ts b/apps/sim/app/api/v2/files/folders/route.test.ts index bbdf3ffdf82..8c42e0ebd66 100644 --- a/apps/sim/app/api/v2/files/folders/route.test.ts +++ b/apps/sim/app/api/v2/files/folders/route.test.ts @@ -272,6 +272,31 @@ describe('/api/v2/files/folders', () => { expect((await response.json()).error.code).toBe('NOT_FOUND') }) + it('rejects a percent-encoded NUL in a canonical path before the write reaches Postgres', async () => { + const created = await POST( + request('POST', '/api/v2/files/folders', { + workspaceId: WORKSPACE_ID, + path: '/apitest_%00x', + }), + context + ) + const relocated = await PATCH( + request('PATCH', '/api/v2/files/folders', { + workspaceId: WORKSPACE_ID, + path: '/Reports', + destinationPath: '/apitest_%00b', + }), + context + ) + + expect(created.status).toBe(400) + expect((await created.json()).error.code).toBe('BAD_REQUEST') + expect(relocated.status).toBe(400) + expect((await relocated.json()).error.code).toBe('BAD_REQUEST') + expect(mocks.createFolder).not.toHaveBeenCalled() + expect(mocks.updateFolder).not.toHaveBeenCalled() + }) + it('authenticates before parsing folder input', async () => { v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/route.test.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/route.test.ts index 203817873d4..607c5b6f13a 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/route.test.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/route.test.ts @@ -20,6 +20,7 @@ const { mockPlatformUploaded, mockCapture, mockIsPayloadSizeLimitError, + mockIsMultipartFieldValidationError, } = vi.hoisted(() => ({ mockAdmitUpload: vi.fn(), mockUploadDocument: vi.fn(), @@ -28,6 +29,7 @@ const { mockPlatformUploaded: vi.fn(), mockCapture: vi.fn(), mockIsPayloadSizeLimitError: vi.fn(), + mockIsMultipartFieldValidationError: vi.fn(), })) vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) @@ -56,6 +58,7 @@ vi.mock('@/lib/knowledge/application/documents', () => ({ vi.mock('@/lib/core/utils/stream-limits', () => ({ MAX_MULTIPART_OVERHEAD_BYTES: 1024 * 1024, isPayloadSizeLimitError: mockIsPayloadSizeLimitError, + isMultipartFieldValidationError: mockIsMultipartFieldValidationError, readFormDataWithLimit: mockReadFormData, readFileToBufferWithLimit: mockReadFile, })) @@ -89,6 +92,7 @@ describe('POST /api/v2/knowledge/[id]/documents', () => { v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) v2RouteMocks.gate.mockResolvedValue(null) mockIsPayloadSizeLimitError.mockReturnValue(false) + mockIsMultipartFieldValidationError.mockReturnValue(false) v2RouteMocks.authenticate.mockResolvedValue({ principal: PRINCIPAL, rolloutUserId: 'user-1', @@ -215,6 +219,24 @@ describe('POST /api/v2/knowledge/[id]/documents', () => { expect(mockPlatformUploaded).not.toHaveBeenCalled() }) + it('surfaces an unstorable multipart field as its own bad request', async () => { + const error = new Error( + 'Multipart file name for field "file" cannot contain a NUL character (U+0000)' + ) + mockReadFormData.mockRejectedValueOnce(error) + mockIsMultipartFieldValidationError.mockImplementation( + (candidate: unknown) => candidate === error + ) + + const response = await POST(buildRequest(), { params: Promise.resolve({ id: 'kb-1' }) }) + + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ + error: { code: 'BAD_REQUEST', message: error.message }, + }) + expect(mockUploadDocument).not.toHaveBeenCalled() + }) + it('preserves bounded multipart rejection and stops before the upload operation', async () => { const error = new Error('knowledge document upload body exceeds maximum size') mockReadFormData.mockRejectedValueOnce(error) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts index d41d7a5b719..b7610646ef8 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts @@ -13,6 +13,7 @@ import { import { OrchestrationError } from '@/lib/core/orchestration/types' import { PlatformEvents } from '@/lib/core/telemetry' import { + isMultipartFieldValidationError, isPayloadSizeLimitError, MAX_MULTIPART_OVERHEAD_BYTES, readFileToBufferWithLimit, @@ -167,6 +168,9 @@ export const POST = defineV2BodyLifecycleRoute({ }) } catch (error) { if (isPayloadSizeLimitError(error)) throw error + if (isMultipartFieldValidationError(error)) { + throw new OrchestrationError('validation', error.message) + } throw new OrchestrationError('validation', 'Request body must be valid multipart form data') } diff --git a/apps/sim/lib/api/server/nul-bytes.ts b/apps/sim/lib/api/server/nul-bytes.ts index ff36df53075..0cfc461ae4e 100644 --- a/apps/sim/lib/api/server/nul-bytes.ts +++ b/apps/sim/lib/api/server/nul-bytes.ts @@ -1,4 +1,5 @@ import { isPlainRecord } from '@sim/utils/object' +import { containsNulCharacter } from '@sim/utils/string' import { ZodError } from 'zod' /** @@ -15,9 +16,11 @@ import { ZodError } from 'zod' * break real payloads to fix nothing. Lone surrogates are also left alone: the * driver's UTF-8 encoder substitutes `U+FFFD` rather than throwing, so they are * a data-fidelity question, not an availability one. NUL is the only value in - * this class, and it is rejected on its own. + * this class, and it is rejected on its own. The predicate itself is + * `containsNulCharacter` in `@sim/utils/string`, shared with the multipart + * field scan and the canonical folder-path decoder, which reject the same value + * at boundaries this scan cannot see. */ -const NUL = '\u0000' /** * Cheap existence scan used on every request. Descends only into arrays and @@ -30,7 +33,7 @@ function containsNulByte(root: unknown): boolean { while (stack.length > 0) { const value = stack.pop() if (typeof value === 'string') { - if (value.includes(NUL)) return true + if (containsNulCharacter(value)) return true continue } if (Array.isArray(value)) { @@ -39,7 +42,7 @@ function containsNulByte(root: unknown): boolean { } if (isPlainRecord(value)) { for (const [key, entry] of Object.entries(value)) { - if (key.includes(NUL)) return true + if (containsNulCharacter(key)) return true stack.push(entry) } } @@ -59,7 +62,7 @@ function findNulBytePath(root: unknown): PropertyKey[] { if (!frame) break const { value, path } = frame if (typeof value === 'string') { - if (value.includes(NUL)) return path + if (containsNulCharacter(value)) return path continue } if (Array.isArray(value)) { @@ -72,7 +75,7 @@ function findNulBytePath(root: unknown): PropertyKey[] { const entries = Object.entries(value) for (let index = entries.length - 1; index >= 0; index -= 1) { const [key, entry] = entries[index] - if (key.includes(NUL)) return [...path, key] + if (containsNulCharacter(key)) return [...path, key] stack.push({ value: entry, path: [...path, key] }) } } diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts index 58f1ac5464b..a7ddfb72721 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts @@ -11,6 +11,7 @@ import { import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { canonicalizeVfsPath } from '@/lib/copilot/vfs/path-utils' import { isFeatureEnabled } from '@/lib/core/config/feature-flags' +import { buildStorageKeySegment } from '@/lib/uploads/core/storage-key' import { uploadFile } from '@/lib/uploads/core/storage-service' import { isImageFileType } from '@/lib/uploads/utils/file-utils' import { @@ -77,13 +78,12 @@ async function resolveIconUrl( { fileId: record.id, assertedWorkspaceId: workspaceId, maxBytes: MAX_ICON_BYTES }, { fileId: record.id } ) - const safeFileName = record.name.replace(/[^a-zA-Z0-9.-]/g, '_') const uploaded = await uploadFile({ file: buffer, fileName: record.name, contentType: record.type, context: 'workspace-logos', - customKey: `workspace-logos/${Date.now()}-${generateShortId()}-${safeFileName}`, + customKey: `workspace-logos/${buildStorageKeySegment(`${Date.now()}-${generateShortId()}-`, record.name)}`, preserveKey: true, metadata: { workspaceId, userId: context.userId, originalName: record.name }, }) diff --git a/apps/sim/lib/core/utils/stream-limits.test.ts b/apps/sim/lib/core/utils/stream-limits.test.ts index ae6e9b425d5..eb5cff8fa2b 100644 --- a/apps/sim/lib/core/utils/stream-limits.test.ts +++ b/apps/sim/lib/core/utils/stream-limits.test.ts @@ -6,6 +6,7 @@ import { Readable } from 'stream' import { describe, expect, it, vi } from 'vitest' import { assertContentLengthWithinLimit, + MultipartFieldValidationError, PayloadSizeLimitError, readFileToBufferWithLimit, readFormDataWithLimit, @@ -30,6 +31,25 @@ function streamFromChunks(chunks: Uint8Array[]): ReadableStream { }) } +/** + * Builds a raw multipart body by hand. `FormData` percent-escapes a NUL out of + * a filename on serialization, so a hand-written part is the only way to put + * the byte on the wire exactly as a real client can. + */ +function multipartRequest(disposition: string, value: string): Request { + const boundary = 'streamlimitsboundary' + const body = + `--${boundary}\r\n` + + `Content-Disposition: form-data; ${disposition}\r\n` + + `Content-Type: text/plain\r\n\r\n${value}\r\n` + + `--${boundary}--\r\n` + return new Request('http://localhost/upload', { + method: 'POST', + headers: { 'content-type': `multipart/form-data; boundary=${boundary}` }, + body: new TextEncoder().encode(body), + }) +} + function headers(contentLength?: string): Headers { const headers = new Headers() if (contentLength !== undefined) headers.set('content-length', contentLength) @@ -197,6 +217,19 @@ describe('stream limits', () => { expect(formData.get('name')).toBe('example') }) + it.each([ + ['a NUL in a file name', 'name="file"; filename="apitest_\u0000x.txt"', 'hello'], + ['a NUL in a text field value', 'name="label"', 'apitest_\u0000x'], + ['a NUL in a field name', 'name="apitest_\u0000x"', 'hello'], + ])('rejects multipart form data carrying %s', async (_label, disposition, value) => { + await expect( + readFormDataWithLimit(multipartRequest(disposition, value), { + maxBytes: 1024 * 1024, + label: 'multipart body', + }) + ).rejects.toBeInstanceOf(MultipartFieldValidationError) + }) + it('rejects multipart streams without content-length once bytes exceed the limit', async () => { const request = new Request('http://localhost/upload', { method: 'POST', diff --git a/apps/sim/lib/core/utils/stream-limits.ts b/apps/sim/lib/core/utils/stream-limits.ts index ebbfe4463f8..0c5e75c8715 100644 --- a/apps/sim/lib/core/utils/stream-limits.ts +++ b/apps/sim/lib/core/utils/stream-limits.ts @@ -1,4 +1,5 @@ import { toError } from '@sim/utils/errors' +import { containsNulCharacter } from '@sim/utils/string' export const DEFAULT_MAX_ERROR_BODY_BYTES = 64 * 1024 @@ -71,6 +72,65 @@ export interface ReadFormDataWithLimitRequest { formData: () => Promise } +/** + * A multipart field whose text a downstream store cannot represent. Distinct + * from {@link PayloadSizeLimitError} so a surface can project it as its own 400 + * with the reason intact, and never as a size failure. + */ +export class MultipartFieldValidationError extends Error { + constructor(message: string) { + super(message) + this.name = 'MultipartFieldValidationError' + } +} + +export function isMultipartFieldValidationError( + error: unknown +): error is MultipartFieldValidationError { + return error instanceof MultipartFieldValidationError +} + +/** + * Multipart is the second request boundary, and `parseRequest`'s NUL scan + * cannot reach it: a multipart route declares no body contract, so its fields + * never pass through contract validation at all. A NUL in a `filename` therefore + * reached the knowledge-document insert directly, and because the storage key is + * sanitized while `original_name`/`display_name` are not, the object landed in + * object storage *before* the insert threw — a 500 plus an orphan. + * + * So the scan belongs on the shared multipart reader, which is what every + * multipart route already funnels through, rather than on each route's own + * field extraction. Rejecting here also runs before the caller has a `File` to + * hand to a storage write, which is what removes the orphan rather than + * cleaning it up afterwards. + * + * Only field names, text values, and file names are scanned. A `File`'s bytes + * are deliberately not: a zero *byte* in binary content is legitimate, and the + * bytes never become a text column. + */ +function assertMultipartFieldsAreStorable(formData: FormData): void { + for (const [name, value] of formData.entries()) { + if (containsNulCharacter(name)) { + throw new MultipartFieldValidationError( + 'Multipart field names cannot contain a NUL character (U+0000)' + ) + } + if (typeof value === 'string') { + if (containsNulCharacter(value)) { + throw new MultipartFieldValidationError( + `Multipart field "${name}" cannot contain a NUL character (U+0000)` + ) + } + continue + } + if (containsNulCharacter(value.name)) { + throw new MultipartFieldValidationError( + `Multipart file name for field "${name}" cannot contain a NUL character (U+0000)` + ) + } + } +} + export async function readFormDataWithLimit( request: ReadFormDataWithLimitRequest, options: { maxBytes: number; label: string } @@ -78,7 +138,9 @@ export async function readFormDataWithLimit( assertContentLengthWithinLimit(request.headers, options.maxBytes, options.label) if (request.headers?.get('content-length') || !request.body) { - return request.formData() + const formData = await request.formData() + assertMultipartFieldsAreStorable(formData) + return formData } const body = await readStreamToBufferWithLimit(request.body, options) @@ -87,7 +149,9 @@ export async function readFormDataWithLimit( headers: request.headers, body: new Uint8Array(body), }) - return boundedRequest.formData() + const formData = await boundedRequest.formData() + assertMultipartFieldsAreStorable(formData) + return formData } export interface ReadStreamWithLimitOptions { diff --git a/apps/sim/lib/folders/paths.test.ts b/apps/sim/lib/folders/paths.test.ts index d8273169194..4071f5c1fd8 100644 --- a/apps/sim/lib/folders/paths.test.ts +++ b/apps/sim/lib/folders/paths.test.ts @@ -9,6 +9,7 @@ import { buildFolderPath, buildFolderPathIndex, encodeFolderPathSegment, + FolderPathError, MAX_FOLDER_PATH_SEGMENTS, parseFolderPath, ROOT_FOLDER_PATH, @@ -40,6 +41,18 @@ describe('canonical folder paths', () => { expect(() => parseFolderPath(path)).toThrow() }) + it.each(['/apitest_%00x', '/%00', '/Reports/Q1%00'])( + 'rejects a percent-encoded NUL in path %s', + (path) => { + expect(() => parseFolderPath(path)).toThrow(FolderPathError) + } + ) + + it('rejects a NUL in a folder name before it can be encoded into a path', () => { + expect(() => encodeFolderPathSegment('apitest_\u0000x')).toThrow(FolderPathError) + expect(() => buildFolderPath(['apitest_\u0000x'])).toThrow(FolderPathError) + }) + it('builds a bidirectional index and rejects corrupt hierarchies', () => { const rows = [ { id: 'a', name: 'Reports', parentId: null }, diff --git a/apps/sim/lib/folders/paths.ts b/apps/sim/lib/folders/paths.ts index 45886963053..5de99d4c523 100644 --- a/apps/sim/lib/folders/paths.ts +++ b/apps/sim/lib/folders/paths.ts @@ -1,4 +1,5 @@ import type { folder } from '@sim/db/schema' +import { containsNulCharacter } from '@sim/utils/string' import { OrchestrationError } from '@/lib/core/orchestration/types' export const ROOT_FOLDER_PATH = '/' @@ -54,9 +55,27 @@ function encodedByteLength(value: string): number { return new TextEncoder().encode(value).length } -/** Encodes one stored folder name without normalizing its case or Unicode form. */ +/** + * Encodes one stored folder name without normalizing its case or Unicode form. + * + * This is the single chokepoint for what a folder name may contain: every path + * built from names passes through it, and {@link parseFolderPath} re-encodes + * each decoded segment through it to prove canonicality. So the NUL rejection + * belongs here rather than at either caller. + * + * The request-level scan in `@/lib/api/server/nul-bytes` cannot cover this. A + * folder path arrives percent-encoded, so the scan sees `%00` — three ordinary + * characters — and passes it, and the NUL only exists after this module decodes + * it. Reads happened to survive (an unmatched path is a 404); writers carried + * the decoded name into an `INSERT` and the driver threw a 500. Validating at + * the decode boundary covers every percent-encoded escape a caller can spell, + * not just the one that was reported. + */ export function encodeFolderPathSegment(name: string): string { if (name.length === 0) throw new FolderPathError('Folder names cannot be empty') + if (containsNulCharacter(name)) { + throw new FolderPathError('Folder names cannot contain a NUL character (U+0000)') + } if (name === '.') return '%2E' if (name === '..') return '%2E%2E' diff --git a/apps/sim/lib/uploads/contexts/execution/utils.ts b/apps/sim/lib/uploads/contexts/execution/utils.ts index b426d0515b3..d64969c78eb 100644 --- a/apps/sim/lib/uploads/contexts/execution/utils.ts +++ b/apps/sim/lib/uploads/contexts/execution/utils.ts @@ -1,6 +1,7 @@ import { generateId } from '@sim/utils/id' import { randomFloat } from '@sim/utils/random' -import { isUuid, sanitizeFileName } from '@/executor/constants' +import { buildStorageKeySegment } from '@/lib/uploads/core/storage-key' +import { isUuid } from '@/executor/constants' import type { UserFile } from '@/executor/types' /** @@ -24,7 +25,7 @@ export interface ExecutionContext { */ export function generateLargeValuePayloadKey(context: ExecutionContext, id: string): string { const { workspaceId, workflowId, executionId } = context - const safeFileName = sanitizeFileName(`large-value-${id}.json`) + const safeFileName = buildStorageKeySegment('', `large-value-${id}.json`) return `execution/${workspaceId}/${workflowId}/${executionId}/${safeFileName}` } @@ -37,7 +38,10 @@ export function generateLargeValuePayloadKey(context: ExecutionContext, id: stri * loop), which the deterministic key would overwrite. The unique id is its own * path segment rather than a filename prefix so the last segment stays the * original name — presigned URLs carry no content-disposition, so that segment - * is what a consumer sees. + * is what a consumer sees. It is still bounded by + * {@link buildStorageKeySegment}: a name past one path component's byte limit + * is `ENAMETOOLONG` on local storage, and an unreadable 500 beats a slightly + * shortened display name. * * Large-value payloads, whose ids are already unique, keep using * {@link generateLargeValuePayloadKey}. @@ -47,7 +51,7 @@ export function generateUniqueExecutionFileKey( fileName: string ): string { const { workspaceId, workflowId, executionId } = context - const safeFileName = sanitizeFileName(fileName) + const safeFileName = buildStorageKeySegment('', fileName) return `execution/${workspaceId}/${workflowId}/${executionId}/${generateId()}/${safeFileName}` } diff --git a/apps/sim/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager.ts b/apps/sim/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager.ts index fbaea2c0bf7..3084fe9150a 100644 --- a/apps/sim/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager.ts @@ -1,5 +1,5 @@ import { randomBytes } from 'crypto' -import { sanitizeFileName } from '@/executor/constants' +import { buildStorageKeySegment } from '@/lib/uploads/core/storage-key' /** * Generate a canonical knowledge-base storage key. @@ -7,10 +7,14 @@ import { sanitizeFileName } from '@/executor/constants' * Direct/presigned uploads previously used the generic `${context}/...` key * shape (`knowledge-base/...`). New KB uploads should use the same `kb/...` * prefix as server-side uploads so key-derived context inference is consistent. + * + * The uniquifier shares a path component with the name, so + * {@link buildStorageKeySegment} reserves it out of that component's byte + * budget: a document uploaded over multipart carries an unbounded filename, and + * a long one otherwise produced an `ENAMETOOLONG` 500 from local storage. */ export function generateKnowledgeBaseFileKey(fileName: string): string { const timestamp = Date.now() const random = randomBytes(8).toString('hex') - const safeFileName = sanitizeFileName(fileName) - return `kb/${timestamp}-${random}-${safeFileName}` + return `kb/${buildStorageKeySegment(`${timestamp}-${random}-`, fileName)}` } diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.test.ts index f867639089e..83c4186067d 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.test.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest' import { findWorkspaceFileRecord, + generateWorkspaceFileKey, normalizeWorkspaceFileReference, type WorkspaceFileRecord, } from './workspace-file-manager' @@ -90,3 +91,14 @@ describe('workspace file reference normalization', () => { ) }) }) + +describe('workspace file storage keys', () => { + it('keeps the last key component within one path component for the longest admitted name', () => { + const key = generateWorkspaceFileKey('ws_123', `${'a'.repeat(251)}.txt`) + const lastSegment = key.slice(key.lastIndexOf('/') + 1) + + expect(Buffer.byteLength(lastSegment, 'utf-8')).toBeLessThanOrEqual(255) + expect(key.startsWith('workspace/ws_123/')).toBe(true) + expect(lastSegment.endsWith('.txt')).toBe(true) + }) +}) diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index cc83cc5e98d..ae1a45cc9bf 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -57,6 +57,7 @@ import { type WorkspaceFileSecretProvenance, type WorkspaceFileSecretProvenancePolicy, } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { buildStorageKeySegment } from '@/lib/uploads/core/storage-key' import { deleteFile, downloadFile, @@ -67,7 +68,7 @@ import { import { MAX_WORKSPACE_FILE_SIZE, toLegacyWorkspaceFileSize } from '@/lib/uploads/shared/types' import { isMarkdownFile } from '@/lib/uploads/utils/file-utils' import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' -import { isUuid, sanitizeFileName } from '@/executor/constants' +import { isUuid } from '@/executor/constants' import type { UserFile } from '@/executor/types' import type { WorkspaceFileFolderRecord } from './workspace-file-folder-manager' import { @@ -201,12 +202,16 @@ export function parseWorkspaceFileKey(key: string): string | null { /** * Generate workspace-scoped storage key with explicit prefix * Format: workspace/{workspaceId}/{timestamp}-{random}-{filename} + * + * The name shares its path component with the uniquifier, so + * {@link buildStorageKeySegment} reserves that prefix out of the component's + * byte budget — otherwise the effective name limit is smaller than the 255 the + * file contracts advertise. */ export function generateWorkspaceFileKey(workspaceId: string, fileName: string): string { const timestamp = Date.now() const random = randomBytes(8).toString('hex') - const safeFileName = sanitizeFileName(fileName) - return `workspace/${workspaceId}/${timestamp}-${random}-${safeFileName}` + return `workspace/${workspaceId}/${buildStorageKeySegment(`${timestamp}-${random}-`, fileName)}` } const MAX_COPY_SUFFIX = 1000 diff --git a/apps/sim/lib/uploads/core/storage-key.test.ts b/apps/sim/lib/uploads/core/storage-key.test.ts new file mode 100644 index 00000000000..1bfd9e9bfc4 --- /dev/null +++ b/apps/sim/lib/uploads/core/storage-key.test.ts @@ -0,0 +1,67 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it } from 'vitest' +import { + generateLargeValuePayloadKey, + generateUniqueExecutionFileKey, +} from '@/lib/uploads/contexts/execution/utils' +import { generateKnowledgeBaseFileKey } from '@/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager' +import { buildStorageKeySegment } from '@/lib/uploads/core/storage-key' + +/** Bytes in the last path component — what POSIX `NAME_MAX` actually bounds. */ +function lastSegmentBytes(key: string): number { + return Buffer.byteLength(key.slice(key.lastIndexOf('/') + 1), 'utf-8') +} + +/** Longest name the workspace-file and knowledge-document contracts admit. */ +const MAX_CONTRACT_NAME = `${'a'.repeat(251)}.txt` + +describe('storage key segments', () => { + it('keeps the name when it already fits, sanitizing only', () => { + expect(buildStorageKeySegment('123-abc-', 'quarterly report.csv')).toBe( + '123-abc-quarterly-report.csv' + ) + }) + + it('reserves the prefix out of the segment budget', () => { + const segment = buildStorageKeySegment('123-abc-', MAX_CONTRACT_NAME) + + expect(Buffer.byteLength(segment, 'utf-8')).toBe(255) + expect(segment.startsWith('123-abc-')).toBe(true) + expect(segment.endsWith('.txt')).toBe(true) + }) + + it('drops an extension that would consume the whole budget', () => { + const segment = buildStorageKeySegment('', `name.${'x'.repeat(300)}`) + + expect(Buffer.byteLength(segment, 'utf-8')).toBe(255) + }) + + it('refuses a prefix that leaves no room for a name', () => { + expect(() => buildStorageKeySegment('p'.repeat(255), 'a.txt')).toThrow('no room') + }) + + it.each([ + ['knowledge base', () => generateKnowledgeBaseFileKey(MAX_CONTRACT_NAME)], + [ + 'execution file', + () => + generateUniqueExecutionFileKey( + { workspaceId: 'ws', workflowId: 'wf', executionId: 'ex' }, + MAX_CONTRACT_NAME + ), + ], + [ + 'large value payload', + () => + generateLargeValuePayloadKey( + { workspaceId: 'ws', workflowId: 'wf', executionId: 'ex' }, + 'p' + ), + ], + ])('bounds the last component of a %s key', (_label, generate) => { + expect(lastSegmentBytes(generate())).toBeLessThanOrEqual(255) + }) +}) diff --git a/apps/sim/lib/uploads/core/storage-key.ts b/apps/sim/lib/uploads/core/storage-key.ts new file mode 100644 index 00000000000..4c9d146a720 --- /dev/null +++ b/apps/sim/lib/uploads/core/storage-key.ts @@ -0,0 +1,69 @@ +import { sanitizeFileName } from '@/executor/constants' + +/** + * POSIX `NAME_MAX`. It bounds one *path component*, not the whole key, and it + * counts bytes. Local storage writes a key straight into the upload directory, + * so a key whose last component crosses this throws `ENAMETOOLONG` out of + * `writeFile` — an unclassifiable 500 on a name the contract already accepted. + */ +const MAX_STORAGE_KEY_SEGMENT_BYTES = 255 + +/** + * Longest trailing `.ext` worth preserving through a truncation. Beyond this + * the dot is part of the name, not a type marker, and keeping it would eat the + * whole budget. + */ +const MAX_PRESERVED_EXTENSION_LENGTH = 16 + +/** + * Fits a sanitized name into `budget` characters, keeping its extension so a + * truncated key still reads as the same kind of file. + * + * `sanitizeFileName` maps every character outside `[A-Za-z0-9.-]` to `_`, so its + * output is pure ASCII and one character is one byte. That is what lets this + * measure the budget with `length` instead of re-encoding. + */ +function fitStorageKeyName(safeName: string, budget: number): string { + if (safeName.length <= budget) return safeName + + const dotIndex = safeName.lastIndexOf('.') + const extension = dotIndex > 0 ? safeName.slice(dotIndex) : '' + if (extension.length === 0 || extension.length > MAX_PRESERVED_EXTENSION_LENGTH) { + return safeName.slice(0, budget) + } + if (extension.length >= budget) return safeName.slice(0, budget) + return safeName.slice(0, budget - extension.length) + extension +} + +/** + * Builds the last component of a storage key from a caller-supplied file name. + * + * The defect this exists to remove: every key generator embedded the file name + * in a component it also prefixed with a timestamp and a random uniquifier, so + * the *effective* name limit was `255 − prefix`, not the 255 the contract + * advertises. A 225-character name — well inside `maxLength: 255` — produced a + * 256-byte component and a 500, while 256 characters was correctly a 400. The + * upload-session path was worse: admission accepted the name, handed back a + * transfer URL, and every later request against that session failed. + * + * Fixing it by shrinking the declared `maxLength` would make each caller's limit + * a function of its own key prefix and would break names that already store + * fine on S3 and GCS, which have no per-component limit. So the budget is + * reserved here instead: the key is made independent of the name's length, the + * declared limit stays honest, and no name a contract admits can produce a key + * a store rejects. The name in a key is a debugging convenience — the row's + * `originalName` is the identity — so truncating it costs nothing. + * + * @param prefix Fixed leading text of the component (uniquifier, timestamp). + * Must itself leave room for at least one character of the name. + * @param fileName Raw caller-supplied name; sanitized here. + */ +export function buildStorageKeySegment(prefix: string, fileName: string): string { + const budget = MAX_STORAGE_KEY_SEGMENT_BYTES - prefix.length + if (budget < 1) { + throw new Error( + `Storage key prefix of ${prefix.length} bytes leaves no room for a file name within ${MAX_STORAGE_KEY_SEGMENT_BYTES} bytes` + ) + } + return `${prefix}${fitStorageKeyName(sanitizeFileName(fileName), budget)}` +} diff --git a/packages/utils/src/string.ts b/packages/utils/src/string.ts index 3feb0427aff..8adfb932e10 100644 --- a/packages/utils/src/string.ts +++ b/packages/utils/src/string.ts @@ -1,3 +1,18 @@ +/** + * `U+0000` is the one code point a Postgres `text`/`jsonb` value cannot carry: + * the wire protocol terminates strings on it, so the driver throws before the + * statement is planned, and the throw carries no SQLSTATE a route layer can + * classify. Every boundary that admits caller-supplied text — the JSON request + * scan, the multipart field scan, and the canonical folder-path decoder — + * rejects it, so the predicate lives here instead of being restated at each. + */ +export const NUL_CHARACTER = '\u0000' + +/** Reports whether `value` carries a `U+0000`. See {@link NUL_CHARACTER}. */ +export function containsNulCharacter(value: string): boolean { + return value.includes(NUL_CHARACTER) +} + /** * Truncates `str` if it exceeds `sliceLength` characters, appending `suffix`. * The total output length when truncated is `sliceLength + suffix.length`. From 8e357cf2eec8b9d5bbc3373efa41f077b1cee083 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 18:38:03 -0700 Subject: [PATCH 18/56] docs(v2): make the published spec describe the API it has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three descriptions asserted behavior the code no longer has, and three rules the code enforces were published as unconstrained strings. `downloadFile` and `listMcpServerTools` still told callers a `HEAD` on a not-head-safe route "is answered with an empty 200 ... reports only that the endpoint exists and the caller is authorized". That was true of the old short-circuit, which sat between admission and parsing and therefore returned 200 for an id the same caller's `GET` refused. The builders now authorize a HEAD exactly as the GET, so the spec said the opposite of a security fix. One `HEAD_MIRRORS_GET` constant replaces both sentences and is added to `exportWorkflow`, whose `headSafe: false` was never documented at all. A test walks the `app/api/v2` tree for the declaration and fails on any operation that carries it without the sentence, or that resurrects the old claim. `createMcpServer` promised that re-registering an existing URL "rewrites the configuration and returns the server to the same unverified state"; it is a 409 pointing at PATCH. `authType` claimed Sim "detects it from the server when omitted" — registration deliberately never contacts the server, and the column defaults to `headers`. The default stays: `headers` and `none` are behaviourally identical (only `oauth` branches), so changing it is a migration with no caller-visible payoff, while the sentence was simply false. `predicate` was the API's most consequential gap: a `pipe` over `z.unknown()` documents from its input, so the leaf keys `field`/`op`/`value` appeared nowhere in the contract and `{column, operator, value}` was a 400 a caller could not correct against. Both predicate schemas now publish a real recursive JSON Schema through `.meta()`, self-referencing so the recursion resolves from one `$defs` entry, with every bound read from the constant that enforces it. Also published: the canonical folder-path rule and its 4096-byte cap on the four path components (the `superRefine` contributed nothing to JSON Schema); the closed 12-value `recursive` vocabulary on a destructive delete; and the null-matching behaviour of the negating operators. The clamping `limit` branch drops `minimum`/`maximum`, which in JSON Schema mean "rejected outside" and made SDKs refuse locally what the server clamps. `deleteFile` stops publishing a 409 nothing in its path can raise. `restoreFile` and `abortFileUpload` keep theirs — the report called them unemittable, but restore raises `FileConflictError` after exhausting its rename retries and abort refuses a completed session. Description tail, across the seven specs: p99 733 to 465, max operation 1643 to 1114, over 700 chars 31 to 13, over 400 70 to 61. Constraints moved from operation prose onto the fields they constrain rather than being deleted. --- apps/docs/openapi-v2-billing.json | 4 +- apps/docs/openapi-v2-files-audit.json | 99 ++-- apps/docs/openapi-v2-knowledge.json | 78 +++- apps/docs/openapi-v2-logs.json | 14 +- apps/docs/openapi-v2-resources.json | 44 +- apps/docs/openapi-v2-tables.json | 428 ++++++++++++++++-- apps/docs/openapi-v2-workflows.json | 97 ++-- .../api/contracts/tables-predicate.test.ts | 46 ++ apps/sim/lib/api/contracts/tables.ts | 117 ++++- .../v2/__tests__/pagination-limit.test.ts | 22 + .../api/contracts/v2/__tests__/shared.test.ts | 65 +++ apps/sim/lib/api/contracts/v2/files.ts | 20 +- apps/sim/lib/api/contracts/v2/knowledge.ts | 2 +- apps/sim/lib/api/contracts/v2/logs.ts | 4 +- apps/sim/lib/api/contracts/v2/mcp-servers.ts | 23 +- .../lib/api/contracts/v2/openapi/billing.ts | 4 +- .../api/contracts/v2/openapi/files-audit.ts | 12 +- .../v2/openapi/head-not-safe.test.ts | 68 +++ .../lib/api/contracts/v2/openapi/knowledge.ts | 8 +- apps/sim/lib/api/contracts/v2/openapi/logs.ts | 4 +- .../lib/api/contracts/v2/openapi/resources.ts | 9 +- .../lib/api/contracts/v2/openapi/shared.ts | 15 + .../lib/api/contracts/v2/openapi/tables.ts | 2 +- .../lib/api/contracts/v2/openapi/workflows.ts | 7 +- apps/sim/lib/api/contracts/v2/shared.ts | 112 ++++- apps/sim/lib/api/contracts/v2/workflows.ts | 16 +- apps/sim/lib/table/query-builder/predicate.ts | 7 +- 27 files changed, 1108 insertions(+), 219 deletions(-) create mode 100644 apps/sim/lib/api/contracts/v2/openapi/head-not-safe.test.ts diff --git a/apps/docs/openapi-v2-billing.json b/apps/docs/openapi-v2-billing.json index 9f465cd766d..6c6c4fa7b0f 100644 --- a/apps/docs/openapi-v2-billing.json +++ b/apps/docs/openapi-v2-billing.json @@ -36,7 +36,7 @@ "get": { "operationId": "getBillingStatus", "summary": "Get Billing Status", - "description": "Return the current plan, billing standing, credit allowance, and storage quota. `credits` and `storage` report the payer's pooled allowances and are null unless the caller can manage that payer's billing; they are always null for a workspace API key. Billing history lives at `GET /api/v2/billing/logs`. Without a Stripe subscription — notably on the free plan — there is no real billing period: `period` is the open interval 1970-01-01 to 9999-12-31 and `credits.used` is lifetime consumption, not consumption since a period start.", + "description": "Return the current plan, billing standing, credit allowance, and storage quota. `credits` and `storage` report the payer's pooled allowances and are null unless the caller can manage that payer's billing; they are always null for a workspace API key. Billing history lives at `GET /api/v2/billing/logs`.", "tags": ["Billing"], "parameters": [ { @@ -101,7 +101,7 @@ "get": { "operationId": "listBillingLogs", "summary": "List Billing Logs", - "description": "List the credit-denominated billing ledger with source filtering and opaque cursor pagination. `period` defaults to `30d`, so an unqualified request covers only the last 30 days: paginating to `nextCursor: null` exhausts that window, not the whole ledger. Pass `period=all` for full history, or `period=custom` with `startDate` and `endDate` for a specific range. Both bounds are accepted only with `period=custom` — sending either alongside a relative period is a 400, never a page silently answered over a different window — and both take the same strict UTC ISO 8601 form as `GET /api/v2/logs`. An inverted window is a 400 rather than an empty page.", + "description": "List the credit-denominated billing ledger with source filtering and opaque cursor pagination. `period` defaults to `30d`, so an unqualified request covers only the last 30 days: paginating to `nextCursor: null` exhausts that window, not the whole ledger. An inverted custom window is a 400 rather than an empty page.", "tags": ["Billing"], "parameters": [ { diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index df6f9abbeb1..eaaf52c9eb2 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -61,7 +61,7 @@ "description": "Restrict results to files directly inside this folder.", "schema": { "description": "Restrict results to files directly inside this folder.", - "type": "string" + "$ref": "#/components/schemas/FolderPathInput" } }, { @@ -92,10 +92,10 @@ "name": "sortBy", "in": "query", "required": false, - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "schema": { "default": "uploadedAt", - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "type": "string", "enum": ["name", "size", "uploadedAt", "updatedAt"] } @@ -120,8 +120,6 @@ "schema": { "description": "Maximum files per page. Values outside 1–1000 are truncated and clamped into that range rather than rejected. Defaults to 100.", "type": "integer", - "minimum": 1, - "maximum": 1000, "default": 100 } }, @@ -607,7 +605,7 @@ "get": { "operationId": "downloadFile", "summary": "Download File", - "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. Downloading is recorded as an audit event, so it is not a safe read: a `HEAD` request is answered with an empty `200` without fetching the bytes or recording anything, and reports only that the endpoint exists and the caller is authorized.", + "description": "Download the current file bytes from a workspace. A generated document is served as its compiled artifact, so it answers `409` while that artifact is still compiling and `413` if it renders past the size ceiling. Downloading records an audit event, so it is not a safe read. A `HEAD` skips the effect but is authorized exactly as the `GET` is, so it answers `400`, `401`, `403`, or `404` wherever the `GET` would and an empty `200` otherwise.", "tags": ["Files"], "parameters": [ { @@ -699,7 +697,7 @@ "delete": { "operationId": "deleteFile", "summary": "Delete File", - "description": "Archive a workspace file. This is a soft delete: the row is retained with a deletion timestamp, the file stops appearing in the default listing and is no longer readable through the API, and its stored bytes are never removed. List archived files with `GET /files?scope=archived` and reverse the delete with `POST /files/{fileId}/restore`.", + "description": "Archive a workspace file. This is a soft delete: the row is retained with a deletion timestamp, the file stops appearing in the default listing and is no longer readable through the API, and its stored bytes are never removed. Archiving an already-archived file is a `404`, not a no-op. List archived files with `GET /files?scope=archived` and reverse the delete with `POST /files/{fileId}/restore`.", "tags": ["Files"], "parameters": [ { @@ -761,9 +759,6 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -862,7 +857,7 @@ "post": { "operationId": "restoreFile", "summary": "Restore File", - "description": "Reverse a soft delete and return the file to the workspace. Restore is not a pure undo: the file comes back at the workspace root regardless of the folder it was deleted from, and it gains a `_restored` suffix when another file at the root already holds its name — so read `folderPath` and `name` off the response rather than assuming the pre-delete values. Restoring a file that is already active is a no-op that returns that file, so a retry is safe. Returns 400 when the workspace itself has been archived, and 409 when no free restore name could be found.", + "description": "Reverse a soft delete and return the file to the workspace. Restore is not a pure undo — the file comes back at the workspace root, and gains a `_restored` suffix when another file there already holds its name — so read `folderPath` and `name` off the response. Restoring an already-active file is a no-op that returns it, so a retry is safe. An archived workspace is a 400, and a name the restore could not free is a 409.", "tags": ["Files"], "parameters": [ { @@ -1418,7 +1413,7 @@ "patch": { "operationId": "upsertFileShare", "summary": "Enable or Disable File Share", - "description": "Create or partially update a server-tokenized public share. Only isActive is required, and an omitted authType keeps the stored auth mode. What happens to password and allowedEmails depends on the resulting mode, because enabling a share always rewrites the credentials the chosen mode does not use: 'public' clears the stored password and empties allowedEmails; 'password' keeps the stored password when password is omitted but empties allowedEmails; 'email' and 'sso' clear the stored password and keep the stored allowedEmails when the field is omitted. Only disabling with isActive false preserves the whole access configuration untouched — it also retains the token, so re-enabling restores the share as it was. Two enabling combinations are rejected outright with a 400 instead of being partially applied: 'password' when neither a password is supplied nor one is already stored, and 'email' or 'sso' when the resulting allowedEmails would be empty because none was supplied and none is stored. On a file that has never been shared there is nothing stored to fall back on, so enabling any mode other than 'public' must carry its credential in the same request. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.", + "description": "Create or partially update a server-tokenized public share. Only `isActive` is required; each other field states what enabling a mode does to it. A file that has never been shared has nothing stored to fall back on, so enabling any mode other than `public` must carry its credential in the same request. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.", "tags": ["Files"], "parameters": [ { @@ -1670,7 +1665,7 @@ "description": "Restrict results to direct children of this parent path.", "schema": { "description": "Restrict results to direct children of this parent path.", - "type": "string" + "$ref": "#/components/schemas/FolderPathInput" } }, { @@ -1689,10 +1684,10 @@ "name": "sortBy", "in": "query", "required": false, - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "schema": { "default": "name", - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "type": "string", "enum": ["name", "createdAt", "updatedAt"] } @@ -1913,16 +1908,30 @@ "description": "Path of the folder to delete.", "schema": { "description": "Path of the folder to delete.", - "type": "string" + "$ref": "#/components/schemas/NonRootFolderPathInput" } }, { "name": "recursive", "in": "query", "required": false, - "description": "Delete nested files and folders when true.", - "schema": { - "description": "Delete nested files and folders when true.", + "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. Any listed spelling is accepted in any case, and any other value is rejected.", + "schema": { + "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. Any listed spelling is accepted in any case, and any other value is rejected.", + "enum": [ + "true", + "1", + "yes", + "on", + "y", + "enabled", + "false", + "0", + "no", + "off", + "n", + "disabled" + ], "default": "false", "type": "string" } @@ -2257,6 +2266,12 @@ } ] }, + "FolderPathInput": { + "title": "Folder path input", + "description": "Folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. Unicode is supported encoded. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", + "maxLength": 4096, + "type": "string" + }, "V2File": { "type": "object", "properties": { @@ -2288,7 +2303,9 @@ }, "folderPath": { "type": "string", - "description": "Canonical containing-folder path. `/` is the workspace root." + "title": "Folder path", + "description": "Canonical containing-folder path. `/` is the workspace root.", + "maxLength": 4096 }, "uploadedByEmail": { "type": "string", @@ -2436,7 +2453,7 @@ }, "folderPath": { "description": "Canonical containing-folder path. Omit for the workspace root.", - "type": "string" + "$ref": "#/components/schemas/FolderPathInput" }, "content": { "default": "", @@ -2656,7 +2673,7 @@ }, "folderPath": { "description": "Canonical destination folder path. Omit for the workspace root.", - "type": "string" + "$ref": "#/components/schemas/FolderPathInput" } }, "required": ["workspaceId", "name", "contentType", "size"], @@ -2968,7 +2985,9 @@ }, "folderPath": { "type": "string", - "description": "Canonical containing-folder path. `/` is the workspace root." + "title": "Folder path", + "description": "Canonical containing-folder path. `/` is the workspace root.", + "maxLength": 4096 }, "uploadedByEmail": { "type": "string", @@ -3347,7 +3366,7 @@ }, "targetFolderPath": { "description": "Destination folder path. Omit to move files to the workspace root.", - "type": "string" + "$ref": "#/components/schemas/FolderPathInput" } }, "required": ["workspaceId", "fileIds"], @@ -3431,21 +3450,21 @@ }, "isActive": { "type": "boolean", - "description": "Whether the share should resolve." + "description": "Whether the share should resolve. Disabling preserves the token and the whole access configuration, so re-enabling restores the share as it was; enabling rewrites the credentials the resulting mode does not use." }, "authType": { - "description": "How access to the share is gated.", + "description": "How access to the share is gated. The stored mode is kept when omitted. Enabling `public` clears the stored password and empties `allowedEmails`; `password` empties `allowedEmails`; `email` and `sso` clear the stored password.", "type": "string", "enum": ["public", "password", "email", "sso"] }, "password": { - "description": "Password for a password-gated share.", + "description": "Password for a password-gated share. Kept when omitted; enabling `password` with neither a supplied nor a stored password is a 400.", "type": "string", "minLength": 1, "maxLength": 1024 }, "allowedEmails": { - "description": "Allowed addresses or @domain patterns for email and SSO shares.", + "description": "Allowed addresses or `@domain` patterns for email and SSO shares. Kept when omitted; enabling `email` or `sso` with an empty resulting list is a 400.", "maxItems": 200, "type": "array", "items": { @@ -3586,11 +3605,15 @@ }, "path": { "type": "string", - "description": "Canonical folder path used as the public folder identifier." + "title": "Non-root folder path", + "description": "Canonical folder path used as the public folder identifier.", + "maxLength": 4096 }, "parentPath": { "type": "string", - "description": "Canonical parent path; `/` is the root." + "title": "Folder path", + "description": "Canonical parent path; `/` is the root.", + "maxLength": 4096 }, "createdAt": { "type": "string", @@ -3648,6 +3671,12 @@ "title": "File folder response", "description": "A single workspace file folder." }, + "NonRootFolderPathInput": { + "title": "Non-root folder path input", + "description": "Non-root folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. Unicode is supported encoded. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", + "maxLength": 4096, + "type": "string" + }, "CreateFileFolderRequest": { "type": "object", "properties": { @@ -3658,7 +3687,7 @@ }, "path": { "description": "Path of the folder to create.", - "type": "string" + "$ref": "#/components/schemas/NonRootFolderPathInput" } }, "required": ["workspaceId", "path"], @@ -3676,11 +3705,11 @@ }, "path": { "description": "Current folder path.", - "type": "string" + "$ref": "#/components/schemas/NonRootFolderPathInput" }, "destinationPath": { "description": "New full path for the folder and its descendants.", - "type": "string" + "$ref": "#/components/schemas/NonRootFolderPathInput" } }, "required": ["workspaceId", "path", "destinationPath"], @@ -3693,7 +3722,9 @@ "properties": { "path": { "type": "string", - "description": "Deleted folder path." + "title": "Folder path", + "description": "Deleted folder path.", + "maxLength": 4096 }, "deleted": { "type": "boolean", diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index cfc45fab919..a6bb105ad89 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -57,7 +57,7 @@ "description": "Restrict results to knowledge bases in this folder.", "schema": { "description": "Restrict results to knowledge bases in this folder.", - "type": "string" + "$ref": "#/components/schemas/FolderPathInput" } }, { @@ -76,10 +76,10 @@ "name": "sortBy", "in": "query", "required": false, - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "schema": { "default": "createdAt", - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "type": "string", "enum": ["name", "createdAt", "updatedAt"] } @@ -474,7 +474,7 @@ "post": { "operationId": "searchKnowledge", "summary": "Search Knowledge", - "description": "Search one or more knowledge bases with semantic vector retrieval, optional hybrid full-text retrieval, and structured tag filters. Set `rerankerEnabled` to re-order the retrieved chunks with a reranking model before truncating to `topK`; `rerankerModel` selects the model and defaults when omitted. Reranked results carry a `rerankerScore` and are ordered by it, and reranking is billed as an additional search unit. Reranking is best-effort: a reranker that cannot run — a provider failure, a timeout, or a deployment with no reranking credential — falls back to vector ordering rather than failing the search, so read `rerankerStatus` on the response to tell an ordering the reranker produced from one it never touched. Every result names the `knowledgeBaseId` it came from. The request body is capped at 2 MiB; a larger body is a 413.", + "description": "Search one or more knowledge bases with semantic vector retrieval, optional hybrid full-text retrieval, and structured tag filters. Every result names the `knowledgeBaseId` it came from. The request body is capped at 2 MiB; a larger body is a 413.", "tags": ["Knowledge Bases"], "requestBody": { "required": true, @@ -543,7 +543,7 @@ "get": { "operationId": "listKnowledgeTags", "summary": "List Tags", - "description": "List the knowledge base's tag vocabulary: each tag's display name, the slot it is stored in, and its field type. Display names are what tag filters and the tag values on document reads use; slots are what document writes set. Every slot listed here is writable, in its declared type: `tag1`..`tag7` take a string, `number1`..`number5` a number, `date1`..`date2` a `YYYY-MM-DD` string, and `boolean1`..`boolean3` a boolean. The vocabulary is bounded by the fixed slot table. The bounded set is returned in one page with `nextCursor` always null; there is no second page to fetch.", + "description": "List the knowledge base's tag vocabulary: each tag's display name, the slot it is stored in, and its field type. Filters and document reads use display names; document writes address slots, each in its declared type. The vocabulary is bounded by the fixed slot table. The bounded set is returned in one page with `nextCursor` always null; there is no second page to fetch.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -786,7 +786,7 @@ "patch": { "operationId": "bulkUpdateKnowledgeDocuments", "summary": "Bulk Enable or Disable Documents", - "description": "Enable or disable many documents in one request, either by identifier (up to 100) or, with `selectAll`, every document in the knowledge base optionally narrowed by `enabledFilter`. Disabling keeps a document indexed but excludes it from search. Bulk delete is deliberately not offered: the bulk path records no audit entries, so deletions go through `DELETE /api/v2/knowledge/{id}/documents/{documentId}`, which audits each one. An identifier request echoes the documents it changed in `documentIds`; a `selectAll` request omits that field because the selection is unbounded, and reports `updatedCount` alone. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.", + "description": "Enable or disable many documents in one request, either by identifier or, with `selectAll`, every document in the knowledge base. Bulk delete is deliberately not offered: the bulk path records no audit entries, so deletions go through `DELETE /api/v2/knowledge/{id}/documents/{documentId}`, which audits each one. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -1449,7 +1449,7 @@ "patch": { "operationId": "updateKnowledgeDocument", "summary": "Update Document", - "description": "Rename a document, enable or disable it for search, set any of its 17 tag slots, or requeue it for processing. A tag slot takes its declared type — a string for `tag1`..`tag7`, a number for `number1`..`number5`, a `YYYY-MM-DD` string for `date1`..`date2`, a boolean for `boolean1`..`boolean3` — and a value that is not valid for the slot is a `400` rather than a silently cleared tag. Resolve a display name to its slot with `GET /api/v2/knowledge/{id}/tags`. Absent fields are unchanged. Only caller-owned fields are accepted: derived indexing state (`chunkCount`, `tokenCount`, `characterCount`, `processingStatus`, `processingError`) is written by the processing pipeline and cannot be asserted here. `retryProcessing: true` re-queues a failed or stuck document and must be sent on its own — it runs instead of, not alongside, the field updates — and it answers with a queue acknowledgement rather than the document. Otherwise the updated document is returned; it omits the connector provenance the detail read carries, so re-read with GET when that is needed. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.", + "description": "Rename a document, enable or disable it for search, set any of its 17 tag slots, or requeue it for processing. Absent fields are unchanged, and derived indexing state — `chunkCount`, `tokenCount`, `characterCount`, `processingStatus`, `processingError` — is written by the processing pipeline and cannot be asserted here. Resolve a tag display name to its slot with `GET /api/v2/knowledge/{id}/tags`. The returned document omits the connector provenance the detail read carries. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -1645,7 +1645,7 @@ "description": "Restrict results to direct children of this parent path.", "schema": { "description": "Restrict results to direct children of this parent path.", - "type": "string" + "$ref": "#/components/schemas/FolderPathInput" } }, { @@ -1664,10 +1664,10 @@ "name": "sortBy", "in": "query", "required": false, - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "schema": { "default": "name", - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "type": "string", "enum": ["name", "createdAt", "updatedAt"] } @@ -1891,16 +1891,30 @@ "description": "Path of the folder to delete.", "schema": { "description": "Path of the folder to delete.", - "type": "string" + "$ref": "#/components/schemas/NonRootFolderPathInput" } }, { "name": "recursive", "in": "query", "required": false, - "description": "Delete nested files and folders when true.", + "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. Any listed spelling is accepted in any case, and any other value is rejected.", "schema": { - "description": "Delete nested files and folders when true.", + "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. Any listed spelling is accepted in any case, and any other value is rejected.", + "enum": [ + "true", + "1", + "yes", + "on", + "y", + "enabled", + "false", + "0", + "no", + "off", + "n", + "disabled" + ], "default": "false", "type": "string" } @@ -2213,6 +2227,12 @@ } ] }, + "FolderPathInput": { + "title": "Folder path input", + "description": "Folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. Unicode is supported encoded. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", + "maxLength": 4096, + "type": "string" + }, "V2KnowledgeBase": { "type": "object", "properties": { @@ -2290,7 +2310,9 @@ }, "folderPath": { "type": "string", + "title": "Folder path", "description": "Canonical containing-folder path; `/` is the workspace root.", + "maxLength": 4096, "examples": ["/Product"] } }, @@ -2473,7 +2495,7 @@ }, "folderPath": { "description": "Containing folder path; omission creates the knowledge base at the root.", - "type": "string" + "$ref": "#/components/schemas/FolderPathInput" } }, "required": ["workspaceId", "name"], @@ -2508,7 +2530,7 @@ }, "folderPath": { "description": "New containing-folder path.", - "type": "string" + "$ref": "#/components/schemas/FolderPathInput" } }, "required": ["workspaceId"], @@ -3971,7 +3993,7 @@ "type": "boolean" }, "retryProcessing": { - "description": "Requeue the document for processing. Send it alone: no other field may accompany it.", + "description": "Requeue a failed or stuck document for processing. Send it alone — no other field may accompany it — and it answers with a queue acknowledgement rather than the document.", "type": "boolean", "const": true } @@ -3997,11 +4019,15 @@ }, "path": { "type": "string", - "description": "Canonical folder path used as the public folder identifier." + "title": "Non-root folder path", + "description": "Canonical folder path used as the public folder identifier.", + "maxLength": 4096 }, "parentPath": { "type": "string", - "description": "Canonical parent path; `/` is the root." + "title": "Folder path", + "description": "Canonical parent path; `/` is the root.", + "maxLength": 4096 }, "createdAt": { "type": "string", @@ -4059,6 +4085,12 @@ "title": "Knowledge folder response", "description": "A single knowledge-base folder." }, + "NonRootFolderPathInput": { + "title": "Non-root folder path input", + "description": "Non-root folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. Unicode is supported encoded. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", + "maxLength": 4096, + "type": "string" + }, "CreateKnowledgeFolderRequest": { "type": "object", "properties": { @@ -4069,7 +4101,7 @@ }, "path": { "description": "Path of the folder to create.", - "type": "string" + "$ref": "#/components/schemas/NonRootFolderPathInput" } }, "required": ["workspaceId", "path"], @@ -4087,11 +4119,11 @@ }, "path": { "description": "Current folder path.", - "type": "string" + "$ref": "#/components/schemas/NonRootFolderPathInput" }, "destinationPath": { "description": "New full path for the folder and its descendants.", - "type": "string" + "$ref": "#/components/schemas/NonRootFolderPathInput" } }, "required": ["workspaceId", "path", "destinationPath"], @@ -4104,7 +4136,9 @@ "properties": { "path": { "type": "string", - "description": "Canonical path of the deleted folder." + "title": "Folder path", + "description": "Canonical path of the deleted folder.", + "maxLength": 4096 }, "deleted": { "type": "boolean", diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index 42f7acb51bc..24b43bafa76 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -36,7 +36,7 @@ "get": { "operationId": "listLogs", "summary": "List Logs", - "description": "List workflow execution logs for a workspace with filters, selectable detail, and opaque cursor pagination. This list predates the shared sort convention: it has no `sortBy` (the sort column is fixed to execution start time) and spells the direction `order` rather than `sortOrder`. Runs are hard-deleted once they pass the payer's log retention window, so an older run is absent from this list rather than reported as removed. The window is 30 days from run start on the free plan; Pro and Team have none configured and keep runs indefinitely; Enterprise sets its own per organization, with an optional per-workspace override, and is also unbounded until configured. A workflow's `runCount` is never reduced by this deletion, so a workflow can report runs while this list is empty. Trace spans are stored separately from the log row and are pruned on their own retention schedule: `includeTraceSpans=true` on a run whose stored spans have aged out returns `traceSpans: []` rather than an error, so an empty array does not mean the run recorded no spans.", + "description": "List workflow execution logs for a workspace with filters, selectable detail, and opaque cursor pagination. This list predates the shared sort convention: it has no `sortBy` (the sort column is fixed to execution start time) and spells the direction `order` rather than `sortOrder`. Runs are hard-deleted once they pass the payer's log retention window, so an older run is absent from this list rather than reported as removed. The window is 30 days from run start on the free plan; Pro and Team have none configured and keep runs indefinitely; Enterprise sets its own per organization, with an optional per-workspace override, and is also unbounded until configured. A workflow's `runCount` is never reduced by this deletion, so a workflow can report runs while this list is empty.", "tags": ["Logs"], "parameters": [ { @@ -171,9 +171,9 @@ "name": "includeTraceSpans", "in": "query", "required": false, - "description": "Whether to include block-level trace spans.", + "description": "Whether to include block-level trace spans. Spans are stored apart from the log row and pruned on their own retention schedule, so a run whose spans have aged out returns `traceSpans: []` rather than an error — an empty array does not mean the run recorded none.", "schema": { - "description": "Whether to include block-level trace spans.", + "description": "Whether to include block-level trace spans. Spans are stored apart from the log row and pruned on their own retention schedule, so a run whose spans have aged out returns `traceSpans: []` rather than an error — an empty array does not mean the run recorded none.", "type": "boolean" } }, @@ -195,8 +195,6 @@ "schema": { "description": "Maximum log entries per page. Values outside 1–1000 are truncated and clamped into that range rather than rejected. Defaults to 100.", "type": "integer", - "minimum": 1, - "maximum": 1000, "default": 100 } }, @@ -297,7 +295,7 @@ "get": { "operationId": "getLog", "summary": "Get Log", - "description": "Retrieve the diagnostic representation of a run, including its workflow snapshot, trace spans, final output, and cost. The returned `workflowState` snapshot has credential values redacted: OAuth credential references and secret (`password`) sub-block values are null, while `{{VAR}}` environment-variable references are preserved so consecutive snapshots stay diffable. Trace spans are stored separately from the log row and are pruned on their own retention schedule: a run whose stored spans have aged out returns `traceSpans: []` rather than an error, so an empty array does not mean the run recorded no spans.", + "description": "Retrieve the diagnostic representation of a run, including its workflow snapshot, trace spans, final output, and cost. Trace spans are stored apart from the log row and pruned on their own retention schedule, so an empty `traceSpans` array does not mean the run recorded none.", "tags": ["Logs"], "parameters": [ { @@ -1138,7 +1136,9 @@ "anyOf": [ { "type": "string", - "description": "Canonical slash-prefixed folder path. `/` is the workspace root." + "title": "Folder path", + "description": "Canonical slash-prefixed folder path. `/` is the workspace root. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. Unicode is supported encoded. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", + "maxLength": 4096 }, { "type": "null" diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 06632c2ce70..98ee080e9d5 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -210,7 +210,7 @@ "get": { "operationId": "listMcpServers", "summary": "List MCP Servers", - "description": "List MCP servers registered in a workspace. Request-header values and OAuth client secrets are never returned. Nothing caps how many servers a workspace registers, so this list is paginated: paginate with `limit` and `cursor`, stopping when `nextCursor` is null. `connectionStatus`, `lastConnected`, `toolCount`, `lastError`, and `lastToolsRefresh` describe the most recent tool discovery and stay at their registration defaults — `disconnected`, with `lastConnected` absent — until one runs. Call `GET /api/v2/mcp-servers/{id}/tools` to run it.", + "description": "List MCP servers registered in a workspace. Request-header values and OAuth client secrets are never returned. Paginate with `limit` and `cursor`, stopping when `nextCursor` is null. The discovery fields stay at their registration defaults until `GET /api/v2/mcp-servers/{id}/tools` runs a discovery.", "tags": ["MCP Servers"], "parameters": [ { @@ -240,10 +240,10 @@ "name": "sortBy", "in": "query", "required": false, - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "schema": { "default": "createdAt", - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "type": "string", "enum": ["name", "createdAt", "updatedAt"] } @@ -333,7 +333,7 @@ "post": { "operationId": "createMcpServer", "summary": "Create MCP Server", - "description": "Register an MCP server in a workspace. The endpoint URL determines server identity, must be absolute HTTP or HTTPS, and cannot contain environment-variable references. Header values and OAuth client secrets are write-only. `transport`, `timeout`, `retries`, and `enabled` are applied server-side when omitted; the effective values are in the response. Registration stores the configuration and does not connect to the endpoint, so a 201 is not evidence the server is reachable: the response carries `connectionStatus: \"disconnected\"` and omits `lastConnected`, and the workspace tool registry treats the server as unavailable until a discovery succeeds. Call `GET /api/v2/mcp-servers/{id}/tools` to attempt one and see the outcome. Re-registering an existing URL rewrites the configuration and returns the server to the same unverified state.", + "description": "Register an MCP server in a workspace. The endpoint URL determines server identity, so a URL already registered in the workspace is a `409` — reconfigure that server with `PATCH /api/v2/mcp-servers/{id}` instead. Registration stores the configuration and never connects to the endpoint, so a 201 is not evidence the server is reachable: it comes back `disconnected` and the workspace tool registry treats it as unavailable until `GET /api/v2/mcp-servers/{id}/tools` succeeds.", "tags": ["MCP Servers"], "requestBody": { "required": true, @@ -476,7 +476,7 @@ "patch": { "operationId": "updateMcpServer", "summary": "Update MCP Server", - "description": "Update the supplied MCP server fields. The URL is immutable because it determines server identity; delete and recreate the server to change endpoints. Two fields do not follow the omitted-fields-are-retained rule. `headers` is replaced wholesale rather than merged: sending it drops every stored header it does not repeat, and the only way to keep a header is to resend it. Changing `oauthClientId`, or sending `oauthClientSecret` as null or a new value, revokes the stored OAuth grant and forces reauthorization; switching away from OAuth authentication revokes it too.", + "description": "Update the supplied MCP server fields. Omitted fields are retained, except where a field says otherwise. Any change that invalidates authentication revokes the stored OAuth grant, resets `connectionStatus` to `disconnected`, and clears `lastConnected` and `lastError`, so the server must be rediscovered.", "tags": ["MCP Servers"], "parameters": [ { @@ -629,7 +629,7 @@ "get": { "operationId": "listMcpServerTools", "summary": "List MCP Server Tools", - "description": "Connect to a registered MCP server and return the tools it exposes. Unlike most reads this one has side effects: it opens a live connection to the third-party server and writes `connectionStatus`, `toolCount`, `lastError`, and `lastToolsRefresh` on the server resource, so registering a server and then calling this completes onboarding without opening the Sim UI. Because the pass is not a safe read, a `HEAD` request is answered with an empty `200` without connecting or writing, so it reports only that the endpoint exists and the caller is authorized. Results are served from a short-lived per-workspace cache, so an uncached call reflects whichever workspace member last ran discovery; pass `refresh=true` to reconnect under your own credentials and pick up tools added since the last pass, at the cost of a live round trip to the server. The set is bounded by discovery itself — at most 1,000 tools and 5 MB of tool payload per server. The bounded set is returned in one page with `nextCursor` always null; there is no second page to fetch. An unreachable, slow, or cooling-down server is a `503`; a server whose stored OAuth grant no longer works is a `409` with `error.details.code` `MCP_SERVER_REAUTHORIZATION_REQUIRED`, meaning the registration is intact but a human must reauthorize it in Sim — your API key is fine and re-issuing it changes nothing. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key. Discovery resolves the calling user's own OAuth credentials for the server, which a workspace key cannot supply — so a workspace key that can register a server cannot list its tools.", + "description": "Connect to a registered MCP server and return the tools it exposes, completing onboarding without opening the Sim UI. Unlike most reads this one has side effects: it opens a live connection to the third-party server and writes `connectionStatus`, `toolCount`, `lastError`, and `lastToolsRefresh` on the server resource. A `HEAD` skips the effect but is authorized exactly as the `GET` is, so it answers `400`, `401`, `403`, or `404` wherever the `GET` would and an empty `200` otherwise. Discovery itself bounds the set at 1,000 tools and 5 MB of tool payload per server. The bounded set is returned in one page with `nextCursor` always null; there is no second page to fetch. An unreachable, slow, or cooling-down server is a `503`; a stored OAuth grant that no longer works is a `409` with `error.details.code` `MCP_SERVER_REAUTHORIZATION_REQUIRED`, which only a human reauthorizing in Sim can clear. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.", "tags": ["MCP Servers"], "parameters": [ { @@ -658,9 +658,9 @@ "name": "refresh", "in": "query", "required": false, - "description": "Bypass the cached tool list and reconnect to the server. Slower, and the only way to pick up a tool added since the last refresh.", + "description": "Bypass the short-lived per-workspace tool cache and reconnect under your own credentials. A cached result reflects whichever workspace member last ran discovery, so this is the only way to pick up a tool added since then; it costs a live round trip.", "schema": { - "description": "Bypass the cached tool list and reconnect to the server. Slower, and the only way to pick up a tool added since the last refresh.", + "description": "Bypass the short-lived per-workspace tool cache and reconnect under your own credentials. A cached result reflects whichever workspace member last ran discovery, so this is the only way to pick up a tool added since then; it costs a live round trip.", "type": "boolean" } } @@ -748,10 +748,10 @@ "name": "sortBy", "in": "query", "required": false, - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "schema": { "default": "createdAt", - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "type": "string", "enum": ["name", "createdAt", "updatedAt"] } @@ -1750,10 +1750,10 @@ "name": "sortBy", "in": "query", "required": false, - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "schema": { "default": "name", - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "type": "string", "enum": ["name", "createdAt", "updatedAt"] } @@ -2691,15 +2691,16 @@ "type": "string", "minLength": 1, "maxLength": 2048, - "description": "Absolute HTTP or HTTPS endpoint URL without `{{ENV_VAR}}` references." + "description": "Absolute HTTP or HTTPS endpoint URL without `{{ENV_VAR}}` references. It determines server identity and is immutable: delete and recreate the server to change endpoints." }, "authType": { - "description": "Authentication method. Sim detects it from the server when omitted.", + "description": "Authentication method. Applied server-side as `headers` when omitted; registration never contacts the server, so an omitted value is never detected from it.", + "default": "headers", "type": "string", "enum": ["none", "headers", "oauth"] }, "headers": { - "description": "Write-only request headers sent to the server.", + "description": "Write-only request headers sent to the server. Replaced wholesale rather than merged on update: sending this field drops every stored header it does not repeat.", "writeOnly": true, "type": "object", "propertyNames": { @@ -2731,7 +2732,7 @@ "type": "boolean" }, "oauthClientId": { - "description": "Pre-registered OAuth client identifier.", + "description": "Pre-registered OAuth client identifier. Changing it on update revokes the stored OAuth grant and forces reauthorization.", "anyOf": [ { "type": "string", @@ -2743,7 +2744,7 @@ ] }, "oauthClientSecret": { - "description": "Write-only pre-registered OAuth client secret.", + "description": "Write-only pre-registered OAuth client secret. Sending it on update as null or a new value revokes the stored OAuth grant and forces reauthorization, as does switching away from OAuth authentication.", "writeOnly": true, "anyOf": [ { @@ -2880,12 +2881,13 @@ "maxLength": 2048 }, "authType": { - "description": "Authentication method. Sim detects it from the server when omitted.", + "description": "Authentication method. Applied server-side as `headers` when omitted; registration never contacts the server, so an omitted value is never detected from it.", + "default": "headers", "type": "string", "enum": ["none", "headers", "oauth"] }, "headers": { - "description": "Write-only request headers sent to the server.", + "description": "Write-only request headers sent to the server. Replaced wholesale rather than merged on update: sending this field drops every stored header it does not repeat.", "writeOnly": true, "type": "object", "propertyNames": { @@ -2917,7 +2919,7 @@ "type": "boolean" }, "oauthClientId": { - "description": "Pre-registered OAuth client identifier.", + "description": "Pre-registered OAuth client identifier. Changing it on update revokes the stored OAuth grant and forces reauthorization.", "anyOf": [ { "type": "string", @@ -2929,7 +2931,7 @@ ] }, "oauthClientSecret": { - "description": "Write-only pre-registered OAuth client secret.", + "description": "Write-only pre-registered OAuth client secret. Sending it on update as null or a new value revokes the stored OAuth grant and forces reauthorization, as does switching away from OAuth authentication.", "writeOnly": true, "anyOf": [ { diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 1c73f7e22a4..d21ef26d878 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -57,7 +57,7 @@ "description": "Restrict results to tables in this folder.", "schema": { "description": "Restrict results to tables in this folder.", - "type": "string" + "$ref": "#/components/schemas/FolderPathInput" } }, { @@ -76,10 +76,10 @@ "name": "sortBy", "in": "query", "required": false, - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "schema": { "default": "createdAt", - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "type": "string", "enum": ["name", "createdAt", "updatedAt"] } @@ -104,8 +104,6 @@ "schema": { "description": "Maximum tables to return per page. Values outside 1–1000 are truncated and clamped into that range rather than rejected. Defaults to 100.", "type": "integer", - "minimum": 1, - "maximum": 1000, "default": 100 } }, @@ -395,7 +393,7 @@ "patch": { "operationId": "updateTable", "summary": "Update Table", - "description": "Rename a table, edit its description, or move it to a canonical folder. At least one mutable field is required; lock flags remain read-only.\n\nThis operation is NOT atomic. The name, description, and folder changes are written independently in that order, so a failure part-way through leaves the earlier writes committed — a 4xx does NOT mean nothing changed. When at least one field landed before the failure, the error body carries `details.applied`: the list of fields (`name`, `description`, `folderPath`) that were successfully written. Re-read the table, or retry with only the fields missing from `details.applied`.\n\nA workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", + "description": "Rename a table, edit its description, or move it to a canonical folder. At least one mutable field is required; lock flags remain read-only.\n\nThis operation is NOT atomic. Name, description, and folder are written independently in that order, so a 4xx does NOT mean nothing changed: the error body carries `details.applied` naming the fields that landed. Retry with only the fields missing from it.\n\nA workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", "tags": ["Tables"], "parameters": [ { @@ -3414,7 +3412,7 @@ "description": "Restrict results to direct children of this parent path.", "schema": { "description": "Restrict results to direct children of this parent path.", - "type": "string" + "$ref": "#/components/schemas/FolderPathInput" } }, { @@ -3433,10 +3431,10 @@ "name": "sortBy", "in": "query", "required": false, - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "schema": { "default": "name", - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "type": "string", "enum": ["name", "createdAt", "updatedAt"] } @@ -3660,16 +3658,30 @@ "description": "Path of the folder to delete.", "schema": { "description": "Path of the folder to delete.", - "type": "string" + "$ref": "#/components/schemas/NonRootFolderPathInput" } }, { "name": "recursive", "in": "query", "required": false, - "description": "Delete nested files and folders when true.", + "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. Any listed spelling is accepted in any case, and any other value is rejected.", "schema": { - "description": "Delete nested files and folders when true.", + "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. Any listed spelling is accepted in any case, and any other value is rejected.", + "enum": [ + "true", + "1", + "yes", + "on", + "y", + "enabled", + "false", + "0", + "no", + "off", + "n", + "disabled" + ], "default": "false", "type": "string" } @@ -3985,6 +3997,12 @@ } ] }, + "FolderPathInput": { + "title": "Folder path input", + "description": "Folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. Unicode is supported encoded. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", + "maxLength": 4096, + "type": "string" + }, "V2ApiTable": { "type": "object", "properties": { @@ -4106,7 +4124,9 @@ }, "folderPath": { "type": "string", - "description": "Canonical slash-prefixed folder path. `/` is the workspace root." + "title": "Folder path", + "description": "Canonical slash-prefixed folder path. `/` is the workspace root. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. Unicode is supported encoded. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", + "maxLength": 4096 }, "locks": { "type": "object", @@ -4368,7 +4388,7 @@ }, "folderPath": { "description": "Folder in which to create the table.", - "type": "string" + "$ref": "#/components/schemas/FolderPathInput" } }, "required": ["name", "workspaceId", "schema"], @@ -4462,8 +4482,7 @@ "description": "Replacement table description, or null to clear it." }, "folderPath": { - "description": "Folder path. A missing leading slash is normalized before validation.", - "type": "string" + "$ref": "#/components/schemas/FolderPathInput" } }, "required": ["workspaceId"], @@ -5001,6 +5020,145 @@ "title": "Update table rows response", "description": "Updated row count and identifiers." }, + "TablePredicate": { + "title": "Table predicate", + "description": "Recursive predicate tree. Each group node is exactly one non-empty `all` or `any` array whose members are further groups or `{ field, op, value }` conditions; the root must be a group, not a bare condition. At most 100 members per group, 10 levels of nesting, and 500 nodes in total. The negating operators include nulls: `ne`, `nin`, `ncontains`, `nlike`, and `nilike` match rows whose column is null or absent, so \"not X\" is not the complement of \"X\" over a nullable column. Multi-select `ncontains` is the exception and excludes nulls. Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Membership: `in`, `nin` (array operand). Emptiness: `isEmpty`, `isNotEmpty`, `isNull`, `isNotNull` (no operand). Substring, always case-insensitive, operand matched literally: `contains`, `ncontains`, `startsWith`, `endsWith`. Pattern: `like`/`nlike` (case-sensitive), `ilike`/`nilike` (case-insensitive). **`*` is the only wildcard** and stands for any run of characters; `%`, `_`, and backslash match themselves. Use `like: \"Hi*\"`, not `like: \"Hi%\"`. A `select` column compares by option id and restricts its operators: single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names are accepted as operands and resolved to ids.", + "type": "object", + "oneOf": [ + { + "type": "object", + "description": "Matches a row when every member matches.", + "properties": { + "all": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "description": "Members combined with AND. An empty group is rejected, because it would compile to no filter at all.", + "items": { + "description": "A nested group, or a single condition.", + "anyOf": [ + { + "$ref": "#/components/schemas/TablePredicate" + }, + { + "type": "object", + "title": "Predicate condition", + "description": "One column comparison.", + "properties": { + "field": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Column name to compare, or one of the system fields `id`, `createdAt`, `updatedAt`." + }, + "op": { + "type": "string", + "enum": [ + "eq", + "ne", + "gt", + "gte", + "lt", + "lte", + "in", + "nin", + "contains", + "ncontains", + "startsWith", + "endsWith", + "like", + "ilike", + "nlike", + "nilike", + "isEmpty", + "isNotEmpty", + "isNull", + "isNotNull" + ], + "description": "Comparison operator. The `TablePredicate` schema description carries the grammar for all of them." + }, + "value": { + "description": "Operand. A scalar for the comparison operators, an array of at most 1000 entries for `in`/`nin`, a pattern for the matching operators, and omitted for `isEmpty`/`isNotEmpty`/`isNull`/`isNotNull`." + } + }, + "required": ["field", "op"], + "additionalProperties": false + } + ] + } + } + }, + "required": ["all"], + "additionalProperties": false + }, + { + "type": "object", + "description": "Matches a row when at least one member matches.", + "properties": { + "any": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "description": "Members combined with OR. An empty group is rejected, because it would compile to no filter at all.", + "items": { + "description": "A nested group, or a single condition.", + "anyOf": [ + { + "$ref": "#/components/schemas/TablePredicate" + }, + { + "type": "object", + "title": "Predicate condition", + "description": "One column comparison.", + "properties": { + "field": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Column name to compare, or one of the system fields `id`, `createdAt`, `updatedAt`." + }, + "op": { + "type": "string", + "enum": [ + "eq", + "ne", + "gt", + "gte", + "lt", + "lte", + "in", + "nin", + "contains", + "ncontains", + "startsWith", + "endsWith", + "like", + "ilike", + "nlike", + "nilike", + "isEmpty", + "isNotEmpty", + "isNull", + "isNotNull" + ], + "description": "Comparison operator. The `TablePredicate` schema description carries the grammar for all of them." + }, + "value": { + "description": "Operand. A scalar for the comparison operators, an array of at most 1000 entries for `in`/`nin`, a pattern for the matching operators, and omitted for `isEmpty`/`isNotEmpty`/`isNull`/`isNotNull`." + } + }, + "required": ["field", "op"], + "additionalProperties": false + } + ] + } + } + }, + "required": ["any"], + "additionalProperties": false + } + ] + }, "UpdateTableRowsRequest": { "type": "object", "properties": { @@ -5010,7 +5168,7 @@ "description": "Unique workspace identifier." }, "filter": { - "description": "Recursive predicate tree with exactly one non-empty `all` or `any` group at each group node. Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Membership: `in`, `nin` (array operand). Emptiness: `isEmpty`, `isNotEmpty`, `isNull`, `isNotNull` (no operand). Substring, always case-insensitive, operand matched literally: `contains`, `ncontains`, `startsWith`, `endsWith`. Pattern: `like`/`nlike` (case-sensitive), `ilike`/`nilike` (case-insensitive). **`*` is the only wildcard** and stands for any run of characters; `%`, `_`, and backslash match themselves. Use `like: \"Hi*\"`, not `like: \"Hi%\"`. A `select` column compares by option id and restricts its operators: single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names are accepted as operands and resolved to ids." + "$ref": "#/components/schemas/TablePredicate" }, "data": { "description": "Row-data patch applied to every matching row.", @@ -5081,7 +5239,7 @@ "description": "Unique workspace identifier." }, "filter": { - "description": "Recursive predicate tree with exactly one non-empty `all` or `any` group at each group node. Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Membership: `in`, `nin` (array operand). Emptiness: `isEmpty`, `isNotEmpty`, `isNull`, `isNotNull` (no operand). Substring, always case-insensitive, operand matched literally: `contains`, `ncontains`, `startsWith`, `endsWith`. Pattern: `like`/`nlike` (case-sensitive), `ilike`/`nilike` (case-insensitive). **`*` is the only wildcard** and stands for any run of characters; `%`, `_`, and backslash match themselves. Use `like: \"Hi*\"`, not `like: \"Hi%\"`. A `select` column compares by option id and restricts its operators: single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names are accepted as operands and resolved to ids." + "$ref": "#/components/schemas/TablePredicate" }, "limit": { "description": "Maximum matching rows to delete.", @@ -5281,7 +5439,7 @@ "description": "Unique workspace identifier." }, "predicate": { - "description": "Recursive predicate tree with exactly one non-empty `all` or `any` group at each group node. Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Membership: `in`, `nin` (array operand). Emptiness: `isEmpty`, `isNotEmpty`, `isNull`, `isNotNull` (no operand). Substring, always case-insensitive, operand matched literally: `contains`, `ncontains`, `startsWith`, `endsWith`. Pattern: `like`/`nlike` (case-sensitive), `ilike`/`nilike` (case-insensitive). **`*` is the only wildcard** and stands for any run of characters; `%`, `_`, and backslash match themselves. Use `like: \"Hi*\"`, not `like: \"Hi%\"`. A `select` column compares by option id and restricts its operators: single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names are accepted as operands and resolved to ids." + "$ref": "#/components/schemas/TablePredicate" }, "sort": { "description": "Ordered table-row sort specification.", @@ -5381,7 +5539,7 @@ "description": "Unique workspace identifier." }, "predicate": { - "description": "Recursive predicate tree with exactly one non-empty `all` or `any` group at each group node. Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Membership: `in`, `nin` (array operand). Emptiness: `isEmpty`, `isNotEmpty`, `isNull`, `isNotNull` (no operand). Substring, always case-insensitive, operand matched literally: `contains`, `ncontains`, `startsWith`, `endsWith`. Pattern: `like`/`nlike` (case-sensitive), `ilike`/`nilike` (case-insensitive). **`*` is the only wildcard** and stands for any run of characters; `%`, `_`, and backslash match themselves. Use `like: \"Hi*\"`, not `like: \"Hi%\"`. A `select` column compares by option id and restricts its operators: single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names are accepted as operands and resolved to ids." + "$ref": "#/components/schemas/TablePredicate" } }, "required": ["workspaceId"], @@ -5587,6 +5745,188 @@ "title": "Create table view response", "description": "The created saved view." }, + "TablePredicateInput": { + "title": "Table predicate input", + "description": "A single `{ field, op, value }` condition or a group, normalized to a grouped predicate after validation. Same grammar and limits as `TablePredicate`.", + "oneOf": [ + { + "type": "object", + "description": "Matches a row when every member matches.", + "properties": { + "all": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "description": "Members combined with AND. An empty group is rejected, because it would compile to no filter at all.", + "items": { + "description": "A nested group, or a single condition.", + "anyOf": [ + { + "$ref": "#/components/schemas/TablePredicateInput" + }, + { + "type": "object", + "title": "Predicate condition", + "description": "One column comparison.", + "properties": { + "field": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Column name to compare, or one of the system fields `id`, `createdAt`, `updatedAt`." + }, + "op": { + "type": "string", + "enum": [ + "eq", + "ne", + "gt", + "gte", + "lt", + "lte", + "in", + "nin", + "contains", + "ncontains", + "startsWith", + "endsWith", + "like", + "ilike", + "nlike", + "nilike", + "isEmpty", + "isNotEmpty", + "isNull", + "isNotNull" + ], + "description": "Comparison operator. The `TablePredicate` schema description carries the grammar for all of them." + }, + "value": { + "description": "Operand. A scalar for the comparison operators, an array of at most 1000 entries for `in`/`nin`, a pattern for the matching operators, and omitted for `isEmpty`/`isNotEmpty`/`isNull`/`isNotNull`." + } + }, + "required": ["field", "op"], + "additionalProperties": false + } + ] + } + } + }, + "required": ["all"], + "additionalProperties": false + }, + { + "type": "object", + "description": "Matches a row when at least one member matches.", + "properties": { + "any": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "description": "Members combined with OR. An empty group is rejected, because it would compile to no filter at all.", + "items": { + "description": "A nested group, or a single condition.", + "anyOf": [ + { + "$ref": "#/components/schemas/TablePredicateInput" + }, + { + "type": "object", + "title": "Predicate condition", + "description": "One column comparison.", + "properties": { + "field": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Column name to compare, or one of the system fields `id`, `createdAt`, `updatedAt`." + }, + "op": { + "type": "string", + "enum": [ + "eq", + "ne", + "gt", + "gte", + "lt", + "lte", + "in", + "nin", + "contains", + "ncontains", + "startsWith", + "endsWith", + "like", + "ilike", + "nlike", + "nilike", + "isEmpty", + "isNotEmpty", + "isNull", + "isNotNull" + ], + "description": "Comparison operator. The `TablePredicate` schema description carries the grammar for all of them." + }, + "value": { + "description": "Operand. A scalar for the comparison operators, an array of at most 1000 entries for `in`/`nin`, a pattern for the matching operators, and omitted for `isEmpty`/`isNotEmpty`/`isNull`/`isNotNull`." + } + }, + "required": ["field", "op"], + "additionalProperties": false + } + ] + } + } + }, + "required": ["any"], + "additionalProperties": false + }, + { + "type": "object", + "title": "Predicate condition", + "description": "One column comparison.", + "properties": { + "field": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Column name to compare, or one of the system fields `id`, `createdAt`, `updatedAt`." + }, + "op": { + "type": "string", + "enum": [ + "eq", + "ne", + "gt", + "gte", + "lt", + "lte", + "in", + "nin", + "contains", + "ncontains", + "startsWith", + "endsWith", + "like", + "ilike", + "nlike", + "nilike", + "isEmpty", + "isNotEmpty", + "isNull", + "isNotNull" + ], + "description": "Comparison operator. The `TablePredicate` schema description carries the grammar for all of them." + }, + "value": { + "description": "Operand. A scalar for the comparison operators, an array of at most 1000 entries for `in`/`nin`, a pattern for the matching operators, and omitted for `isEmpty`/`isNotEmpty`/`isNull`/`isNotNull`." + } + }, + "required": ["field", "op"], + "additionalProperties": false + } + ] + }, "CreateTableViewRequest": { "type": "object", "properties": { @@ -5639,7 +5979,7 @@ "description": "Saved row predicate, or null when the view is unfiltered.", "anyOf": [ { - "description": "Recursive predicate condition or group, normalized to a grouped predicate after validation. Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Membership: `in`, `nin` (array operand). Emptiness: `isEmpty`, `isNotEmpty`, `isNull`, `isNotNull` (no operand). Substring, always case-insensitive, operand matched literally: `contains`, `ncontains`, `startsWith`, `endsWith`. Pattern: `like`/`nlike` (case-sensitive), `ilike`/`nilike` (case-insensitive). **`*` is the only wildcard** and stands for any run of characters; `%`, `_`, and backslash match themselves. Use `like: \"Hi*\"`, not `like: \"Hi%\"`. A `select` column compares by option id and restricts its operators: single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names are accepted as operands and resolved to ids." + "$ref": "#/components/schemas/TablePredicateInput" }, { "type": "null" @@ -5752,7 +6092,7 @@ "description": "Saved row predicate, or null when the view is unfiltered.", "anyOf": [ { - "description": "Recursive predicate condition or group, normalized to a grouped predicate after validation. Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Membership: `in`, `nin` (array operand). Emptiness: `isEmpty`, `isNotEmpty`, `isNull`, `isNotNull` (no operand). Substring, always case-insensitive, operand matched literally: `contains`, `ncontains`, `startsWith`, `endsWith`. Pattern: `like`/`nlike` (case-sensitive), `ilike`/`nilike` (case-insensitive). **`*` is the only wildcard** and stands for any run of characters; `%`, `_`, and backslash match themselves. Use `like: \"Hi*\"`, not `like: \"Hi%\"`. A `select` column compares by option id and restricts its operators: single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names are accepted as operands and resolved to ids." + "$ref": "#/components/schemas/TablePredicateInput" }, { "type": "null" @@ -5832,7 +6172,7 @@ "description": "Saved row predicate, or null when the view is unfiltered.", "anyOf": [ { - "description": "Recursive predicate condition or group, normalized to a grouped predicate after validation. Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Membership: `in`, `nin` (array operand). Emptiness: `isEmpty`, `isNotEmpty`, `isNull`, `isNotNull` (no operand). Substring, always case-insensitive, operand matched literally: `contains`, `ncontains`, `startsWith`, `endsWith`. Pattern: `like`/`nlike` (case-sensitive), `ilike`/`nilike` (case-insensitive). **`*` is the only wildcard** and stands for any run of characters; `%`, `_`, and backslash match themselves. Use `like: \"Hi*\"`, not `like: \"Hi%\"`. A `select` column compares by option id and restricts its operators: single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names are accepted as operands and resolved to ids." + "$ref": "#/components/schemas/TablePredicateInput" }, { "type": "null" @@ -6686,7 +7026,7 @@ } }, "filter": { - "description": "Recursive predicate tree with exactly one non-empty `all` or `any` group at each group node. Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Membership: `in`, `nin` (array operand). Emptiness: `isEmpty`, `isNotEmpty`, `isNull`, `isNotNull` (no operand). Substring, always case-insensitive, operand matched literally: `contains`, `ncontains`, `startsWith`, `endsWith`. Pattern: `like`/`nlike` (case-sensitive), `ilike`/`nilike` (case-insensitive). **`*` is the only wildcard** and stands for any run of characters; `%`, `_`, and backslash match themselves. Use `like: \"Hi*\"`, not `like: \"Hi%\"`. A `select` column compares by option id and restricts its operators: single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names are accepted as operands and resolved to ids." + "$ref": "#/components/schemas/TablePredicate" }, "excludeRowIds": { "description": "Rows excluded from a select-all run scope.", @@ -6829,7 +7169,7 @@ "description": "Case-insensitive cell substring to find." }, "predicate": { - "description": "Recursive predicate tree with exactly one non-empty `all` or `any` group at each group node. Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Membership: `in`, `nin` (array operand). Emptiness: `isEmpty`, `isNotEmpty`, `isNull`, `isNotNull` (no operand). Substring, always case-insensitive, operand matched literally: `contains`, `ncontains`, `startsWith`, `endsWith`. Pattern: `like`/`nlike` (case-sensitive), `ilike`/`nilike` (case-insensitive). **`*` is the only wildcard** and stands for any run of characters; `%`, `_`, and backslash match themselves. Use `like: \"Hi*\"`, not `like: \"Hi%\"`. A `select` column compares by option id and restricts its operators: single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names are accepted as operands and resolved to ids." + "$ref": "#/components/schemas/TablePredicate" }, "sort": { "description": "Ordered table-row sort specification.", @@ -6945,8 +7285,7 @@ "description": "Name of the table to create." }, "folderPath": { - "description": "Folder path. A missing leading slash is normalized before validation.", - "type": "string" + "$ref": "#/components/schemas/FolderPathInput" } }, "required": ["type", "name"], @@ -7166,8 +7505,7 @@ "description": "Name of the table to create." }, "folderPath": { - "description": "Folder path. A missing leading slash is normalized before validation.", - "type": "string" + "$ref": "#/components/schemas/FolderPathInput" } }, "required": ["type", "name"], @@ -7371,8 +7709,7 @@ "description": "Name of the table to create." }, "folderPath": { - "description": "Folder path. A missing leading slash is normalized before validation.", - "type": "string" + "$ref": "#/components/schemas/FolderPathInput" } }, "required": ["type", "name"], @@ -7489,8 +7826,7 @@ "description": "Name of the table to create." }, "folderPath": { - "description": "Folder path. A missing leading slash is normalized before validation.", - "type": "string" + "$ref": "#/components/schemas/FolderPathInput" } }, "required": ["type", "name"], @@ -7953,7 +8289,7 @@ "minLength": 1 }, "filter": { - "description": "Recursive predicate tree with exactly one non-empty `all` or `any` group at each group node. Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Membership: `in`, `nin` (array operand). Emptiness: `isEmpty`, `isNotEmpty`, `isNull`, `isNotNull` (no operand). Substring, always case-insensitive, operand matched literally: `contains`, `ncontains`, `startsWith`, `endsWith`. Pattern: `like`/`nlike` (case-sensitive), `ilike`/`nilike` (case-insensitive). **`*` is the only wildcard** and stands for any run of characters; `%`, `_`, and backslash match themselves. Use `like: \"Hi*\"`, not `like: \"Hi%\"`. A `select` column compares by option id and restricts its operators: single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names are accepted as operands and resolved to ids." + "$ref": "#/components/schemas/TablePredicate" }, "excludeRowIds": { "description": "Rows excluded from an all-scope cancellation.", @@ -7986,11 +8322,15 @@ }, "path": { "type": "string", - "description": "Canonical folder path used as the public folder identifier." + "title": "Non-root folder path", + "description": "Canonical folder path used as the public folder identifier.", + "maxLength": 4096 }, "parentPath": { "type": "string", - "description": "Canonical parent path; `/` is the root." + "title": "Folder path", + "description": "Canonical parent path; `/` is the root.", + "maxLength": 4096 }, "createdAt": { "type": "string", @@ -8048,6 +8388,12 @@ "title": "Create table folder response", "description": "The created table folder." }, + "NonRootFolderPathInput": { + "title": "Non-root folder path input", + "description": "Non-root folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. Unicode is supported encoded. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", + "maxLength": 4096, + "type": "string" + }, "CreateTableFolderRequest": { "type": "object", "properties": { @@ -8058,7 +8404,7 @@ }, "path": { "description": "Path of the folder to create.", - "type": "string" + "$ref": "#/components/schemas/NonRootFolderPathInput" } }, "required": ["workspaceId", "path"], @@ -8089,11 +8435,11 @@ }, "path": { "description": "Current folder path.", - "type": "string" + "$ref": "#/components/schemas/NonRootFolderPathInput" }, "destinationPath": { "description": "New full path for the folder and its descendants.", - "type": "string" + "$ref": "#/components/schemas/NonRootFolderPathInput" } }, "required": ["workspaceId", "path", "destinationPath"], @@ -8106,7 +8452,9 @@ "properties": { "path": { "type": "string", - "description": "Canonical path of the deleted folder." + "title": "Folder path", + "description": "Canonical path of the deleted folder.", + "maxLength": 4096 }, "deleted": { "type": "boolean", diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index a6e0fa1f5ae..289661473a2 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -61,7 +61,7 @@ "description": "Restrict results to workflows in this folder path.", "schema": { "description": "Restrict results to workflows in this folder path.", - "type": "string" + "$ref": "#/components/schemas/FolderPathInput" } }, { @@ -114,10 +114,10 @@ "name": "sortBy", "in": "query", "required": false, - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "schema": { "default": "position", - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "type": "string", "enum": ["position", "name", "createdAt", "updatedAt", "runCount"] } @@ -712,7 +712,7 @@ "post": { "operationId": "deployWorkflow", "summary": "Deploy Workflow", - "description": "Create and asynchronously activate a deployment version. This request is not idempotent: it accepts no idempotency key and every call mints a new deployment version, so retrying after a timeout creates a second version rather than returning the first. The response carries `latestDeploymentAttempt` for the accepted attempt, but `GET /workflows/{id}` does not expose that field — poll activation with `isDeployed` and `deployedAt` on the workflow, or with `isActive` on `GET /workflows/{id}/versions`. Returns 409 when the deployment would conflict with an existing webhook path. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.", + "description": "Create and asynchronously activate a deployment version. This request is not idempotent: every call mints a new deployment version, so retrying after a timeout creates a second version rather than returning the first. A deployment that would conflict with an existing webhook path is a 409. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.", "tags": ["Workflows"], "parameters": [ { @@ -951,7 +951,7 @@ "get": { "operationId": "exportWorkflow", "summary": "Export Workflow", - "description": "Export a portable, secret-sanitized workflow. Workspace-scoped bindings must be selected again after import. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", + "description": "Export a portable, secret-sanitized workflow. Workspace-scoped bindings must be selected again after import. Exporting records an audit event, so it is not a safe read. A `HEAD` skips the effect but is authorized exactly as the `GET` is, so it answers `400`, `401`, `403`, or `404` wherever the `GET` would and an empty `200` otherwise. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", "tags": ["Workflows"], "parameters": [ { @@ -1092,7 +1092,7 @@ "post": { "operationId": "executeWorkflowV2", "summary": "Execute Workflow", - "description": "Execute a deployed workflow synchronously, asynchronously, or as Server-Sent Events. Public workflows permit anonymous synchronous and streaming execution; asynchronous execution requires an API key. A synchronous run that exceeds its execution timeout returns HTTP 200 with `status: \"failed\"` and `error.code: \"TIMEOUT\"` rather than an HTTP error, so branch on `status`. The optional `X-Run-Id` header is a one-shot uniqueness claim, not an idempotency key: reusing a value returns 409 with `error.details.code: \"RUN_ID_CONFLICT\"` and never replays the earlier run. Option constraints — each is a 400: (1) `async: true` requires an API key; anonymous public-workflow callers may only execute synchronously or as a stream. (2) `async` and `stream` cannot both be true. (3) `executionTimeoutSeconds` is accepted only when `async: true`. (4) `async: true` rejects every streaming and output-shaping option — `selectedOutputs`, `includeThinking`, `includeToolCalls`, `includeFileBase64`, and `base64MaxBytes`. (5) `includeThinking` and `includeToolCalls` require `stream: true`. (6) `includeThinking` and `includeToolCalls` require the `X-Sim-Stream-Protocol: agent-events-v1` request header, which declares that the client understands agent-event frames.", + "description": "Execute a deployed workflow synchronously, asynchronously, or as Server-Sent Events. Public workflows permit anonymous synchronous and streaming execution; asynchronous execution requires an API key. A synchronous run that exceeds its execution timeout returns HTTP 200 with `status: \"failed\"` and `error.code: \"TIMEOUT\"` rather than an HTTP error, so branch on `status`. The optional `X-Run-Id` header is a one-shot uniqueness claim, not an idempotency key: reusing a value returns 409 with `error.details.code: \"RUN_ID_CONFLICT\"` and never replays the earlier run. Each option carries the modes it requires and the modes that reject it; a violated combination is a 400.", "tags": ["Workflows"], "security": [ { @@ -1140,7 +1140,7 @@ ], "requestBody": { "required": true, - "description": "Input and execution-mode options for a deployed workflow. Option constraints — each is a 400: (1) `async: true` requires an API key; anonymous public-workflow callers may only execute synchronously or as a stream. (2) `async` and `stream` cannot both be true. (3) `executionTimeoutSeconds` is accepted only when `async: true`. (4) `async: true` rejects every streaming and output-shaping option — `selectedOutputs`, `includeThinking`, `includeToolCalls`, `includeFileBase64`, and `base64MaxBytes`. (5) `includeThinking` and `includeToolCalls` require `stream: true`. (6) `includeThinking` and `includeToolCalls` require the `X-Sim-Stream-Protocol: agent-events-v1` request header, which declares that the client understands agent-event frames.", + "description": "Input and execution-mode options for a deployed workflow. Each option carries the modes it requires and the modes that reject it; a violated combination is a 400.", "content": { "application/json": { "schema": { @@ -1619,7 +1619,7 @@ "post": { "operationId": "cancelRunV2", "summary": "Cancel Workflow Run", - "description": "Request cancellation of a running, queued, or paused workflow run. Cancelling a run that has already reached a terminal state succeeds with no effect rather than returning an error. The `reason` field is present on every response, including full successes — `recorded` is the success value; it is not a partial-failure marker. A run produced by a table workflow group is a 409 when its cell can no longer accept the cancellation, because the run and its cell must reach the cancelled state together.", + "description": "Request cancellation of a running, queued, or paused workflow run. Cancelling a run that has already reached a terminal state succeeds with no effect rather than returning an error. A run produced by a table workflow group is a 409 when its cell can no longer accept the cancellation, because the run and its cell must reach the cancelled state together.", "tags": ["Workflow Runs"], "parameters": [ { @@ -1722,7 +1722,7 @@ "description": "Restrict results to direct children of this parent path.", "schema": { "description": "Restrict results to direct children of this parent path.", - "type": "string" + "$ref": "#/components/schemas/FolderPathInput" } }, { @@ -1741,10 +1741,10 @@ "name": "sortBy", "in": "query", "required": false, - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "schema": { "default": "name", - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "type": "string", "enum": ["name", "createdAt", "updatedAt"] } @@ -1974,16 +1974,30 @@ "description": "Path of the folder to delete.", "schema": { "description": "Path of the folder to delete.", - "type": "string" + "$ref": "#/components/schemas/NonRootFolderPathInput" } }, { "name": "recursive", "in": "query", "required": false, - "description": "Delete nested files and folders when true.", + "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. Any listed spelling is accepted in any case, and any other value is rejected.", "schema": { - "description": "Delete nested files and folders when true.", + "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. Any listed spelling is accepted in any case, and any other value is rejected.", + "enum": [ + "true", + "1", + "yes", + "on", + "y", + "enabled", + "false", + "0", + "no", + "off", + "n", + "disabled" + ], "default": "false", "type": "string" } @@ -2299,6 +2313,12 @@ } ] }, + "FolderPathInput": { + "title": "Folder path input", + "description": "Folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. Unicode is supported encoded. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", + "maxLength": 4096, + "type": "string" + }, "WorkflowListItem": { "type": "object", "properties": { @@ -2325,7 +2345,9 @@ }, "folderPath": { "type": "string", + "title": "Folder path", "description": "Canonical containing-folder path; `/` is the workspace root.", + "maxLength": 4096, "examples": ["/Operations"] }, "workspaceId": { @@ -2498,8 +2520,7 @@ ] }, "folderPath": { - "description": "Folder path. A missing leading slash is normalized before validation.", - "type": "string" + "$ref": "#/components/schemas/FolderPathInput" } }, "required": ["workspaceId", "name"], @@ -2554,7 +2575,9 @@ }, "folderPath": { "type": "string", + "title": "Folder path", "description": "Canonical containing-folder path; `/` is the workspace root.", + "maxLength": 4096, "examples": ["/Operations"] }, "workspaceId": { @@ -2727,7 +2750,7 @@ }, "folderPath": { "description": "Destination folder path; `/` moves the workflow to the workspace root.", - "type": "string" + "$ref": "#/components/schemas/FolderPathInput" } }, "additionalProperties": false, @@ -3665,7 +3688,9 @@ }, "folderPath": { "type": "string", - "description": "Canonical containing-folder path; `/` is the workspace root." + "title": "Folder path", + "description": "Canonical containing-folder path; `/` is the workspace root.", + "maxLength": 4096 } }, "required": ["id", "name", "description", "workspaceId", "folderPath"], @@ -3743,7 +3768,9 @@ }, "folderPath": { "type": "string", - "description": "Canonical containing-folder path." + "title": "Folder path", + "description": "Canonical containing-folder path.", + "maxLength": 4096 }, "createdAt": { "type": "string", @@ -3820,7 +3847,7 @@ }, "folderPath": { "description": "Destination folder path; omit for the workspace root.", - "type": "string" + "$ref": "#/components/schemas/FolderPathInput" }, "name": { "description": "Override for the imported workflow name.", @@ -4054,11 +4081,11 @@ "type": "boolean" }, "includeFileBase64": { - "description": "Inline eligible output files as base64 content.", + "description": "Inline eligible output files as base64 content. Rejected when `async` is true.", "type": "boolean" }, "base64MaxBytes": { - "description": "Maximum total bytes of file content to inline as base64.", + "description": "Maximum total bytes of file content to inline as base64. Rejected when `async` is true.", "type": "integer", "exclusiveMinimum": 0, "maximum": 10485760 @@ -4066,7 +4093,7 @@ }, "additionalProperties": false, "title": "Execute workflow request", - "description": "Input and execution-mode options for a deployed workflow. Option constraints — each is a 400: (1) `async: true` requires an API key; anonymous public-workflow callers may only execute synchronously or as a stream. (2) `async` and `stream` cannot both be true. (3) `executionTimeoutSeconds` is accepted only when `async: true`. (4) `async: true` rejects every streaming and output-shaping option — `selectedOutputs`, `includeThinking`, `includeToolCalls`, `includeFileBase64`, and `base64MaxBytes`. (5) `includeThinking` and `includeToolCalls` require `stream: true`. (6) `includeThinking` and `includeToolCalls` require the `X-Sim-Stream-Protocol: agent-events-v1` request header, which declares that the client understands agent-event frames.", + "description": "Input and execution-mode options for a deployed workflow. Each option carries the modes it requires and the modes that reject it; a violated combination is a 400.", "examples": [ { "input": { @@ -4703,11 +4730,15 @@ }, "path": { "type": "string", - "description": "Canonical folder path used as the public folder identifier." + "title": "Non-root folder path", + "description": "Canonical folder path used as the public folder identifier.", + "maxLength": 4096 }, "parentPath": { "type": "string", - "description": "Canonical parent path; `/` is the root." + "title": "Folder path", + "description": "Canonical parent path; `/` is the root.", + "maxLength": 4096 }, "createdAt": { "type": "string", @@ -4796,6 +4827,12 @@ } ] }, + "NonRootFolderPathInput": { + "title": "Non-root folder path input", + "description": "Non-root folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. Unicode is supported encoded. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", + "maxLength": 4096, + "type": "string" + }, "CreateWorkflowFolderRequest": { "type": "object", "properties": { @@ -4806,7 +4843,7 @@ }, "path": { "description": "Path of the folder to create.", - "type": "string" + "$ref": "#/components/schemas/NonRootFolderPathInput" } }, "required": ["workspaceId", "path"], @@ -4849,11 +4886,11 @@ }, "path": { "description": "Current folder path.", - "type": "string" + "$ref": "#/components/schemas/NonRootFolderPathInput" }, "destinationPath": { "description": "New full path for the folder and its descendants.", - "type": "string" + "$ref": "#/components/schemas/NonRootFolderPathInput" } }, "required": ["workspaceId", "path", "destinationPath"], @@ -4866,7 +4903,9 @@ "properties": { "path": { "type": "string", - "description": "Path of the deleted workflow folder." + "title": "Folder path", + "description": "Path of the deleted workflow folder.", + "maxLength": 4096 }, "deleted": { "type": "boolean", diff --git a/apps/sim/lib/api/contracts/tables-predicate.test.ts b/apps/sim/lib/api/contracts/tables-predicate.test.ts index b5daf33c638..23e2d2e7019 100644 --- a/apps/sim/lib/api/contracts/tables-predicate.test.ts +++ b/apps/sim/lib/api/contracts/tables-predicate.test.ts @@ -6,6 +6,7 @@ * (unknown field, json-op) runs server-side in `validate.ts`. */ import { describe, expect, it } from 'vitest' +import { z } from 'zod' import { deleteTableRowsBodySchema, predicateInputSchema, @@ -15,8 +16,13 @@ import { tableViewConfigSchema, updateRowsByFilterBodySchema, } from '@/lib/api/contracts/tables' +import { FILTER_OPS } from '@/lib/table/constants' +import { MAX_PREDICATE_GROUP_SIZE } from '@/lib/table/query-builder/predicate' import { validatePredicate } from '@/lib/table/query-builder/validate' +/** Loose view of the generated JSON Schema, which is untyped by construction. */ +type JsonSchemaNode = Record & Record + describe('rowQueryBodySchema', () => { it('accepts a root condition and normalizes it to the canonical all group', () => { const parsed = rowQueryBodySchema.parse({ @@ -291,3 +297,43 @@ function rowQueryStringSchemaProbe(input: Record) { if (!result.success) throw new Error(JSON.stringify(result.error.issues[0])) return result.data } + +/** + * The predicate is a `pipe` over `z.unknown()`, so `z.toJSONSchema` documents + * it from an input side that carries no shape: the leaf keys `field`, `op`, and + * `value` were named nowhere in the published contract and were discoverable + * only by reading an example. The shape is now supplied through `.meta()`, + * which means it is hand-written beside a runtime schema that can move without + * it. These assertions are the join. + */ +describe('the published predicate schema', () => { + const published = z.toJSONSchema(predicateSchema, { io: 'input', unrepresentable: 'any' }) + const leaf = (published.oneOf as JsonSchemaNode[])[0].properties.all.items.anyOf[1] + + it('names the leaf keys the server actually requires', () => { + expect(Object.keys(leaf.properties)).toEqual(['field', 'op', 'value']) + expect(leaf.required).toEqual(['field', 'op']) + expect(leaf.additionalProperties).toBe(false) + }) + + it('publishes exactly the operators the server accepts', () => { + expect(leaf.properties.op.enum).toEqual([...FILTER_OPS]) + }) + + it('publishes the group keys and their size bound', () => { + const [all, any] = published.oneOf as JsonSchemaNode[] + expect(Object.keys(all.properties)).toEqual(['all']) + expect(Object.keys(any.properties)).toEqual(['any']) + expect(all.properties.all.minItems).toBe(1) + expect(all.properties.all.maxItems).toBe(MAX_PREDICATE_GROUP_SIZE) + }) + + it('rejects the v1-shaped leaf a caller would guess without the schema', () => { + expect( + predicateSchema.safeParse({ all: [{ column: 'a', operator: 'eq', value: 1 }] }).success + ).toBe(false) + expect(predicateSchema.safeParse({ all: [{ field: 'a', op: 'eq', value: 1 }] }).success).toBe( + true + ) + }) +}) diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index 4289e226cc9..f00cbadf3c6 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -38,7 +38,9 @@ import { import { CSV_SYNC_MAX_FILE_SIZE_BYTES, CSV_SYNC_MAX_FILE_SIZE_MESSAGE } from '@/lib/table/import' import { getTablePredicateTreeSizeError, + MAX_PREDICATE_DEPTH, MAX_PREDICATE_GROUP_SIZE, + MAX_PREDICATE_NODES, normalizeTablePredicate, } from '@/lib/table/query-builder/predicate' @@ -554,16 +556,108 @@ const predicateBoundarySchema = z.unknown().superRefine((value, ctx) => { if (problem) ctx.addIssue({ code: 'custom', message: problem }) }) +/** + * The published JSON Schema for a predicate tree. + * + * `predicateSchema` is a `pipe` whose input side is `z.unknown()` — the size + * guard has to run before the recursive union so pathological input is a `400` + * rather than a stack overflow — and `z.toJSONSchema` documents a pipe from its + * input. That published the most consequential shape in the API as a bare + * description: the leaf keys `field`/`op`/`value` were named nowhere in the + * contract and were discoverable only by reading an example, so a caller + * guessing `{column, operator, value}` got a `400` with nothing to correct + * against. This object is merged in through `.meta()` so the shape is published + * without moving the guard. + * + * Every bound below is read from the runtime constant that enforces it, and + * `tables-predicate.test.ts` pins the published operator set against + * `FILTER_OPS`, so the two cannot drift. + */ +const PREDICATE_LEAF_JSON_SCHEMA = { + type: 'object', + title: 'Predicate condition', + description: 'One column comparison.', + properties: { + field: { + type: 'string', + minLength: 1, + maxLength: 128, + description: + 'Column name to compare, or one of the system fields `id`, `createdAt`, `updatedAt`.', + }, + op: { + type: 'string', + enum: [...FILTER_OPS], + description: + 'Comparison operator. The `TablePredicate` schema description carries the grammar for all of them.', + }, + value: { + description: + 'Operand. A scalar for the comparison operators, an array of at most 1000 entries for `in`/`nin`, a pattern for the matching operators, and omitted for `isEmpty`/`isNotEmpty`/`isNull`/`isNotNull`.', + }, + }, + required: ['field', 'op'], + additionalProperties: false, +} as const + +/** + * A group's members are the schema itself, so each component carries a `$ref` + * to its own id. The self-reference is what makes the recursion resolvable from + * inside a single `$defs` entry — a reference to a sibling component would be + * dangling wherever only one of the two is reachable, which is exactly the + * shape the table-view body has. + */ +const predicateGroupJsonSchema = (key: 'all' | 'any', conjunction: string, selfRef: string) => + ({ + type: 'object', + description: `Matches a row when ${conjunction} member matches.`, + properties: { + [key]: { + type: 'array', + minItems: 1, + maxItems: MAX_PREDICATE_GROUP_SIZE, + description: `Members combined with ${key === 'all' ? 'AND' : 'OR'}. An empty group is rejected, because it would compile to no filter at all.`, + items: { + description: 'A nested group, or a single condition.', + anyOf: [{ $ref: selfRef }, PREDICATE_LEAF_JSON_SCHEMA], + }, + }, + }, + required: [key], + additionalProperties: false, + }) as const + +const predicateGroupsJsonSchema = (selfRef: string) => + [ + predicateGroupJsonSchema('all', 'every', selfRef), + predicateGroupJsonSchema('any', 'at least one', selfRef), + ] as const + +/** + * Stated once here rather than on each operation that accepts a predicate. + * The NULL clause is the surprising half: `ncontains`, `nlike`, and `nilike` + * emit an explicit `IS NULL OR NOT …` arm, and `ne`/`nin` negate a JSONB + * containment test that is false for an absent key, so all of them return rows + * whose column is null. + */ +const PREDICATE_TREE_DESCRIPTION = [ + `Recursive predicate tree. Each group node is exactly one non-empty \`all\` or \`any\` array whose members are further groups or \`{ field, op, value }\` conditions; the root must be a group, not a bare condition. At most ${MAX_PREDICATE_GROUP_SIZE} members per group, ${MAX_PREDICATE_DEPTH} levels of nesting, and ${MAX_PREDICATE_NODES} nodes in total.`, + 'The negating operators include nulls: `ne`, `nin`, `ncontains`, `nlike`, and `nilike` match rows whose column is null or absent, so "not X" is not the complement of "X" over a nullable column. Multi-select `ncontains` is the exception and excludes nulls.', + PREDICATE_OPERATOR_GRAMMAR, +].join(' ') + /** * The canonical grouped predicate schema for dual-grammar boundaries. Keeping * its root group-only prevents a legacy filter with columns named `field`, * `op`, and `value` from being reinterpreted as a v2 predicate. */ -const documentedPredicateSchema = predicateBoundarySchema - .pipe(predicateTreeSchema) - .describe( - `Recursive predicate tree with exactly one non-empty \`all\` or \`any\` group at each group node. ${PREDICATE_OPERATOR_GRAMMAR}` - ) +const documentedPredicateSchema = predicateBoundarySchema.pipe(predicateTreeSchema).meta({ + id: 'TablePredicate', + title: 'Table predicate', + description: PREDICATE_TREE_DESCRIPTION, + type: 'object', + oneOf: [...predicateGroupsJsonSchema('#/$defs/TablePredicate')], +}) // double-cast-allowed: the pipe's inferred input is `unknown`, and letting TS widen the recursive lazy union through it makes typecheck OOM export const predicateSchema = documentedPredicateSchema as unknown as z.ZodType @@ -577,9 +671,16 @@ export const predicateSchema = documentedPredicateSchema as unknown as z.ZodType export const predicateInputSchema = predicateBoundarySchema .pipe(predicateNodeSchema) .transform(normalizeTablePredicate) - .describe( - `Recursive predicate condition or group, normalized to a grouped predicate after validation. ${PREDICATE_OPERATOR_GRAMMAR}` - ) as z.ZodType + .meta({ + id: 'TablePredicateInput', + title: 'Table predicate input', + description: + 'A single `{ field, op, value }` condition or a group, normalized to a grouped predicate after validation. Same grammar and limits as `TablePredicate`.', + oneOf: [ + ...predicateGroupsJsonSchema('#/$defs/TablePredicateInput'), + PREDICATE_LEAF_JSON_SCHEMA, + ], + }) as z.ZodType /** * v2 sort wire format: an ordered list of `{ field, direction }`. diff --git a/apps/sim/lib/api/contracts/v2/__tests__/pagination-limit.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/pagination-limit.test.ts index 497c4a71c55..a8bc3d77253 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/pagination-limit.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/pagination-limit.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' +import { z } from 'zod' import { v2ListBillingLogsContract } from '@/lib/api/contracts/v2/billing' import { v2ListCredentialsContract } from '@/lib/api/contracts/v2/credentials' import { v2ListCustomToolsContract } from '@/lib/api/contracts/v2/custom-tools' @@ -108,5 +109,26 @@ describe('v2 limit validation', () => { expect(clamped.parse('-5')).toBe(1) expect(clamped.parse('99999')).toBe(1000) }) + + /** + * The published schema must not carry `minimum`/`maximum`. In JSON Schema + * they mean "rejected outside", so publishing them beside a description + * that promises clamping made a generated SDK refuse locally a `limit` this + * branch would have accepted and corrected. The rejecting branch keeps its + * bounds, because there they are true. + */ + it('publishes no numeric bounds, because it clamps rather than rejects', () => { + const published = z.toJSONSchema(clamped, { io: 'input', unrepresentable: 'any' }) + expect(published).not.toHaveProperty('minimum') + expect(published).not.toHaveProperty('maximum') + expect(published.description).toContain('clamped') + }) + + it('keeps the bounds on the rejecting branch, where they are enforced', () => { + const rejecting = v2LimitSchema({ max: 1000, fallback: 100 }) + const published = z.toJSONSchema(rejecting, { io: 'input', unrepresentable: 'any' }) + expect(published).toMatchObject({ minimum: 1, maximum: 1000 }) + expect(rejecting.safeParse('99999').success).toBe(false) + }) }) }) diff --git a/apps/sim/lib/api/contracts/v2/__tests__/shared.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/shared.test.ts index e440b051abd..4abed1858c7 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/shared.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/shared.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest' +import { z } from 'zod' import { traceSpansSchema } from '@/lib/api/contracts/logs' import { v2ListLogsQuerySchema } from '@/lib/api/contracts/v2/logs' import { + V2_FALSE_VALUES, + V2_TRUE_VALUES, v2DeleteFolderQuerySchema, v2FolderPathInputSchema, v2FolderPathSchema, @@ -9,6 +12,7 @@ import { v2NonRootFolderPathSchema, v2RelocateFolderBodySchema, } from '@/lib/api/contracts/v2/shared' +import { MAX_FOLDER_PATH_BYTES, MAX_FOLDER_PATH_SEGMENTS } from '@/lib/folders/paths' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' @@ -45,6 +49,67 @@ describe('v2 folder path contracts', () => { ).toBe(false) }) + /** + * The published `enum` is a restatement of `z.stringbool()`'s internal + * vocabulary, which contributes nothing to JSON Schema. Parsing every listed + * spelling here is what keeps the restatement honest through a Zod upgrade — + * and this is a destructive switch, so a spelling the spec advertises but the + * server rejects is worse than an undocumented one. + */ + it('accepts every spelling of `recursive` it publishes, in any case', () => { + const published = z.toJSONSchema(v2DeleteFolderQuerySchema, { + io: 'input', + unrepresentable: 'any', + }) + const declared = (published.properties as Record).recursive.enum + + expect(declared).toEqual([...V2_TRUE_VALUES, ...V2_FALSE_VALUES]) + for (const value of V2_TRUE_VALUES) { + expect( + v2DeleteFolderQuerySchema.parse({ workspaceId: WORKSPACE_ID, path: '/R', recursive: value }) + .recursive + ).toBe(true) + } + for (const value of V2_FALSE_VALUES) { + expect( + v2DeleteFolderQuerySchema.parse({ workspaceId: WORKSPACE_ID, path: '/R', recursive: value }) + .recursive + ).toBe(false) + } + expect( + v2DeleteFolderQuerySchema.parse({ workspaceId: WORKSPACE_ID, path: '/R', recursive: 'YES' }) + .recursive + ).toBe(true) + expect( + v2DeleteFolderQuerySchema.safeParse({ + workspaceId: WORKSPACE_ID, + path: '/R', + recursive: 'maybe', + }).success + ).toBe(false) + }) + + /** + * The canonical-path rule is enforced in a `superRefine`, which publishes + * nothing, so it lived only in the implementation until it was written onto + * these two components. Pinning the published text against the constants that + * enforce the caps keeps the prose from outliving a bound change. + */ + it('publishes the canonical-path rule and the byte cap on every path schema', () => { + for (const schema of [ + v2FolderPathSchema, + v2NonRootFolderPathSchema, + v2FolderPathInputSchema, + v2NonRootFolderPathInputSchema, + ]) { + const published = z.toJSONSchema(schema, { io: 'input', unrepresentable: 'any' }) + expect(published.maxLength).toBe(MAX_FOLDER_PATH_BYTES) + expect(published.description).toContain('percent-encoded') + expect(published.description).toContain(String(MAX_FOLDER_PATH_SEGMENTS)) + expect(published.description).toContain(String(MAX_FOLDER_PATH_BYTES)) + } + }) + it('defaults folder deletion to non-recursive', () => { expect(v2DeleteFolderQuerySchema.parse({ workspaceId: WORKSPACE_ID, path: 'Reports' })).toEqual( { workspaceId: WORKSPACE_ID, path: '/Reports', recursive: false } diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts index 9d27d322321..d72eb04334d 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -476,19 +476,31 @@ export type V2NullableFileShare = z.output export const v2UpsertFileShareBodySchema = z .object({ workspaceId: workspaceIdSchema.describe('Workspace that owns the file.'), - isActive: z.boolean().describe('Whether the share should resolve.'), - authType: shareAuthTypeSchema.optional().describe('How access to the share is gated.'), + isActive: z + .boolean() + .describe( + 'Whether the share should resolve. Disabling preserves the token and the whole access configuration, so re-enabling restores the share as it was; enabling rewrites the credentials the resulting mode does not use.' + ), + authType: shareAuthTypeSchema + .optional() + .describe( + 'How access to the share is gated. The stored mode is kept when omitted. Enabling `public` clears the stored password and empties `allowedEmails`; `password` empties `allowedEmails`; `email` and `sso` clear the stored password.' + ), password: z .string() .min(1, 'password cannot be empty') .max(1024, 'password is too long') .optional() - .describe('Password for a password-gated share.'), + .describe( + 'Password for a password-gated share. Kept when omitted; enabling `password` with neither a supplied nor a stored password is a 400.' + ), allowedEmails: z .array(z.string().min(1, 'allowedEmails entries cannot be empty').max(320)) .max(200, 'Too many allowed emails') .optional() - .describe('Allowed addresses or @domain patterns for email and SSO shares.'), + .describe( + 'Allowed addresses or `@domain` patterns for email and SSO shares. Kept when omitted; enabling `email` or `sso` with an empty resulting list is a 400.' + ), }) .strict() diff --git a/apps/sim/lib/api/contracts/v2/knowledge.ts b/apps/sim/lib/api/contracts/v2/knowledge.ts index ee0bec07ac4..4e2fcbb3629 100644 --- a/apps/sim/lib/api/contracts/v2/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/knowledge.ts @@ -1206,7 +1206,7 @@ export const v2UpdateKnowledgeDocumentBodySchema = z .literal(true) .optional() .describe( - 'Requeue the document for processing. Send it alone: no other field may accompany it.' + 'Requeue a failed or stuck document for processing. Send it alone — no other field may accompany it — and it answers with a queue acknowledgement rather than the document.' ), }) .strict() diff --git a/apps/sim/lib/api/contracts/v2/logs.ts b/apps/sim/lib/api/contracts/v2/logs.ts index 6d388c424e0..f9abe133db2 100644 --- a/apps/sim/lib/api/contracts/v2/logs.ts +++ b/apps/sim/lib/api/contracts/v2/logs.ts @@ -227,7 +227,9 @@ export const v2ListLogsQuerySchema = v1ListLogsQuerySchema .optional() .default('basic'), includeTraceSpans: booleanQueryFlagSchema - .describe('Whether to include block-level trace spans.') + .describe( + 'Whether to include block-level trace spans. Spans are stored apart from the log row and pruned on their own retention schedule, so a run whose spans have aged out returns `traceSpans: []` rather than an error — an empty array does not mean the run recorded none.' + ) .optional() .default(false), includeFinalOutput: booleanQueryFlagSchema diff --git a/apps/sim/lib/api/contracts/v2/mcp-servers.ts b/apps/sim/lib/api/contracts/v2/mcp-servers.ts index fc1b5f2926e..60111ac2442 100644 --- a/apps/sim/lib/api/contracts/v2/mcp-servers.ts +++ b/apps/sim/lib/api/contracts/v2/mcp-servers.ts @@ -61,7 +61,9 @@ const v2McpServerUrlSchema = z }, { error: 'url must be an absolute http or https URL' } ) - .describe('Absolute HTTP or HTTPS endpoint URL without `{{ENV_VAR}}` references.') + .describe( + 'Absolute HTTP or HTTPS endpoint URL without `{{ENV_VAR}}` references. It determines server identity and is immutable: delete and recreate the server to change endpoints.' + ) const v2McpServerHeadersSchema = z.record( z.string().min(1, 'Header names cannot be empty'), @@ -223,11 +225,16 @@ export const v2CreateMcpServerBodySchema = z url: v2McpServerUrlSchema, authType: mcpAuthTypeSchema .optional() - .describe('Authentication method. Sim detects it from the server when omitted.'), + .describe( + 'Authentication method. Applied server-side as `headers` when omitted; registration never contacts the server, so an omitted value is never detected from it.' + ) + .meta({ default: 'headers' }), /** Write-only. Reads expose `hasHeaders` and `headerNames` instead. */ headers: v2McpServerHeadersSchema .optional() - .describe('Write-only request headers sent to the server.') + .describe( + 'Write-only request headers sent to the server. Replaced wholesale rather than merged on update: sending this field drops every stored header it does not repeat.' + ) .meta({ writeOnly: true }), timeout: z .number() @@ -259,14 +266,18 @@ export const v2CreateMcpServerBodySchema = z .max(512, 'oauthClientId is too long') .nullable() .optional() - .describe('Pre-registered OAuth client identifier.'), + .describe( + 'Pre-registered OAuth client identifier. Changing it on update revokes the stored OAuth grant and forces reauthorization.' + ), /** Write-only. Reads expose `hasOauthClientSecret` instead. */ oauthClientSecret: z .string() .max(2048, 'oauthClientSecret is too long') .nullable() .optional() - .describe('Write-only pre-registered OAuth client secret.') + .describe( + 'Write-only pre-registered OAuth client secret. Sending it on update as null or a new value revokes the stored OAuth grant and forces reauthorization, as does switching away from OAuth authentication.' + ) .meta({ writeOnly: true }), }) .strict() @@ -351,7 +362,7 @@ export const v2ListMcpServerToolsQuerySchema = v2McpServerWorkspaceQuerySchema .optional() .default(false) .describe( - 'Bypass the cached tool list and reconnect to the server. Slower, and the only way to pick up a tool added since the last refresh.' + 'Bypass the short-lived per-workspace tool cache and reconnect under your own credentials. A cached result reflects whichever workspace member last ran discovery, so this is the only way to pick up a tool added since then; it costs a live round trip.' ), }) .strict() diff --git a/apps/sim/lib/api/contracts/v2/openapi/billing.ts b/apps/sim/lib/api/contracts/v2/openapi/billing.ts index c2c9776269b..556a6875f45 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/billing.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/billing.ts @@ -80,7 +80,7 @@ const routes = [ operationId: 'getBillingStatus', summary: 'Get Billing Status', description: - "Return the current plan, billing standing, credit allowance, and storage quota. `credits` and `storage` report the payer's pooled allowances and are null unless the caller can manage that payer's billing; they are always null for a workspace API key. Billing history lives at `GET /api/v2/billing/logs`. Without a Stripe subscription — notably on the free plan — there is no real billing period: `period` is the open interval 1970-01-01 to 9999-12-31 and `credits.used` is lifetime consumption, not consumption since a period start.", + "Return the current plan, billing standing, credit allowance, and storage quota. `credits` and `storage` report the payer's pooled allowances and are null unless the caller can manage that payer's billing; they are always null for a workspace API key. Billing history lives at `GET /api/v2/billing/logs`.", errors: RESOURCE_ERRORS, success: { description: 'The current billing and storage status.' }, }), @@ -106,7 +106,7 @@ const routes = [ operationId: 'listBillingLogs', summary: 'List Billing Logs', description: - 'List the credit-denominated billing ledger with source filtering and opaque cursor pagination. `period` defaults to `30d`, so an unqualified request covers only the last 30 days: paginating to `nextCursor: null` exhausts that window, not the whole ledger. Pass `period=all` for full history, or `period=custom` with `startDate` and `endDate` for a specific range. Both bounds are accepted only with `period=custom` — sending either alongside a relative period is a 400, never a page silently answered over a different window — and both take the same strict UTC ISO 8601 form as `GET /api/v2/logs`. An inverted window is a 400 rather than an empty page.', + 'List the credit-denominated billing ledger with source filtering and opaque cursor pagination. `period` defaults to `30d`, so an unqualified request covers only the last 30 days: paginating to `nextCursor: null` exhausts that window, not the whole ledger. An inverted custom window is a 400 rather than an empty page.', errors: RESOURCE_ERRORS, success: { description: 'A page of usage events.' }, }), diff --git a/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts b/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts index 53eafb881b7..aba11d0c5cb 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts @@ -28,6 +28,7 @@ import { type ErrorResponseId, FOLDER_TREE_TOO_LARGE, FULL_SET_LIST, + HEAD_MIRRORS_GET, RATE_LIMIT_HEADERS, RESOURCE_CONFLICT_ERRORS, RESOURCE_ERRORS, @@ -331,8 +332,7 @@ const routes = [ filesOperation({ operationId: 'downloadFile', summary: 'Download File', - 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. Downloading is recorded as an audit event, so it is not a safe read: a `HEAD` request is answered with an empty `200` without fetching the bytes or recording anything, and reports only that the endpoint exists and the caller is authorized.', + description: `Download the current file bytes from a workspace. A generated document is served as its compiled artifact, so it answers \`409\` while that artifact is still compiling and \`413\` if it renders past the size ceiling. Downloading records an audit event, so it is not a safe read. ${HEAD_MIRRORS_GET}`, errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], success: { description: 'The file bytes.', @@ -361,8 +361,8 @@ const routes = [ operationId: 'deleteFile', summary: 'Delete File', description: - 'Archive a workspace file. This is a soft delete: the row is retained with a deletion timestamp, the file stops appearing in the default listing and is no longer readable through the API, and its stored bytes are never removed. List archived files with `GET /files?scope=archived` and reverse the delete with `POST /files/{fileId}/restore`.', - errors: RESOURCE_CONFLICT_ERRORS, + 'Archive a workspace file. This is a soft delete: the row is retained with a deletion timestamp, the file stops appearing in the default listing and is no longer readable through the API, and its stored bytes are never removed. Archiving an already-archived file is a `404`, not a no-op. List archived files with `GET /files?scope=archived` and reverse the delete with `POST /files/{fileId}/restore`.', + errors: RESOURCE_ERRORS, success: { description: 'Deletion confirmation.' }, }), { @@ -430,7 +430,7 @@ const routes = [ operationId: 'restoreFile', summary: 'Restore File', description: - 'Reverse a soft delete and return the file to the workspace. Restore is not a pure undo: the file comes back at the workspace root regardless of the folder it was deleted from, and it gains a `_restored` suffix when another file at the root already holds its name — so read `folderPath` and `name` off the response rather than assuming the pre-delete values. Restoring a file that is already active is a no-op that returns that file, so a retry is safe. Returns 400 when the workspace itself has been archived, and 409 when no free restore name could be found.', + 'Reverse a soft delete and return the file to the workspace. Restore is not a pure undo — the file comes back at the workspace root, and gains a `_restored` suffix when another file there already holds its name — so read `folderPath` and `name` off the response. Restoring an already-active file is a no-op that returns it, so a retry is safe. An archived workspace is a 400, and a name the restore could not free is a 409.', errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], success: { description: 'The file as it exists after the restore.' }, }), @@ -618,7 +618,7 @@ const routes = [ filesOperation({ operationId: 'upsertFileShare', summary: 'Enable or Disable File Share', - description: `Create or partially update a server-tokenized public share. Only isActive is required, and an omitted authType keeps the stored auth mode. What happens to password and allowedEmails depends on the resulting mode, because enabling a share always rewrites the credentials the chosen mode does not use: 'public' clears the stored password and empties allowedEmails; 'password' keeps the stored password when password is omitted but empties allowedEmails; 'email' and 'sso' clear the stored password and keep the stored allowedEmails when the field is omitted. Only disabling with isActive false preserves the whole access configuration untouched — it also retains the token, so re-enabling restores the share as it was. Two enabling combinations are rejected outright with a 400 instead of being partially applied: 'password' when neither a password is supplied nor one is already stored, and 'email' or 'sso' when the resulting allowedEmails would be empty because none was supplied and none is stored. On a file that has never been shared there is nothing stored to fall back on, so enabling any mode other than 'public' must carry its credential in the same request. ${WORKSPACE_API_KEY_DENIED}`, + description: `Create or partially update a server-tokenized public share. Only \`isActive\` is required; each other field states what enabling a mode does to it. A file that has never been shared has nothing stored to fall back on, so enabling any mode other than \`public\` must carry its credential in the same request. ${WORKSPACE_API_KEY_DENIED}`, errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], success: { description: 'The updated file share.' }, }), diff --git a/apps/sim/lib/api/contracts/v2/openapi/head-not-safe.test.ts b/apps/sim/lib/api/contracts/v2/openapi/head-not-safe.test.ts new file mode 100644 index 00000000000..6204cc45afb --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/openapi/head-not-safe.test.ts @@ -0,0 +1,68 @@ +/** + * @vitest-environment node + */ +import { existsSync, readFileSync } from 'node:fs' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { filesAuditOpenApiDocument } from '@/lib/api/contracts/v2/openapi/files-audit' +import { resourcesOpenApiDocument } from '@/lib/api/contracts/v2/openapi/resources' +import { HEAD_MIRRORS_GET } from '@/lib/api/contracts/v2/openapi/shared' +import { tablesOpenApiDocument } from '@/lib/api/contracts/v2/openapi/tables' +import { workflowsOpenApiDocument } from '@/lib/api/contracts/v2/openapi/workflows' +import type { OpenApiDocumentDefinition, OpenApiRouteDefinition } from '@/lib/api/openapi/types' + +const APP_ROOT = path.resolve(import.meta.dirname, '../../../../../app') + +const DOCUMENTS: readonly OpenApiDocumentDefinition[] = [ + filesAuditOpenApiDocument, + resourcesOpenApiDocument, + tablesOpenApiDocument, + workflowsOpenApiDocument, +] + +/** + * Reads the route module's source rather than importing it: importing an + * `app/api/**` route pulls the whole server graph into a contract-layer test, + * and `headSafe` is a literal on the builder call, so the source is where it is + * unambiguously visible. + */ +function declaresHeadNotSafe(route: OpenApiRouteDefinition): boolean { + if (route.contract.method !== 'GET') return false + const file = path.join(APP_ROOT, route.contract.path, 'route.ts') + if (!existsSync(file)) return false + return readFileSync(file, 'utf8') + .split('\n') + .some((line) => line.trim() === 'headSafe: false,') +} + +/** + * The `headSafe: false` short-circuit used to answer a bodiless `200` straight + * after admission, before the use case — and therefore before authorization — + * ran at all. Both descriptions that mentioned `HEAD` documented that behavior, + * and both stayed put when the builders were fixed to authorize first, so the + * spec went on telling callers a `HEAD` on a forbidden or nonexistent id was a + * `200`. That is a security claim, which makes it the one sentence worth a + * standing check rather than a one-time correction. + */ +describe('operations whose GET declares headSafe: false', () => { + it('document that HEAD is authorized exactly as GET is', () => { + const routes = DOCUMENTS.flatMap((document) => document.routes).filter(declaresHeadNotSafe) + + expect(routes.length).toBeGreaterThan(0) + expect( + routes + .filter((route) => !route.operation.description.includes(HEAD_MIRRORS_GET)) + .map((route) => `${route.operation.operationId} (GET ${route.contract.path})`) + ).toEqual([]) + }) + + it('never claims a HEAD is answered without an authorization check', () => { + const claiming = DOCUMENTS.flatMap((document) => document.routes) + .filter((route) => + /`?HEAD`? request is answered with an empty/i.test(route.operation.description) + ) + .map((route) => route.operation.operationId) + + expect(claiming).toEqual([]) + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts b/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts index f506e235128..f1df03b394a 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts @@ -213,7 +213,7 @@ const routes = [ operationId: 'searchKnowledge', summary: 'Search Knowledge', description: - 'Search one or more knowledge bases with semantic vector retrieval, optional hybrid full-text retrieval, and structured tag filters. Set `rerankerEnabled` to re-order the retrieved chunks with a reranking model before truncating to `topK`; `rerankerModel` selects the model and defaults when omitted. Reranked results carry a `rerankerScore` and are ordered by it, and reranking is billed as an additional search unit. Reranking is best-effort: a reranker that cannot run — a provider failure, a timeout, or a deployment with no reranking credential — falls back to vector ordering rather than failing the search, so read `rerankerStatus` on the response to tell an ordering the reranker produced from one it never touched. Every result names the `knowledgeBaseId` it came from. The request body is capped at 2 MiB; a larger body is a 413.', + 'Search one or more knowledge bases with semantic vector retrieval, optional hybrid full-text retrieval, and structured tag filters. Every result names the `knowledgeBaseId` it came from. The request body is capped at 2 MiB; a larger body is a 413.', errors: [...WORKSPACE_ERRORS, 'UsageLimitExceeded', 'NotFound', 'PayloadTooLarge'], success: { description: 'Matching document chunks ordered by relevance.' }, }), @@ -246,7 +246,7 @@ const routes = [ knowledgeOperation({ operationId: 'listKnowledgeTags', summary: 'List Tags', - description: `List the knowledge base's tag vocabulary: each tag's display name, the slot it is stored in, and its field type. Display names are what tag filters and the tag values on document reads use; slots are what document writes set. Every slot listed here is writable, in its declared type: \`tag1\`..\`tag7\` take a string, \`number1\`..\`number5\` a number, \`date1\`..\`date2\` a \`YYYY-MM-DD\` string, and \`boolean1\`..\`boolean3\` a boolean. The vocabulary is bounded by the fixed slot table. ${FULL_SET_LIST}`, + description: `List the knowledge base's tag vocabulary: each tag's display name, the slot it is stored in, and its field type. Filters and document reads use display names; document writes address slots, each in its declared type. The vocabulary is bounded by the fixed slot table. ${FULL_SET_LIST}`, errors: RESOURCE_ERRORS, success: { description: 'The knowledge base tag vocabulary.' }, }), @@ -307,7 +307,7 @@ const routes = [ knowledgeOperation({ operationId: 'bulkUpdateKnowledgeDocuments', summary: 'Bulk Enable or Disable Documents', - description: `Enable or disable many documents in one request, either by identifier (up to 100) or, with \`selectAll\`, every document in the knowledge base optionally narrowed by \`enabledFilter\`. Disabling keeps a document indexed but excludes it from search. Bulk delete is deliberately not offered: the bulk path records no audit entries, so deletions go through \`DELETE /api/v2/knowledge/{id}/documents/{documentId}\`, which audits each one. An identifier request echoes the documents it changed in \`documentIds\`; a \`selectAll\` request omits that field because the selection is unbounded, and reports \`updatedCount\` alone. ${WORKSPACE_API_KEY_DENIED}`, + description: `Enable or disable many documents in one request, either by identifier or, with \`selectAll\`, every document in the knowledge base. Bulk delete is deliberately not offered: the bulk path records no audit entries, so deletions go through \`DELETE /api/v2/knowledge/{id}/documents/{documentId}\`, which audits each one. ${WORKSPACE_API_KEY_DENIED}`, errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], success: { description: 'The number and identifiers of the documents that changed.' }, }), @@ -583,7 +583,7 @@ const routes = [ knowledgeOperation({ operationId: 'updateKnowledgeDocument', summary: 'Update Document', - description: `Rename a document, enable or disable it for search, set any of its 17 tag slots, or requeue it for processing. A tag slot takes its declared type — a string for \`tag1\`..\`tag7\`, a number for \`number1\`..\`number5\`, a \`YYYY-MM-DD\` string for \`date1\`..\`date2\`, a boolean for \`boolean1\`..\`boolean3\` — and a value that is not valid for the slot is a \`400\` rather than a silently cleared tag. Resolve a display name to its slot with \`GET /api/v2/knowledge/{id}/tags\`. Absent fields are unchanged. Only caller-owned fields are accepted: derived indexing state (\`chunkCount\`, \`tokenCount\`, \`characterCount\`, \`processingStatus\`, \`processingError\`) is written by the processing pipeline and cannot be asserted here. \`retryProcessing: true\` re-queues a failed or stuck document and must be sent on its own — it runs instead of, not alongside, the field updates — and it answers with a queue acknowledgement rather than the document. Otherwise the updated document is returned; it omits the connector provenance the detail read carries, so re-read with GET when that is needed. ${WORKSPACE_API_KEY_DENIED}`, + description: `Rename a document, enable or disable it for search, set any of its 17 tag slots, or requeue it for processing. Absent fields are unchanged, and derived indexing state — \`chunkCount\`, \`tokenCount\`, \`characterCount\`, \`processingStatus\`, \`processingError\` — is written by the processing pipeline and cannot be asserted here. Resolve a tag display name to its slot with \`GET /api/v2/knowledge/{id}/tags\`. The returned document omits the connector provenance the detail read carries. ${WORKSPACE_API_KEY_DENIED}`, errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], success: { description: 'The updated document, or the requeue acknowledgement.' }, }), diff --git a/apps/sim/lib/api/contracts/v2/openapi/logs.ts b/apps/sim/lib/api/contracts/v2/openapi/logs.ts index bc734ffb3f3..c9813605a20 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/logs.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/logs.ts @@ -95,7 +95,7 @@ const routes = [ logsOperation({ operationId: 'listLogs', summary: 'List Logs', - description: `List workflow execution logs for a workspace with filters, selectable detail, and opaque cursor pagination. This list predates the shared sort convention: it has no \`sortBy\` (the sort column is fixed to execution start time) and spells the direction \`order\` rather than \`sortOrder\`. ${RUN_RETENTION} Trace spans are stored separately from the log row and are pruned on their own retention schedule: \`includeTraceSpans=true\` on a run whose stored spans have aged out returns \`traceSpans: []\` rather than an error, so an empty array does not mean the run recorded no spans.`, + description: `List workflow execution logs for a workspace with filters, selectable detail, and opaque cursor pagination. This list predates the shared sort convention: it has no \`sortBy\` (the sort column is fixed to execution start time) and spells the direction \`order\` rather than \`sortOrder\`. ${RUN_RETENTION}`, errors: RESOURCE_ERRORS, success: { description: 'A page of execution logs matching the filters.' }, }), @@ -121,7 +121,7 @@ const routes = [ operationId: 'getLog', summary: 'Get Log', description: - 'Retrieve the diagnostic representation of a run, including its workflow snapshot, trace spans, final output, and cost. The returned `workflowState` snapshot has credential values redacted: OAuth credential references and secret (`password`) sub-block values are null, while `{{VAR}}` environment-variable references are preserved so consecutive snapshots stay diffable. Trace spans are stored separately from the log row and are pruned on their own retention schedule: a run whose stored spans have aged out returns `traceSpans: []` rather than an error, so an empty array does not mean the run recorded no spans.', + 'Retrieve the diagnostic representation of a run, including its workflow snapshot, trace spans, final output, and cost. Trace spans are stored apart from the log row and pruned on their own retention schedule, so an empty `traceSpans` array does not mean the run recorded none.', errors: RESOURCE_ERRORS, success: { description: 'The requested diagnostic log representation.' }, }), diff --git a/apps/sim/lib/api/contracts/v2/openapi/resources.ts b/apps/sim/lib/api/contracts/v2/openapi/resources.ts index 1befdb06033..3ff095f788f 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/resources.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/resources.ts @@ -19,6 +19,7 @@ import { ERROR_RESPONSES, type ErrorResponseId, FULL_SET_LIST, + HEAD_MIRRORS_GET, RATE_LIMIT_HEADERS, RESOURCE_BODY_ERRORS, RESOURCE_CONFLICT_BODY_ERRORS, @@ -279,7 +280,7 @@ const routes = [ operationId: 'listMcpServers', summary: 'List MCP Servers', description: - 'List MCP servers registered in a workspace. Request-header values and OAuth client secrets are never returned. Nothing caps how many servers a workspace registers, so this list is paginated: paginate with `limit` and `cursor`, stopping when `nextCursor` is null. `connectionStatus`, `lastConnected`, `toolCount`, `lastError`, and `lastToolsRefresh` describe the most recent tool discovery and stay at their registration defaults — `disconnected`, with `lastConnected` absent — until one runs. Call `GET /api/v2/mcp-servers/{id}/tools` to run it.', + 'List MCP servers registered in a workspace. Request-header values and OAuth client secrets are never returned. Paginate with `limit` and `cursor`, stopping when `nextCursor` is null. The discovery fields stay at their registration defaults until `GET /api/v2/mcp-servers/{id}/tools` runs a discovery.', errors: RESOURCE_ERRORS, success: { description: 'MCP servers registered in the workspace.' }, }), @@ -305,7 +306,7 @@ const routes = [ operationId: 'createMcpServer', summary: 'Create MCP Server', description: - 'Register an MCP server in a workspace. The endpoint URL determines server identity, must be absolute HTTP or HTTPS, and cannot contain environment-variable references. Header values and OAuth client secrets are write-only. `transport`, `timeout`, `retries`, and `enabled` are applied server-side when omitted; the effective values are in the response. Registration stores the configuration and does not connect to the endpoint, so a 201 is not evidence the server is reachable: the response carries `connectionStatus: "disconnected"` and omits `lastConnected`, and the workspace tool registry treats the server as unavailable until a discovery succeeds. Call `GET /api/v2/mcp-servers/{id}/tools` to attempt one and see the outcome. Re-registering an existing URL rewrites the configuration and returns the server to the same unverified state.', + 'Register an MCP server in a workspace. The endpoint URL determines server identity, so a URL already registered in the workspace is a `409` — reconfigure that server with `PATCH /api/v2/mcp-servers/{id}` instead. Registration stores the configuration and never connects to the endpoint, so a 201 is not evidence the server is reachable: it comes back `disconnected` and the workspace tool registry treats it as unavailable until `GET /api/v2/mcp-servers/{id}/tools` succeeds.', errors: RESOURCE_CONFLICT_BODY_ERRORS, success: { description: 'The MCP server was registered.' }, }), @@ -373,7 +374,7 @@ const routes = [ operationId: 'updateMcpServer', summary: 'Update MCP Server', description: - 'Update the supplied MCP server fields. The URL is immutable because it determines server identity; delete and recreate the server to change endpoints. Two fields do not follow the omitted-fields-are-retained rule. `headers` is replaced wholesale rather than merged: sending it drops every stored header it does not repeat, and the only way to keep a header is to resend it. Changing `oauthClientId`, or sending `oauthClientSecret` as null or a new value, revokes the stored OAuth grant and forces reauthorization; switching away from OAuth authentication revokes it too.', + 'Update the supplied MCP server fields. Omitted fields are retained, except where a field says otherwise. Any change that invalidates authentication revokes the stored OAuth grant, resets `connectionStatus` to `disconnected`, and clears `lastConnected` and `lastError`, so the server must be rediscovered.', errors: RESOURCE_BODY_ERRORS, success: { description: 'The updated MCP server.' }, }), @@ -438,7 +439,7 @@ const routes = [ resourceOperation('MCP Servers', { operationId: 'listMcpServerTools', summary: 'List MCP Server Tools', - description: `Connect to a registered MCP server and return the tools it exposes. Unlike most reads this one has side effects: it opens a live connection to the third-party server and writes \`connectionStatus\`, \`toolCount\`, \`lastError\`, and \`lastToolsRefresh\` on the server resource, so registering a server and then calling this completes onboarding without opening the Sim UI. Because the pass is not a safe read, a \`HEAD\` request is answered with an empty \`200\` without connecting or writing, so it reports only that the endpoint exists and the caller is authorized. Results are served from a short-lived per-workspace cache, so an uncached call reflects whichever workspace member last ran discovery; pass \`refresh=true\` to reconnect under your own credentials and pick up tools added since the last pass, at the cost of a live round trip to the server. The set is bounded by discovery itself — at most 1,000 tools and 5 MB of tool payload per server. ${FULL_SET_LIST} An unreachable, slow, or cooling-down server is a \`503\`; a server whose stored OAuth grant no longer works is a \`409\` with \`error.details.code\` \`MCP_SERVER_REAUTHORIZATION_REQUIRED\`, meaning the registration is intact but a human must reauthorize it in Sim — your API key is fine and re-issuing it changes nothing. ${WORKSPACE_API_KEY_DENIED} Discovery resolves the calling user's own OAuth credentials for the server, which a workspace key cannot supply — so a workspace key that can register a server cannot list its tools.`, + description: `Connect to a registered MCP server and return the tools it exposes, completing onboarding without opening the Sim UI. Unlike most reads this one has side effects: it opens a live connection to the third-party server and writes \`connectionStatus\`, \`toolCount\`, \`lastError\`, and \`lastToolsRefresh\` on the server resource. ${HEAD_MIRRORS_GET} Discovery itself bounds the set at 1,000 tools and 5 MB of tool payload per server. ${FULL_SET_LIST} An unreachable, slow, or cooling-down server is a \`503\`; a stored OAuth grant that no longer works is a \`409\` with \`error.details.code\` \`MCP_SERVER_REAUTHORIZATION_REQUIRED\`, which only a human reauthorizing in Sim can clear. ${WORKSPACE_API_KEY_DENIED}`, errors: RESOURCE_CONFLICT_ERRORS, success: { description: 'Tools exposed by the MCP server.' }, }), diff --git a/apps/sim/lib/api/contracts/v2/openapi/shared.ts b/apps/sim/lib/api/contracts/v2/openapi/shared.ts index e29103601c1..90b92d282fe 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/shared.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/shared.ts @@ -214,6 +214,21 @@ export const FOLDER_TREE_TOO_LARGE = export const FULL_SET_LIST = 'The bounded set is returned in one page with `nextCursor` always null; there is no second page to fetch.' +/** + * Appended to a `GET` whose route declares `headSafe: false` because the read + * has an effect — an outbound connection, or an audit event. + * + * The sentence this replaced promised the opposite of what the route now does. + * The short-circuit used to sit between admission and parsing, so a `HEAD` + * returned a bodiless `200` for an id the same caller's `GET` answered `403` or + * `404` for — an existence oracle. `defineV2JsonRoute`/`defineV2BinaryRoute` + * now admit, parse, and authorize a `HEAD` through the use case's `authorize` + * phase before answering it bodiless, so its refusals mirror the `GET`'s. + * Pinned by `contracts/v2/openapi/head-not-safe.test.ts`. + */ +export const HEAD_MIRRORS_GET = + 'A `HEAD` skips the effect but is authorized exactly as the `GET` is, so it answers `400`, `401`, `403`, or `404` wherever the `GET` would and an empty `200` otherwise.' + /** * Appended to an operation whose semantic operation sets `workspaceApiKey: 'deny'`. * That policy is structural — an `admin` operation can never accept a workspace key — diff --git a/apps/sim/lib/api/contracts/v2/openapi/tables.ts b/apps/sim/lib/api/contracts/v2/openapi/tables.ts index 38fefdf73c1..5d98322d4d6 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/tables.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/tables.ts @@ -249,7 +249,7 @@ const declaredRoutes = [ tableOperation({ operationId: 'updateTable', summary: 'Update Table', - description: `Rename a table, edit its description, or move it to a canonical folder. At least one mutable field is required; lock flags remain read-only.\n\nThis operation is NOT atomic. The name, description, and folder changes are written independently in that order, so a failure part-way through leaves the earlier writes committed — a 4xx does NOT mean nothing changed. When at least one field landed before the failure, the error body carries \`details.applied\`: the list of fields (\`name\`, \`description\`, \`folderPath\`) that were successfully written. Re-read the table, or retry with only the fields missing from \`details.applied\`.\n\n${FOLDER_TREE_TOO_LARGE}`, + description: `Rename a table, edit its description, or move it to a canonical folder. At least one mutable field is required; lock flags remain read-only.\n\nThis operation is NOT atomic. Name, description, and folder are written independently in that order, so a 4xx does NOT mean nothing changed: the error body carries \`details.applied\` naming the fields that landed. Retry with only the fields missing from it.\n\n${FOLDER_TREE_TOO_LARGE}`, errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], success: { description: 'The updated table.' }, }), diff --git a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts index bb713ccf358..0afaed27e78 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts @@ -4,6 +4,7 @@ import { type ErrorResponseId, FOLDER_TREE_TOO_LARGE, FULL_SET_LIST, + HEAD_MIRRORS_GET, RATE_LIMIT_HEADERS, RESOURCE_CONFLICT_ERRORS, RESOURCE_ERRORS, @@ -371,7 +372,7 @@ const routes = [ workflowOperation({ operationId: 'deployWorkflow', summary: 'Deploy Workflow', - description: `Create and asynchronously activate a deployment version. This request is not idempotent: it accepts no idempotency key and every call mints a new deployment version, so retrying after a timeout creates a second version rather than returning the first. The response carries \`latestDeploymentAttempt\` for the accepted attempt, but \`GET /workflows/{id}\` does not expose that field — poll activation with \`isDeployed\` and \`deployedAt\` on the workflow, or with \`isActive\` on \`GET /workflows/{id}/versions\`. Returns 409 when the deployment would conflict with an existing webhook path. ${WORKSPACE_API_KEY_DENIED}`, + description: `Create and asynchronously activate a deployment version. This request is not idempotent: every call mints a new deployment version, so retrying after a timeout creates a second version rather than returning the first. A deployment that would conflict with an existing webhook path is a 409. ${WORKSPACE_API_KEY_DENIED}`, errors: [...RESOURCE_ERRORS, 'Conflict', 'PayloadTooLarge', 'Locked'], success: jsonSuccess('The accepted deployment attempt.'), }), @@ -493,7 +494,7 @@ const routes = [ workflowOperation({ operationId: 'exportWorkflow', summary: 'Export Workflow', - description: `Export a portable, secret-sanitized workflow. Workspace-scoped bindings must be selected again after import. ${FOLDER_TREE_TOO_LARGE}`, + description: `Export a portable, secret-sanitized workflow. Workspace-scoped bindings must be selected again after import. Exporting records an audit event, so it is not a safe read. ${HEAD_MIRRORS_GET} ${FOLDER_TREE_TOO_LARGE}`, errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], success: jsonSuccess('The workflow export payload.'), }), @@ -710,7 +711,7 @@ const routes = [ operationId: 'cancelRunV2', summary: 'Cancel Workflow Run', description: - 'Request cancellation of a running, queued, or paused workflow run. Cancelling a run that has already reached a terminal state succeeds with no effect rather than returning an error. The `reason` field is present on every response, including full successes — `recorded` is the success value; it is not a partial-failure marker. A run produced by a table workflow group is a 409 when its cell can no longer accept the cancellation, because the run and its cell must reach the cancelled state together.', + 'Request cancellation of a running, queued, or paused workflow run. Cancelling a run that has already reached a terminal state succeeds with no effect rather than returning an error. A run produced by a table workflow group is a 409 when its cell can no longer accept the cancellation, because the run and its cell must reach the cancelled state together.', errors: RESOURCE_CONFLICT_ERRORS, success: jsonSuccess('The cancellation outcome.'), }), diff --git a/apps/sim/lib/api/contracts/v2/shared.ts b/apps/sim/lib/api/contracts/v2/shared.ts index 47fc81fd2f4..1802bdd861f 100644 --- a/apps/sim/lib/api/contracts/v2/shared.ts +++ b/apps/sim/lib/api/contracts/v2/shared.ts @@ -5,7 +5,13 @@ import { FORBIDDEN_DETAIL_CODE_DESCRIPTIONS, FORBIDDEN_DETAIL_CODES, } from '@/lib/core/application/forbidden' -import { FolderPathError, parseFolderPath, requireNonRootFolderPath } from '@/lib/folders/paths' +import { + FolderPathError, + MAX_FOLDER_PATH_BYTES, + MAX_FOLDER_PATH_SEGMENTS, + parseFolderPath, + requireNonRootFolderPath, +} from '@/lib/folders/paths' /** * Shared building blocks for the v2 API contract surface. @@ -287,12 +293,21 @@ export function v2LimitSchema(options: V2LimitOptions = {}) { const base = z.coerce.number({ error: 'limit must be a number' }) if (outOfRange === 'clamp') { - return base - .optional() - .default(fallback) - .transform((value) => Math.min(Math.max(1, Math.trunc(value)), max)) - .describe(described) - .meta({ type: 'integer', minimum: 1, maximum: max }) + return ( + base + .optional() + .default(fallback) + .transform((value) => Math.min(Math.max(1, Math.trunc(value)), max)) + .describe(described) + /** + * `minimum`/`maximum` are deliberately absent. In JSON Schema they mean + * "rejected outside", and this branch clamps instead — publishing them + * made a generated SDK refuse locally a `limit` the server would have + * accepted and silently corrected. The range lives in the description, + * which is where a clamped bound belongs. + */ + .meta({ type: 'integer' }) + ) } return base @@ -389,6 +404,19 @@ export function v2RunOrderSchema(subject: 'execution' | 'run') { */ export const V2_SEARCH_MAX_LENGTH = 200 +/** + * Added to `sortBy` wherever `name` is sortable. + * + * Name ordering is `ORDER BY` on the stored text with no `COLLATE` and no + * `lower()`, so it is whatever the server database's collation does — under a + * `C`-collated deployment that is byte order, which puts every capitalized name + * ahead of every lowercase one. Nothing in the API pins the collation, so the + * spec must not promise one; what it can promise is that Sim does not case-fold, + * which is the part a caller gets wrong. + */ +const NAME_SORT_COLLATION = + 'Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.' + export const v2SearchSchema = z .string() .trim() @@ -399,6 +427,15 @@ export const v2SearchSchema = z export const v2SortOrderSchema = z.enum(LIST_SORT_ORDERS).describe('Sort direction.') +/** + * The closed vocabulary `z.stringbool()` accepts, restated here only so the + * generated spec can publish it — Zod's defaults are internal to the library + * and contribute nothing to the JSON Schema. `shared.test.ts` pins each spelling + * against the schema so a Zod upgrade that changes the set fails here. + */ +export const V2_TRUE_VALUES = ['true', '1', 'yes', 'on', 'y', 'enabled'] as const +export const V2_FALSE_VALUES = ['false', '0', 'no', 'off', 'n', 'disabled'] as const + export type V2SortOrder = ListSortOrder function canonicalFolderPathSchema(parser: (path: string) => string[]) { @@ -415,16 +452,34 @@ function canonicalFolderPathSchema(parser: (path: string) => string[]) { }) } +/** + * The canonical-path rule, published once on the two folder-path components + * every folder family references rather than restated per operation. + * + * `canonicalFolderPathSchema` validates through `superRefine`, which + * contributes nothing to JSON Schema, so a folder path shipped as an + * unconstrained `string`: the percent-encoding, the rejections, and both caps + * were invisible to a spec-driven client. `maxLength` is the byte cap measured + * on the *encoded* form, so it is an upper bound on characters rather than a + * character count — a name outside the unreserved set spends up to twelve + * bytes per source character. + */ +const FOLDER_PATH_FORMAT = `Segments are percent-encoded, so a folder shown as "New folder" is \`/New%20folder\`: everything outside \`A-Z a-z 0-9 - _ . ~\` is escaped as uppercase hex, and only that exact encoding is accepted. Unicode is supported encoded. A trailing slash, an empty segment, and a literal \`.\` or \`..\` segment are rejected. At most ${MAX_FOLDER_PATH_SEGMENTS} segments and ${MAX_FOLDER_PATH_BYTES} encoded bytes.` + /** Canonical slash-prefixed folder path. `/` is the workspace root. */ -export const v2FolderPathSchema = canonicalFolderPathSchema(parseFolderPath).describe( - 'Canonical slash-prefixed folder path. `/` is the workspace root.' -) +export const v2FolderPathSchema = canonicalFolderPathSchema(parseFolderPath).meta({ + title: 'Folder path', + description: `Canonical slash-prefixed folder path. \`/\` is the workspace root. ${FOLDER_PATH_FORMAT}`, + maxLength: MAX_FOLDER_PATH_BYTES, +}) export type V2FolderPath = z.output /** Canonical path that identifies a real folder rather than the virtual root. */ -export const v2NonRootFolderPathSchema = canonicalFolderPathSchema( - requireNonRootFolderPath -).describe('Canonical slash-prefixed path identifying a real folder rather than the root.') +export const v2NonRootFolderPathSchema = canonicalFolderPathSchema(requireNonRootFolderPath).meta({ + title: 'Non-root folder path', + description: `Canonical slash-prefixed path identifying a real folder rather than the root. ${FOLDER_PATH_FORMAT}`, + maxLength: MAX_FOLDER_PATH_BYTES, +}) function normalizeFolderPathInput(path: string): string { return path.length === 0 || path.startsWith('/') ? path : `/${path}` @@ -435,14 +490,24 @@ export const v2FolderPathInputSchema = z .string() .transform(normalizeFolderPathInput) .pipe(v2FolderPathSchema) - .describe('Folder path. A missing leading slash is normalized before validation.') + .meta({ + id: 'FolderPathInput', + title: 'Folder path input', + description: `Folder path. A missing leading slash is normalized before validation. ${FOLDER_PATH_FORMAT}`, + maxLength: MAX_FOLDER_PATH_BYTES, + }) /** Non-root input path that accepts an omitted leading slash and emits the canonical form. */ export const v2NonRootFolderPathInputSchema = z .string() .transform(normalizeFolderPathInput) .pipe(v2NonRootFolderPathSchema) - .describe('Non-root folder path. A missing leading slash is normalized before validation.') + .meta({ + id: 'NonRootFolderPathInput', + title: 'Non-root folder path input', + description: `Non-root folder path. A missing leading slash is normalized before validation. ${FOLDER_PATH_FORMAT}`, + maxLength: MAX_FOLDER_PATH_BYTES, + }) export const v2FolderSchema = z .object({ @@ -510,10 +575,20 @@ export const v2DeleteFolderQuerySchema = z .object({ workspaceId: workspaceIdSchema.describe('Workspace containing the folder.'), path: v2NonRootFolderPathInputSchema.describe('Path of the folder to delete.'), + /** + * Published as an enum rather than the bare `type: string` `z.stringbool()` + * emits. This is the difference between deleting one empty folder and + * deleting a subtree, and the accepted vocabulary is closed — an + * out-of-vocabulary value is a `400`, not a silent `false` — so leaving it + * undeclared hid a destructive switch behind a guess. + */ recursive: z .stringbool() .prefault('false') - .describe('Delete nested files and folders when true.'), + .describe( + "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. Any listed spelling is accepted in any case, and any other value is rejected." + ) + .meta({ enum: [...V2_TRUE_VALUES, ...V2_FALSE_VALUES] }), }) .strict() @@ -526,8 +601,11 @@ export function v2SortFields( fields: F, defaults: { sortBy: F[number]; sortOrder: V2SortOrder } ) { + const sortByDescription = fields.includes('name') + ? `Field used to sort the result. ${NAME_SORT_COLLATION}` + : 'Field used to sort the result.' return { - sortBy: z.enum(fields).default(defaults.sortBy).describe('Field used to sort the result.'), + sortBy: z.enum(fields).default(defaults.sortBy).describe(sortByDescription), sortOrder: v2SortOrderSchema.default(defaults.sortOrder).describe('Sort direction.'), } } diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 82ca99ec5fc..429193b4ff4 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -817,8 +817,16 @@ export type V2ExecutionError = z.output * exported string so the request-body description and the OpenAPI operation * description cannot drift from each other. */ +/** + * The six rejected option combinations used to be enumerated here and pasted + * onto both the operation and the request-body description, restating what each + * field already says. A caller reads the constraint where it applies — on the + * field it constrains — so the enumeration lives on `async`, `stream`, + * `executionTimeoutSeconds`, `includeThinking`, and `includeToolCalls`, and the + * operation says only that the options are mutually constrained. + */ export const EXECUTE_OPTION_CONSTRAINTS = - 'Option constraints — each is a 400: (1) `async: true` requires an API key; anonymous public-workflow callers may only execute synchronously or as a stream. (2) `async` and `stream` cannot both be true. (3) `executionTimeoutSeconds` is accepted only when `async: true`. (4) `async: true` rejects every streaming and output-shaping option — `selectedOutputs`, `includeThinking`, `includeToolCalls`, `includeFileBase64`, and `base64MaxBytes`. (5) `includeThinking` and `includeToolCalls` require `stream: true`. (6) `includeThinking` and `includeToolCalls` require the `X-Sim-Stream-Protocol: agent-events-v1` request header, which declares that the client understands agent-event frames.' + 'Each option carries the modes it requires and the modes that reject it; a violated combination is a 400.' /** * Strict public execute body. Async is body-selected (`async: true`) — v2 has @@ -888,7 +896,7 @@ export const v2ExecuteWorkflowBodySchema = z includeFileBase64: z .boolean() .optional() - .describe('Inline eligible output files as base64 content.'), + .describe('Inline eligible output files as base64 content. Rejected when `async` is true.'), /** Caps inline base64 file hydration; bounded (v1 leaves it unbounded). */ base64MaxBytes: z .number() @@ -896,7 +904,9 @@ export const v2ExecuteWorkflowBodySchema = z .positive() .max(10 * 1024 * 1024) .optional() - .describe('Maximum total bytes of file content to inline as base64.'), + .describe( + 'Maximum total bytes of file content to inline as base64. Rejected when `async` is true.' + ), }) .strict() .meta({ diff --git a/apps/sim/lib/table/query-builder/predicate.ts b/apps/sim/lib/table/query-builder/predicate.ts index d3e730b73d0..dbaea52fcbc 100644 --- a/apps/sim/lib/table/query-builder/predicate.ts +++ b/apps/sim/lib/table/query-builder/predicate.ts @@ -3,8 +3,11 @@ import type { TablePredicate, TablePredicateInput } from '@/lib/table/types' /** Max members in one `all`/`any` group. */ export const MAX_PREDICATE_GROUP_SIZE = 100 -const MAX_PREDICATE_DEPTH = 10 -const MAX_PREDICATE_NODES = 500 +/** Max nesting levels; the root group counts as level 1. */ +export const MAX_PREDICATE_DEPTH = 10 + +/** Max total nodes — groups plus leaves — in one predicate tree. */ +export const MAX_PREDICATE_NODES = 500 /** * Returns the predicate size-limit violation for an untrusted tree, if any. From 84e5594ac97df4d164218b6bc70e200f7f887360 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 18:42:55 -0700 Subject: [PATCH 19/56] fix(v2): make upload completion, blank query values, search, and folder filters answer correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four defects on the v2 surface, each reproduced before it was fixed. Upload completion dispatched document indexing from inside the completion transaction, so a queue or processing failure returned 500 after the object was stored, the document row was created, and the session was marked completed — and the only recovery, replaying the request, answered 200. The dispatch is now a follow-on step that runs after the session is durably completed and is logged rather than raised. Its outcome stays visible on the document itself (`failed` with an error, or `pending` when it was never picked up), and the recovery path re-queues a `pending` registration instead of keying off a message left on the session. A query parameter sent with no value was read as `0`, `false`, or the parameter default: `?limit=` became `LIMIT 1` on the three lists that clamp, and `?minCost=` on `/logs` became a live `cost >= 0` filter. `search` and `cursor` already rejected a blank and documented "omit the parameter instead"; that rule now applies to every v2 parameter, enforced on the raw query before coercion so a parameter added later inherits it. The document list matched `_` and `%` in `search` as live LIKE wildcards while every sibling list escaped them through `searchFilter`, so the documented substring match returned everything for `a_itest`. It now uses the same helper. A `folderPath`/`folderPaths` naming no folder answered 404 on `/logs`, `/files`, `/workflows`, `/tables`, and `/knowledge`, while every other filter answers an empty page and the sibling folder lists already do. All five now return an empty page. Mutations keep their 404. --- apps/docs/openapi-v2-billing.json | 2 +- apps/docs/openapi-v2-files-audit.json | 10 +- apps/docs/openapi-v2-knowledge.json | 8 +- apps/docs/openapi-v2-logs.json | 6 +- apps/docs/openapi-v2-resources.json | 2 +- apps/docs/openapi-v2-tables.json | 6 +- apps/docs/openapi-v2-workflows.json | 6 +- apps/sim/app/api/v2/files/route.test.ts | 30 +++++ apps/sim/lib/api/contracts/v2/files.ts | 5 +- apps/sim/lib/api/contracts/v2/knowledge.ts | 3 +- apps/sim/lib/api/contracts/v2/logs.ts | 3 +- .../lib/api/contracts/v2/openapi/knowledge.ts | 2 +- .../lib/api/contracts/v2/openapi/shared.ts | 6 +- apps/sim/lib/api/contracts/v2/shared.ts | 32 ++++- apps/sim/lib/api/contracts/v2/tables.ts | 3 +- apps/sim/lib/api/contracts/v2/workflows.ts | 3 +- apps/sim/lib/api/list-convention.test.ts | 17 +++ .../lib/api/server/blank-query-values.test.ts | 53 +++++++++ apps/sim/lib/api/server/blank-query-values.ts | 47 ++++++++ .../lib/api/server/routes/v2-json-route.ts | 9 ++ apps/sim/lib/api/server/validation.ts | 21 ++++ .../folders/application-folder-caps.test.ts | 76 ++++++++++++ apps/sim/lib/folders/queries.ts | 38 ++++++ .../application/knowledge-bases.test.ts | 25 +++- .../knowledge/application/knowledge-bases.ts | 36 +++--- .../application/upload-sessions.test.ts | 110 ++++++++++++++---- .../knowledge/application/upload-sessions.ts | 105 ++++++++++------- apps/sim/lib/knowledge/documents/service.ts | 3 +- .../lib/logs/application/list-public-logs.ts | 20 ++-- .../application/public-log-use-cases.test.ts | 65 ++++++++--- apps/sim/lib/logs/public-queries.test.ts | 60 +++++++++- apps/sim/lib/logs/public-queries.ts | 32 +++-- apps/sim/lib/table/application/tables.ts | 15 +-- .../workflows/application/list-workflows.ts | 21 ++-- .../application/list-workspace-files.ts | 22 ++-- 35 files changed, 720 insertions(+), 182 deletions(-) create mode 100644 apps/sim/lib/api/server/blank-query-values.test.ts create mode 100644 apps/sim/lib/api/server/blank-query-values.ts diff --git a/apps/docs/openapi-v2-billing.json b/apps/docs/openapi-v2-billing.json index 9f465cd766d..e48b3eeb598 100644 --- a/apps/docs/openapi-v2-billing.json +++ b/apps/docs/openapi-v2-billing.json @@ -306,7 +306,7 @@ }, "responses": { "BadRequest": { - "description": "The request is invalid.", + "description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.", "content": { "application/json": { "schema": { diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index df6f9abbeb1..79f45530f44 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -58,9 +58,9 @@ "name": "folderPath", "in": "query", "required": false, - "description": "Restrict results to files directly inside this folder.", + "description": "Restrict results to files directly inside this folder. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", "schema": { - "description": "Restrict results to files directly inside this folder.", + "description": "Restrict results to files directly inside this folder. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", "type": "string" } }, @@ -68,10 +68,10 @@ "name": "scope", "in": "query", "required": false, - "description": "Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted and that `POST /files/{fileId}/restore` can bring back. `folderPath` resolves against active folders only, so combining it with `scope=archived` returns 404 when the containing folder was archived too.", + "description": "Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted and that `POST /files/{fileId}/restore` can bring back. `folderPath` resolves against active folders only, so combining it with `scope=archived` returns an empty page when the containing folder was archived too.", "schema": { "default": "active", - "description": "Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted and that `POST /files/{fileId}/restore` can bring back. `folderPath` resolves against active folders only, so combining it with `scope=archived` returns 404 when the containing folder was archived too.", + "description": "Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted and that `POST /files/{fileId}/restore` can bring back. `folderPath` resolves against active folders only, so combining it with `scope=archived` returns an empty page when the containing folder was archived too.", "type": "string", "enum": ["active", "archived"] } @@ -2065,7 +2065,7 @@ }, "responses": { "BadRequest": { - "description": "The request is invalid.", + "description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.", "content": { "application/json": { "schema": { diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index cfc45fab919..0db091f9319 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -36,7 +36,7 @@ "get": { "operationId": "listKnowledgeBases", "summary": "List Knowledge Bases", - "description": "List knowledge bases in a workspace with folder filtering, search, and sorting. Paginate with `limit` and `cursor`, stopping when `nextCursor` is null. An unknown `folderPath` is a 404. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", + "description": "List knowledge bases in a workspace with folder filtering, search, and sorting. Paginate with `limit` and `cursor`, stopping when `nextCursor` is null. An unknown `folderPath` returns an empty page. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -54,9 +54,9 @@ "name": "folderPath", "in": "query", "required": false, - "description": "Restrict results to knowledge bases in this folder.", + "description": "Restrict results to knowledge bases in this folder. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", "schema": { - "description": "Restrict results to knowledge bases in this folder.", + "description": "Restrict results to knowledge bases in this folder. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", "type": "string" } }, @@ -2021,7 +2021,7 @@ }, "responses": { "BadRequest": { - "description": "The request is invalid.", + "description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.", "content": { "application/json": { "schema": { diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index 42f7acb51bc..c2c634b6215 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -240,10 +240,10 @@ "name": "folderPaths", "in": "query", "required": false, - "description": "Comma-separated workflow folder paths to include.", + "description": "Comma-separated workflow folder paths to include. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", "schema": { "type": "string", - "description": "Comma-separated workflow folder paths to include." + "description": "Comma-separated workflow folder paths to include. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error." } } ], @@ -423,7 +423,7 @@ }, "responses": { "BadRequest": { - "description": "The request is invalid.", + "description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.", "content": { "application/json": { "schema": { diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 06632c2ce70..f7400b4476d 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -2093,7 +2093,7 @@ }, "responses": { "BadRequest": { - "description": "The request is invalid.", + "description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.", "content": { "application/json": { "schema": { diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 1c73f7e22a4..fff36ea108f 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -54,9 +54,9 @@ "name": "folderPath", "in": "query", "required": false, - "description": "Restrict results to tables in this folder.", + "description": "Restrict results to tables in this folder. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", "schema": { - "description": "Restrict results to tables in this folder.", + "description": "Restrict results to tables in this folder. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", "type": "string" } }, @@ -3793,7 +3793,7 @@ }, "responses": { "BadRequest": { - "description": "The request is invalid.", + "description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.", "content": { "application/json": { "schema": { diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index a6e0fa1f5ae..87bc56ba6e7 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -58,9 +58,9 @@ "name": "folderPath", "in": "query", "required": false, - "description": "Restrict results to workflows in this folder path.", + "description": "Restrict results to workflows in this folder path. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", "schema": { - "description": "Restrict results to workflows in this folder path.", + "description": "Restrict results to workflows in this folder path. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", "type": "string" } }, @@ -2107,7 +2107,7 @@ }, "responses": { "BadRequest": { - "description": "The request is invalid.", + "description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.", "content": { "application/json": { "schema": { diff --git a/apps/sim/app/api/v2/files/route.test.ts b/apps/sim/app/api/v2/files/route.test.ts index b646c18d87b..055987e86ce 100644 --- a/apps/sim/app/api/v2/files/route.test.ts +++ b/apps/sim/app/api/v2/files/route.test.ts @@ -104,6 +104,36 @@ describe('/api/v2/files', () => { expect(mocks.queryFiles).not.toHaveBeenCalled() }) + /** + * `?limit=` is not `limit` omitted. `Number('') === 0`, and this list clamps + * out-of-range values, so the blank used to reach the query as `LIMIT 1` and + * return a single row where the omitted param returns a hundred — a silently + * wrong page, not an error. Whitespace-only is the same value. + */ + it.each(['limit=', 'limit=%20', 'sortBy=', 'cursor='])( + 'rejects the blank query value %s instead of coercing it', + async (param) => { + const response = await GET( + new NextRequest(`http://localhost:3000/api/v2/files?workspaceId=${WORKSPACE_ID}&${param}`) + ) + + expect(response.status).toBe(400) + expect((await response.json()).error.code).toBe('BAD_REQUEST') + expect(mocks.queryFiles).not.toHaveBeenCalled() + } + ) + + it('still applies the documented default when limit is omitted entirely', async () => { + const response = await GET( + new NextRequest(`http://localhost:3000/api/v2/files?workspaceId=${WORKSPACE_ID}`) + ) + + expect(response.status).toBe(200) + expect(mocks.queryFiles).toHaveBeenCalledWith( + expect.objectContaining({ input: expect.objectContaining({ limit: 100 }) }) + ) + }) + it('rejects an unauthenticated request', async () => { v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts index 9d27d322321..da955e668ec 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -9,6 +9,7 @@ import { import { shareAuthTypeSchema, shareRecordSchema } from '@/lib/api/contracts/public-shares' import { defineRouteContract } from '@/lib/api/contracts/types' import { + V2_FOLDER_FILTER_MISS, v2CreateFolderBodySchema, v2CursorListResponse, v2DataResponse, @@ -305,11 +306,11 @@ export const v2ListFilesQuerySchema = z /** Restrict to one file folder. Omit to list the whole workspace. */ folderPath: v2FolderPathInputSchema .optional() - .describe('Restrict results to files directly inside this folder.'), + .describe(`Restrict results to files directly inside this folder. ${V2_FOLDER_FILTER_MISS}`), scope: v2FileScopeSchema .default('active') .describe( - 'Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted and that `POST /files/{fileId}/restore` can bring back. `folderPath` resolves against active folders only, so combining it with `scope=archived` returns 404 when the containing folder was archived too.' + 'Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted and that `POST /files/{fileId}/restore` can bring back. `folderPath` resolves against active folders only, so combining it with `scope=archived` returns an empty page when the containing folder was archived too.' ), search: v2SearchSchema.describe('Case-insensitive substring match against the file name.'), ...v2SortFields(v2FileSortFields, { sortBy: 'uploadedAt', sortOrder: 'asc' }), diff --git a/apps/sim/lib/api/contracts/v2/knowledge.ts b/apps/sim/lib/api/contracts/v2/knowledge.ts index ee0bec07ac4..dae486c8f02 100644 --- a/apps/sim/lib/api/contracts/v2/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/knowledge.ts @@ -17,6 +17,7 @@ import { v1SearchTagFilterSchema, } from '@/lib/api/contracts/v1/knowledge' import { + V2_FOLDER_FILTER_MISS, v2CreateFolderBodySchema, v2CursorListResponse, v2DataResponse, @@ -578,7 +579,7 @@ export const v2ListKnowledgeBasesQuerySchema = z workspaceId: workspaceIdSchema.describe('Workspace whose knowledge bases should be listed.'), folderPath: v2FolderPathInputSchema .optional() - .describe('Restrict results to knowledge bases in this folder.'), + .describe(`Restrict results to knowledge bases in this folder. ${V2_FOLDER_FILTER_MISS}`), search: v2SearchSchema, ...v2SortFields(v2KnowledgeBaseSortFields, { sortBy: 'createdAt', sortOrder: 'asc' }), ...v2PaginationFields({ description: 'Maximum knowledge bases to return per page.' }), diff --git a/apps/sim/lib/api/contracts/v2/logs.ts b/apps/sim/lib/api/contracts/v2/logs.ts index 6d388c424e0..efef3e01eda 100644 --- a/apps/sim/lib/api/contracts/v2/logs.ts +++ b/apps/sim/lib/api/contracts/v2/logs.ts @@ -9,6 +9,7 @@ import { import { defineRouteContract } from '@/lib/api/contracts/types' import { v1ListLogsQuerySchema } from '@/lib/api/contracts/v1/logs' import { + V2_FOLDER_FILTER_MISS, v2CursorListResponse, v2DataResponse, v2FolderPathInputSchema, @@ -255,7 +256,7 @@ export const v2ListLogsQuerySchema = v1ListLogsQuerySchema order: v2RunOrderSchema('execution'), folderPaths: z .string() - .describe('Comma-separated workflow folder paths to include.') + .describe(`Comma-separated workflow folder paths to include. ${V2_FOLDER_FILTER_MISS}`) .optional() .transform((value, ctx) => { if (value === undefined) return undefined diff --git a/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts b/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts index f506e235128..eab0b555758 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts @@ -70,7 +70,7 @@ const routes = [ knowledgeOperation({ operationId: 'listKnowledgeBases', summary: 'List Knowledge Bases', - description: `List knowledge bases in a workspace with folder filtering, search, and sorting. Paginate with \`limit\` and \`cursor\`, stopping when \`nextCursor\` is null. An unknown \`folderPath\` is a 404. ${FOLDER_TREE_TOO_LARGE}`, + description: `List knowledge bases in a workspace with folder filtering, search, and sorting. Paginate with \`limit\` and \`cursor\`, stopping when \`nextCursor\` is null. An unknown \`folderPath\` returns an empty page. ${FOLDER_TREE_TOO_LARGE}`, errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], success: { description: 'A page of knowledge bases.' }, }), diff --git a/apps/sim/lib/api/contracts/v2/openapi/shared.ts b/apps/sim/lib/api/contracts/v2/openapi/shared.ts index e29103601c1..b5cc2663c5e 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/shared.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/shared.ts @@ -45,7 +45,11 @@ const FORBIDDEN_DESCRIPTION = 'The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.' export const ERROR_RESPONSES = { - BadRequest: { status: 400, description: 'The request is invalid.' }, + BadRequest: { + status: 400, + description: + 'The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.', + }, Unauthorized: { status: 401, description: 'The API key is missing or invalid.' }, UsageLimitExceeded: { status: 402, diff --git a/apps/sim/lib/api/contracts/v2/shared.ts b/apps/sim/lib/api/contracts/v2/shared.ts index 47fc81fd2f4..4e281f8d7b2 100644 --- a/apps/sim/lib/api/contracts/v2/shared.ts +++ b/apps/sim/lib/api/contracts/v2/shared.ts @@ -72,7 +72,22 @@ import { FolderPathError, parseFolderPath, requireNonRootFolderPath } from '@/li * from the `sortOrder` *param* on purpose. * - **Filters** — resource-specific and enumerated, reusing the names already * on the surface (`scope`, `folderPath`, `deployedOnly`, `type`, `providerId`, - * `resourceType`). No generic filter expression. + * `resourceType`). No generic filter expression. A filter value that matches + * nothing is an empty page, never an error — including a `folderPath` naming + * no folder ({@link V2_FOLDER_FILTER_MISS}), which used to be this family's + * one 404 and is now the same empty page as `workflowIds` naming no workflow. + * + * ## Blank query values + * + * A param sent with no value (`?limit=`, `?search=`, `?limit=%20`) is a 400 + * naming it. It is not the same request as an omitted param, and no schema can + * see the difference on its own: `z.coerce.number()` reads `''` as `0`, so + * `?limit=` on the lists that clamp became `LIMIT 1` — one row where the omitted + * param gives a hundred — and `?minCost=` on `GET /logs` became a live + * `cost >= 0` filter. `search` and `cursor` already rejected a blank because + * their schemas happened to be strict enough; the rule is enforced for every + * param at the surface instead (`V2_PARSE_DEFAULTS.rejectBlankQueryValues`, + * applied to the raw query before coercion), so a param added later inherits it. * * Every one of these is pushed into SQL, except on `GET /skills` (which narrows the * static builtin registry with the same search term, merges it into the DB rows, @@ -397,6 +412,21 @@ export const v2SearchSchema = z .optional() .describe('Case-insensitive substring search on the resource name.') +/** + * Appended to every list folder-filter description. + * + * A folder filter is a filter: a path naming no active folder narrows the result + * to nothing, exactly as `workflowIds` naming no workflow does. These lists used + * to answer `404 Folder not found` instead, which reported a missing collection + * for a collection that exists, broke a pagination walk when a folder was + * deleted mid-walk, and made a list a folder-existence oracle. The sibling + * folder lists already answered a non-matching `parentPath` with an empty page. + * Mutations keep their 404 — creating into or moving to a folder that does not + * exist has no empty-set reading. + */ +export const V2_FOLDER_FILTER_MISS = + 'A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.' + export const v2SortOrderSchema = z.enum(LIST_SORT_ORDERS).describe('Sort direction.') export type V2SortOrder = ListSortOrder diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index 9d8187d45fc..da8b99c09fd 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -43,6 +43,7 @@ import { v1ListTablesQuerySchema, } from '@/lib/api/contracts/v1/tables' import { + V2_FOLDER_FILTER_MISS, V2_SEARCH_MAX_LENGTH, v2CreateFolderBodySchema, v2CursorListResponse, @@ -338,7 +339,7 @@ export const v2ListTablesQuerySchema = z workspaceId: workspaceIdSchema.describe('Workspace whose tables should be listed.'), folderPath: v2FolderPathInputSchema .optional() - .describe('Restrict results to tables in this folder.'), + .describe(`Restrict results to tables in this folder. ${V2_FOLDER_FILTER_MISS}`), search: v2SearchSchema, ...v2SortFields(v2TableSortFields, { sortBy: 'createdAt', sortOrder: 'asc' }), ...v2PaginationFields({ diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 82ca99ec5fc..d37dcf2887f 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -23,6 +23,7 @@ import { v1WorkflowExportPayloadSchema, } from '@/lib/api/contracts/v1/workflows' import { + V2_FOLDER_FILTER_MISS, v2CreateFolderBodySchema, v2CursorListResponse, v2DataResponse, @@ -132,7 +133,7 @@ export const v2ListWorkflowsQuerySchema = z workspaceId: workspaceIdSchema.describe('Workspace whose workflows should be listed.'), folderPath: v2FolderPathInputSchema .optional() - .describe('Restrict results to workflows in this folder path.'), + .describe(`Restrict results to workflows in this folder path. ${V2_FOLDER_FILTER_MISS}`), deployedOnly: booleanQueryFlagSchema .optional() .default(false) diff --git a/apps/sim/lib/api/list-convention.test.ts b/apps/sim/lib/api/list-convention.test.ts index 83640d97c87..80a8257f2a8 100644 --- a/apps/sim/lib/api/list-convention.test.ts +++ b/apps/sim/lib/api/list-convention.test.ts @@ -55,6 +55,7 @@ vi.mock('@/lib/workflows/skills/builtin-skills', () => ({ import { listVisibleWorkspaceCredentials } from '@/lib/credentials/queries' import { listFoldersForWorkspace } from '@/lib/folders/queries' +import { getDocuments } from '@/lib/knowledge/documents/service' import { getKnowledgeBases } from '@/lib/knowledge/service' import { listWorkspaceMcpServers } from '@/lib/mcp/queries' import { listTables } from '@/lib/table/service' @@ -172,6 +173,22 @@ const CASES: ListCase[] = [ columns: [schemaMock.customTools.title, schemaMock.customTools.id], }, }, + { + name: 'knowledge documents', + column: schemaMock.document.filename, + table: schemaMock.document, + run: ({ search, sortBy, sortOrder }) => + getDocuments( + 'knowledge-1', + { search, sortBy: sortBy as never, sortOrder: sortOrder as never }, + 'request-1' + ), + sort: { + sortBy: 'fileSize', + sortOrder: 'asc', + columns: [schemaMock.document.fileSize, schemaMock.document.filename], + }, + }, { name: 'skills', column: schemaMock.skill.name, diff --git a/apps/sim/lib/api/server/blank-query-values.test.ts b/apps/sim/lib/api/server/blank-query-values.test.ts new file mode 100644 index 00000000000..0b98d8a1adf --- /dev/null +++ b/apps/sim/lib/api/server/blank-query-values.test.ts @@ -0,0 +1,53 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { blankQueryValueValidationError } from '@/lib/api/server/blank-query-values' +import { V2_PARSE_DEFAULTS } from '@/lib/api/server/routes/v2-json-route' + +/** + * A query parameter that is present but blank is a different request from one + * that was omitted, and no schema can tell the difference on its own: coercion + * has already turned `''` into `0`, `false`, or a default before validation + * runs. `?limit=` on the lists that clamp reached SQL as `LIMIT 1`, and + * `?minCost=` on `/logs` became a live `cost >= 0` filter — both silently wrong + * pages rather than errors. + */ +describe('blank query values', () => { + it('rejects an empty value and names the parameter', () => { + const error = blankQueryValueValidationError({ workspaceId: 'workspace-1', limit: '' }) + + expect(error?.issues[0]).toMatchObject({ + path: ['limit'], + message: 'limit cannot be empty; omit the parameter instead', + }) + }) + + it('treats a whitespace-only value the same way', () => { + expect(blankQueryValueValidationError({ limit: ' ' })?.issues[0]?.path).toEqual(['limit']) + expect(blankQueryValueValidationError({ limit: '\t' })?.issues[0]?.path).toEqual(['limit']) + }) + + it('rejects a repeated parameter where any occurrence is blank', () => { + expect(blankQueryValueValidationError({ folderPaths: ['/live', ''] })?.issues[0]?.path).toEqual( + ['folderPaths'] + ) + }) + + it('accepts a query with no blank values, including a literal zero', () => { + expect( + blankQueryValueValidationError({ limit: '0', search: 'a', folderPaths: ['/a', '/b'] }) + ).toBeNull() + expect(blankQueryValueValidationError({})).toBeNull() + }) + + /** + * The rule is a v2-surface default rather than something each route opts into, + * for the same reason the malformed-body envelope is: an opt-in is applied by + * whoever remembered it, and the params this protects are exactly the ones + * nobody thought about. + */ + it('is on for every v2 route through the shared parse defaults', () => { + expect(V2_PARSE_DEFAULTS.rejectBlankQueryValues).toBe(true) + }) +}) diff --git a/apps/sim/lib/api/server/blank-query-values.ts b/apps/sim/lib/api/server/blank-query-values.ts new file mode 100644 index 00000000000..18b5bf03090 --- /dev/null +++ b/apps/sim/lib/api/server/blank-query-values.ts @@ -0,0 +1,47 @@ +import { ZodError } from 'zod' + +/** + * Rejects a query parameter that is present but carries no value — + * `?limit=`, `?limit=%20`, `?search=`. + * + * A blank value is not the same request as an omitted parameter, but nothing in + * a schema makes that true on its own. `z.coerce.number()` reads `''` as `0` + * (`Number('') === 0`), so `?limit=` on the three lists that clamp instead of + * rejecting became `LIMIT 1` — one row where the omitted param gives a hundred. + * `z.coerce.number().optional()` on the `/logs` cost and duration bounds turned + * `?minCost=` into a live `cost >= 0` filter. A plain `z.string()` filter kept + * the `''` and compared against it. Each of those is a different result set from + * the one the caller believed they asked for, and none of them is reported. + * + * The v2 surface already answers a blank the same way wherever a schema happens + * to notice — `search` is `.min(1, 'search cannot be empty')` and `cursor` is + * `.min(1, 'cursor must be a non-empty token')`, both documented as "omit the + * parameter instead". This applies that published rule to every parameter + * rather than to the ones whose schema was written strictly enough, so a + * parameter added later inherits it. + * + * It runs on the *raw* query, before schema validation, because that is the only + * place the blank still exists: coercion has already turned it into `0`, `false`, + * or a default by the time a parsed value is available. + * + * This is a boundary rule rather than a shared string primitive for the same + * reason as the NUL-byte scan next door: a primitive only protects the params + * somebody remembered to build on it. + */ +export function blankQueryValueValidationError( + rawQuery: Record +): ZodError | null { + for (const [name, value] of Object.entries(rawQuery)) { + const values = Array.isArray(value) ? value : [value] + if (!values.some((entry) => entry.trim().length === 0)) continue + return new ZodError([ + { + code: 'custom', + path: [name], + message: `${name} cannot be empty; omit the parameter instead`, + input: undefined, + }, + ]) + } + return null +} diff --git a/apps/sim/lib/api/server/routes/v2-json-route.ts b/apps/sim/lib/api/server/routes/v2-json-route.ts index 9d944914864..af5641ed9c6 100644 --- a/apps/sim/lib/api/server/routes/v2-json-route.ts +++ b/apps/sim/lib/api/server/routes/v2-json-route.ts @@ -140,6 +140,15 @@ export const v2InvalidJsonResponse = () => v2Error('BAD_REQUEST', 'Request body export const V2_PARSE_DEFAULTS = { payloadTooLargeResponse: v2PayloadTooLargeResponse, invalidJsonResponse: v2InvalidJsonResponse, + /** + * `?limit=` is not `limit` omitted, and v2 already says so on the two params + * whose schema happens to catch it: `search` and `cursor` both reject a blank + * and tell the caller to omit the parameter. Applying the rule at the surface + * rather than per schema is what makes it true for every param — including the + * coerced ones, where the blank has already become `0` or a default by the + * time a schema sees the value. + */ + rejectBlankQueryValues: true, } as const export interface V2ErrorPolicy { diff --git a/apps/sim/lib/api/server/validation.ts b/apps/sim/lib/api/server/validation.ts index 4a0f0b0b594..9f890a2d86d 100644 --- a/apps/sim/lib/api/server/validation.ts +++ b/apps/sim/lib/api/server/validation.ts @@ -8,6 +8,7 @@ import type { ContractParams, ContractQuery, } from '@/lib/api/contracts' +import { blankQueryValueValidationError } from '@/lib/api/server/blank-query-values' import { nulByteValidationError } from '@/lib/api/server/nul-bytes' import { env } from '@/lib/core/config/env' import { @@ -62,6 +63,14 @@ export interface ParseRequestOptions { maxBodyBytes?: number /** Treat an absent or whitespace-only body as `undefined` before contract validation. */ optionalJsonBody?: boolean + /** + * Reject a query parameter that is present but blank, instead of letting + * coercion read it as `0`/`false`/the default. See + * {@link blankQueryValueValidationError}. The v2 builders set this for the + * whole v2 surface; it is opt-in so the internal surface, whose own clients + * send blanks today, is unaffected. + */ + rejectBlankQueryValues?: boolean } export function serializeZodIssues(error: z.ZodError): z.core.$ZodIssue[] { @@ -277,6 +286,18 @@ export async function parseRequest( body = parsedBody.data } + if (options?.rejectBlankQueryValues) { + const blank = blankQueryValueValidationError(rawQuery) + if (blank) { + return { + success: false, + response: options.validationErrorResponse + ? options.validationErrorResponse(blank) + : validationErrorResponse(blank), + } + } + } + const params = contract.params ? validateRequestSchema(contract.params, rawParams, options) : undefined diff --git a/apps/sim/lib/folders/application-folder-caps.test.ts b/apps/sim/lib/folders/application-folder-caps.test.ts index 619378c8d02..6324ff75646 100644 --- a/apps/sim/lib/folders/application-folder-caps.test.ts +++ b/apps/sim/lib/folders/application-folder-caps.test.ts @@ -24,6 +24,12 @@ vi.mock('@/lib/folders/queries', () => ({ loadActiveFolderPathIndex: mocks.loadFolderIndex, resolveFolderPathFromIndex: (index: { idByPath: Map }, path: string) => path === '/' ? null : index.idByPath.get(path), + resolveFolderPathFilter: (index: { idByPath: Map }, path: string | undefined) => { + if (path === undefined) return { kind: 'unfiltered' } + if (path === '/') return { kind: 'folder', folderId: null } + const folderId = index.idByPath.get(path) + return folderId === undefined ? { kind: 'noMatch' } : { kind: 'folder', folderId } + }, })) vi.mock('@/lib/workflows/application/context', () => ({ resolveActiveWorkspaceApplicationContext: mocks.resolveWorkflowWorkspace, @@ -176,3 +182,73 @@ describe('workflow and table application folder caps', () => { ) }) }) + +/** + * A `folderPath` that names no active folder is a filter nothing satisfies, not + * a missing collection. Answering `404 Folder not found` made the folder filter + * the only one of each list's filters whose miss was an error rather than an + * empty page, and turned a folder deleted mid-walk into a failed pagination + * loop. The row query must not run at all: without a folder id there is nothing + * to constrain it, so issuing it would return the whole unfiltered set. + */ +describe('a list folder filter that matches no folder', () => { + const MISSING = '/does-not-exist' + + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('read') + mocks.resolveWorkflowWorkspace.mockResolvedValue(context) + mocks.resolveTableWorkspace.mockResolvedValue(context) + mocks.resolveWorkspaceFileWorkspace.mockResolvedValue(context) + mocks.loadFolderIndex.mockResolvedValue(folderIndex) + }) + + it('returns an empty workflow page without querying rows', async () => { + const result = await listWorkflows.execute({ + principal, + input: { + workspaceId: context.workspaceId, + folderPath: MISSING, + deployedOnly: false, + sortBy: 'name', + sortOrder: 'asc', + limit: 25, + }, + }) + + expect(result).toMatchObject({ workflows: [], nextCursorKeys: null }) + expect(mocks.listWorkflows).not.toHaveBeenCalled() + }) + + it('returns an empty table page without querying rows', async () => { + const result = await listTablesUseCase.execute({ + principal, + input: { + workspaceId: context.workspaceId, + folderPath: MISSING, + sortBy: 'name', + sortOrder: 'asc', + limit: 25, + }, + }) + + expect(result).toMatchObject({ tables: [], nextKeys: null }) + expect(mocks.listTables).not.toHaveBeenCalled() + }) + + it('returns an empty file page without querying rows', async () => { + const result = await queryWorkspaceFilePage.execute({ + principal, + input: { + workspaceId: context.workspaceId, + folderPath: MISSING, + sortBy: 'name', + sortOrder: 'asc', + limit: 25, + }, + }) + + expect(result).toMatchObject({ files: [], nextKeys: null }) + expect(mocks.queryWorkspaceFiles).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/folders/queries.ts b/apps/sim/lib/folders/queries.ts index 6bc402b68aa..dda1f63b436 100644 --- a/apps/sim/lib/folders/queries.ts +++ b/apps/sim/lib/folders/queries.ts @@ -265,6 +265,44 @@ export function resolveFolderPathFromIndex( return path === ROOT_FOLDER_PATH ? null : index.idByPath.get(path) } +/** + * A list's `folderPath` filter, resolved against the workspace's active folders. + * + * `unfiltered` is an omitted param, `folder` names one folder (`null` being the + * workspace root), and `noMatch` is a path that names no active folder. + */ +export type FolderPathFilter = + | { kind: 'unfiltered' } + | { kind: 'folder'; folderId: string | null } + | { kind: 'noMatch' } + +/** + * Resolves a list's `folderPath` filter, treating a path that names no active + * folder as a filter nothing satisfies rather than as a missing resource. + * + * A list is a collection, and every other filter it accepts answers a value + * nothing matches with an empty page — `workflowIds` naming no workflow and + * `model` naming no model both return zero rows. Answering `404 Folder not + * found` only on the folder filter made one filter's miss a different kind of + * event from all the others, told a caller its *collection* was missing when it + * was not, turned a folder deleted mid-walk into a failed pagination loop, and + * answered whether a path exists on an endpoint that was not asked. The sibling + * folder lists already answer a non-matching `parentPath` with an empty page, so + * this is the family's existing behavior applied to the resource lists too. + * + * A path that could not name a folder at all is still rejected by the contract, + * as a 400, before any of this runs. Mutations keep their 404: creating into or + * moving to a folder that does not exist has no empty-set reading. + */ +export function resolveFolderPathFilter( + index: FolderPathIndex, + path: string | undefined +): FolderPathFilter { + if (path === undefined) return { kind: 'unfiltered' } + const folderId = resolveFolderPathFromIndex(index, path) + return folderId === undefined ? { kind: 'noMatch' } : { kind: 'folder', folderId } +} + export async function listActiveFolderRows( workspaceId: string, resourceType: FolderResourceType, diff --git a/apps/sim/lib/knowledge/application/knowledge-bases.test.ts b/apps/sim/lib/knowledge/application/knowledge-bases.test.ts index f3328157bae..678f5205498 100644 --- a/apps/sim/lib/knowledge/application/knowledge-bases.test.ts +++ b/apps/sim/lib/knowledge/application/knowledge-bases.test.ts @@ -52,6 +52,12 @@ vi.mock('@/lib/core/telemetry', () => ({ vi.mock('@/lib/folders/queries', () => ({ loadActiveFolderPathIndex: mocks.loadFolderIndex, + resolveFolderPathFilter: (index: { idByPath: Map }, path: string | undefined) => { + if (path === undefined) return { kind: 'unfiltered' } + if (path === '/') return { kind: 'folder', folderId: null } + const folderId = index.idByPath.get(path) + return folderId === undefined ? { kind: 'noMatch' } : { kind: 'folder', folderId } + }, })) vi.mock('@/lib/knowledge/application/contexts', () => ({ @@ -94,6 +100,7 @@ import { listArchivedKnowledgeBases, listInternalKnowledgeBases, listKnowledgeBaseCatalog, + listKnowledgeBases, readInternalKnowledgeBase, readKnowledgeBase, restoreInternalKnowledgeBase, @@ -141,7 +148,7 @@ describe('knowledge base application use cases', () => { folderId: null, index: { pathById: new Map(), idByPath: new Map(), rowById: new Map() }, }) - mocks.loadFolderIndex.mockResolvedValue({ pathById: new Map() }) + mocks.loadFolderIndex.mockResolvedValue({ pathById: new Map(), idByPath: new Map() }) mocks.createRecord.mockResolvedValue(knowledgeBase) mocks.listRecords.mockResolvedValue({ data: [], nextCursorKeys: null }) mocks.listInternalRecords.mockResolvedValue([knowledgeBase]) @@ -157,6 +164,22 @@ describe('knowledge base application use cases', () => { mocks.deleteRecord.mockResolvedValue(undefined) }) + /** + * The folder filter is a filter like any other: a path naming no active folder + * narrows the list to nothing instead of failing it. Reaching the row query + * with no folder id would return every knowledge base in the workspace. + */ + it('returns an empty page for a folder path that matches no folder', async () => { + const result = await listKnowledgeBases.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'workspace-1', folderPath: '/does-not-exist' }, + }) + + expect(result).toMatchObject({ knowledgeBases: [], nextCursorKeys: null }) + expect(mocks.listRecords).not.toHaveBeenCalled() + expect(mocks.resolveFolderPath).not.toHaveBeenCalled() + }) + it('lists legacy personal knowledge bases through the explicit session-only operation', async () => { await expect( listInternalKnowledgeBases.execute({ diff --git a/apps/sim/lib/knowledge/application/knowledge-bases.ts b/apps/sim/lib/knowledge/application/knowledge-bases.ts index 9fd5ba27b57..bd3de62faa1 100644 --- a/apps/sim/lib/knowledge/application/knowledge-bases.ts +++ b/apps/sim/lib/knowledge/application/knowledge-bases.ts @@ -14,7 +14,7 @@ import { import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestration/types' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' -import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { loadActiveFolderPathIndex, resolveFolderPathFilter } from '@/lib/folders/queries' import { knowledgeDelegationPolicy } from '@/lib/knowledge/application/authorization' import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case' import { @@ -229,22 +229,28 @@ async function executeListKnowledgeBases(args: { context: KnowledgeWorkspaceContext }): Promise { /** - * The folder index renders each row's `folderPath` and the folder filter - * resolves the caller's `folderPath` to an id. Neither reads the other, so - * they run together rather than adding a serial round-trip to a list route. + * One index read serves both jobs: rendering each row's `folderPath` and + * resolving the caller's `folderPath` filter to an id. The list previously + * paid for a second, lock-taking read for the filter alone, which it needed + * only to raise the 404 this list no longer answers. */ - const [index, folderId] = await Promise.all([ - loadActiveFolderPathIndex(args.context.workspaceId, 'knowledge_base', undefined, { - maxRows: MAX_KNOWLEDGE_FOLDERS_PER_WORKSPACE, - }), - args.input.folderPath === undefined - ? undefined - : resolveKnowledgeFolderPath(args.context.workspaceId, args.input.folderPath).then( - (resolved) => resolved.folderId - ), - ]) + const index = await loadActiveFolderPathIndex( + args.context.workspaceId, + 'knowledge_base', + undefined, + { maxRows: MAX_KNOWLEDGE_FOLDERS_PER_WORKSPACE } + ) + const folderFilter = resolveFolderPathFilter(index, args.input.folderPath) + if (folderFilter.kind === 'noMatch') { + return { + knowledgeBases: [], + nextCursorKeys: null, + sortBy: args.input.sortBy ?? 'createdAt', + sortOrder: args.input.sortOrder ?? 'asc', + } + } const page = await getWorkspaceKnowledgeBases(args.context.workspaceId, 'active', { - folderId, + folderId: folderFilter.kind === 'folder' ? folderFilter.folderId : undefined, search: args.input.search, sortBy: args.input.sortBy, sortOrder: args.input.sortOrder, diff --git a/apps/sim/lib/knowledge/application/upload-sessions.test.ts b/apps/sim/lib/knowledge/application/upload-sessions.test.ts index e4b43e9cf83..9479e1919bb 100644 --- a/apps/sim/lib/knowledge/application/upload-sessions.test.ts +++ b/apps/sim/lib/knowledge/application/upload-sessions.test.ts @@ -394,43 +394,105 @@ describe('knowledge-document upload application lifecycle', () => { expect(mocks.recordAudit).not.toHaveBeenCalled() }) - it('fails completion when document processing cannot be dispatched', async () => { - const failure = new Error('queue unavailable') - mocks.processQueue.mockRejectedValue(failure) + /** + * The registration is durable before indexing is queued, so a queue that is + * down cannot un-create the document. Failing the call reports a completion + * that did happen as a 500, and the only recovery a caller has — replaying the + * same request — answers `200 completed`. + */ + it('completes and audits the upload when processing cannot be dispatched', async () => { + mocks.processQueue.mockRejectedValue(new Error('queue unavailable')) mocks.completeUpload.mockImplementation( async (params: { session: UploadSessionRecord - finalize: (session: UploadSessionRecord) => Promise - }) => params.finalize(params.session) + finalize: (session: UploadSessionRecord) => Promise<{ + value: { document: typeof DOCUMENT; created: boolean; knowledgeBaseName: string | null } + completedFileId?: string + }> + }) => { + const finalized = await params.finalize(params.session) + return { + session: { ...params.session, status: 'completed' as const }, + value: finalized.value, + alreadyCompleted: false, + } + } ) - await expect( - completeKnowledgeDocumentUpload.execute({ - principal: PRINCIPAL, - input: { - knowledgeBaseId: 'knowledge-1', - assertedWorkspaceId: 'workspace-1', - uploadId: 'upload-1', - uploadToken: 'token', - source: 'api', - }, - request: REQUEST, - }) - ).rejects.toMatchObject({ - name: 'KnowledgeDocumentProcessingDispatchError', - message: 'Knowledge document processing dispatch failed', - cause: failure, + const result = await completeKnowledgeDocumentUpload.execute({ + principal: PRINCIPAL, + input: { + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-1', + uploadId: 'upload-1', + uploadToken: 'token', + source: 'api', + }, + request: REQUEST, }) + + expect(result.value.created).toBe(true) + expect(result.value.document).toEqual(DOCUMENT) expect(mocks.createDocument).toHaveBeenCalledTimes(1) - expect(mocks.recordAudit).not.toHaveBeenCalled() + expect(mocks.processQueue).toHaveBeenCalledTimes(1) + expect(mocks.recordAudit).toHaveBeenCalledTimes(1) + }) + + /** + * The dispatch is a follow-on to the completion, not a step inside it: a + * completion that cannot write its durable marker must not have queued + * indexing for a document the caller was told nothing about. + */ + it('queues processing only after the session is durably completed', async () => { + const order: string[] = [] + mocks.processQueue.mockImplementation(async () => { + order.push('dispatch') + }) + mocks.completeUpload.mockImplementation( + async (params: { + session: UploadSessionRecord + finalize: (session: UploadSessionRecord) => Promise<{ + value: { document: typeof DOCUMENT; created: boolean; knowledgeBaseName: string | null } + completedFileId?: string + }> + }) => { + const finalized = await params.finalize(params.session) + order.push('completed') + return { + session: { ...params.session, status: 'completed' as const }, + value: finalized.value, + alreadyCompleted: false, + } + } + ) + + await completeKnowledgeDocumentUpload.execute({ + principal: PRINCIPAL, + input: { + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-1', + uploadId: 'upload-1', + uploadToken: 'token', + source: 'api', + }, + request: REQUEST, + }) + + expect(order).toEqual(['completed', 'dispatch']) }) - it('retries a failed processing dispatch before completing a bound registration', async () => { + /** + * The re-queue is decided by the document — a registration still `pending` was + * never picked up — rather than by the message a previous failure happened to + * leave on the session. The session no longer carries one: a dispatch failure + * completes the session and is logged, so keying recovery off `session.error` + * would leave a `pending` document with nothing to re-queue it. + */ + it('re-queues a bound registration whose document was never picked up', async () => { const recoveringSession = { ...SESSION, status: 'finalizing' as const, completedFileId: null, - error: 'Knowledge document processing dispatch failed', } mocks.getUpload.mockResolvedValue(recoveringSession) mocks.findBound.mockResolvedValue({ diff --git a/apps/sim/lib/knowledge/application/upload-sessions.ts b/apps/sim/lib/knowledge/application/upload-sessions.ts index 73526ddf1f5..29226a442f8 100644 --- a/apps/sim/lib/knowledge/application/upload-sessions.ts +++ b/apps/sim/lib/knowledge/application/upload-sessions.ts @@ -1,5 +1,7 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import type { Principal } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { checkAttributedUsageLimits } from '@/lib/billing/core/billing-attribution' import { authorizeWorkspaceOperation, type WorkspaceOperation } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -40,14 +42,9 @@ import { } from '@/lib/uploads/upload-session/service' import { validateFileType } from '@/lib/uploads/utils/validation' -const PROCESSING_DISPATCH_FAILURE_MESSAGE = 'Knowledge document processing dispatch failed' +const logger = createLogger('KnowledgeUploadSessions') -class KnowledgeDocumentProcessingDispatchError extends Error { - constructor(cause: unknown) { - super(PROCESSING_DISPATCH_FAILURE_MESSAGE, { cause }) - this.name = 'KnowledgeDocumentProcessingDispatchError' - } -} +const PROCESSING_DISPATCH_FAILURE_MESSAGE = 'Knowledge document processing dispatch failed' export class KnowledgeDocumentUnsupportedMediaTypeError extends Error { constructor(message: string) { @@ -209,6 +206,13 @@ export const completeKnowledgeDocumentUpload = defineAuthorizedKnowledgeUseCase( const requestId = generateRequestId() const recoveringUnprojectedRegistration = session.status === 'finalizing' && session.completedFileId === null + /** + * Filled by whichever completion branch establishes a document that still + * needs indexing, and acted on only after the session is durably completed. + * Registration and dispatch are two different transactions: the first must + * commit even when the second cannot run. + */ + let pendingDispatch: PendingProcessingDispatch | null = null const result = await completeUploadSession({ session, loadCompleted: async (claimed) => { @@ -261,21 +265,13 @@ export const completeKnowledgeDocumentUpload = defineAuthorizedKnowledgeUseCase( ) } if (bound.status === 'bound') { - if ( - session.error === PROCESSING_DISPATCH_FAILURE_MESSAGE && - bound.document.processingStatus === 'pending' - ) { - const billingAttribution = await resolveKnowledgeBillingAttribution( - principal, - freshContext - ) - await dispatchKnowledgeDocumentProcessing( - bound.document, - freshContext.knowledgeBaseId, + if (bound.document.processingStatus === 'pending') { + pendingDispatch = { + document: bound.document, + knowledgeBaseId: freshContext.knowledgeBaseId, processingOptions, - requestId, - billingAttribution - ) + billingAttribution: await resolveKnowledgeBillingAttribution(principal, freshContext), + } } return { value: { @@ -339,13 +335,12 @@ export const completeKnowledgeDocumentUpload = defineAuthorizedKnowledgeUseCase( throw error } - await dispatchKnowledgeDocumentProcessing( - created, - registrationContext.knowledgeBaseId, + pendingDispatch = { + document: created, + knowledgeBaseId: registrationContext.knowledgeBaseId, processingOptions, - requestId, - billingAttribution - ) + billingAttribution, + } return { value: { document: created, @@ -356,6 +351,7 @@ export const completeKnowledgeDocumentUpload = defineAuthorizedKnowledgeUseCase( } }, }) + if (pendingDispatch) await queueKnowledgeDocumentProcessing(pendingDispatch, requestId) return { ...result, workspaceId: context.workspaceId, @@ -383,30 +379,57 @@ export const completeKnowledgeDocumentUpload = defineAuthorizedKnowledgeUseCase( }, }) -async function dispatchKnowledgeDocumentProcessing( - document: CreatedKnowledgeDocument, - knowledgeBaseId: string, - processingOptions: KnowledgeDocumentUploadMetadata['processingOptions'], - requestId: string, +/** Everything the indexing dispatch needs, captured while the completion still holds its lease. */ +interface PendingProcessingDispatch { + document: CreatedKnowledgeDocument + knowledgeBaseId: string + processingOptions: KnowledgeDocumentUploadMetadata['processingOptions'] billingAttribution: Awaited> +} + +/** + * Queues indexing for a document the completion has already made durable. + * + * It runs after `completeUploadSession` resolves, and a failure is logged + * rather than raised, because by that point the caller's request has already + * succeeded: the object is stored, the document row exists, and the session is + * marked completed. Raising here used to fail the completion `POST` with a 500 + * after all of that had committed, and the caller's only recovery — replaying + * the same request — answered `200 completed`, so the 500 described nothing the + * caller could act on. + * + * The dispatch outcome is not lost by being swallowed. `processDocumentsWithQueue` + * marks the document `failed` with its error when processing itself breaks, and + * a document that was never picked up stays `pending`; both are visible on the + * document the completion returns and on every subsequent read of it. A + * `pending` document is re-queued by the finalization-recovery path above. + */ +async function queueKnowledgeDocumentProcessing( + dispatch: PendingProcessingDispatch, + requestId: string ): Promise { const processingDocument: DocumentData = { - documentId: document.id, - filename: document.filename, - fileUrl: document.fileUrl, - fileSize: document.fileSize, - mimeType: document.mimeType, + documentId: dispatch.document.id, + filename: dispatch.document.filename, + fileUrl: dispatch.document.fileUrl, + fileSize: dispatch.document.fileSize, + mimeType: dispatch.document.mimeType, } try { await processDocumentsWithQueue( [processingDocument], - knowledgeBaseId, - processingOptions ?? {}, + dispatch.knowledgeBaseId, + dispatch.processingOptions ?? {}, requestId, - billingAttribution + dispatch.billingAttribution ) } catch (error) { - throw new KnowledgeDocumentProcessingDispatchError(error) + logger.error(PROCESSING_DISPATCH_FAILURE_MESSAGE, { + requestId, + documentId: dispatch.document.id, + knowledgeBaseId: dispatch.knowledgeBaseId, + error: getErrorMessage(error), + }) } } diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index 1c57c07fb4a..105935e945c 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -26,6 +26,7 @@ import { type SQL, sql, } from 'drizzle-orm' +import { searchFilter } from '@/lib/api/list-query' import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor' import { assertBillingAttributionSnapshot, @@ -1683,7 +1684,7 @@ export async function getDocuments( } if (search) { - whereConditions.push(sql`LOWER(${document.filename}) LIKE LOWER(${`%${search}%`})`) + whereConditions.push(searchFilter(document.filename, search)) } if (tagFilters && tagFilters.length > 0) { diff --git a/apps/sim/lib/logs/application/list-public-logs.ts b/apps/sim/lib/logs/application/list-public-logs.ts index 12b0c062c6a..6b5ce4e9ea4 100644 --- a/apps/sim/lib/logs/application/list-public-logs.ts +++ b/apps/sim/lib/logs/application/list-public-logs.ts @@ -1,8 +1,7 @@ import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency' -import { ROOT_FOLDER_PATH } from '@/lib/folders/paths' -import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { loadActiveFolderPathIndex, resolveFolderPathFilter } from '@/lib/folders/queries' import { logOperations } from '@/lib/logs/application/operations' import { materializeExecutionDataForDisplay } from '@/lib/logs/execution/trace-store' import type { LogFilters } from '@/lib/logs/public-filters' @@ -46,12 +45,17 @@ export const listPublicLogs = defineAuthorizedWorkspaceUseCase({ const folderIndex = input.folderPaths ? await loadActiveFolderPathIndex(context.workspaceId, 'workflow') : null - const resolvedFolderIds = input.folderPaths?.map((path) => - path === ROOT_FOLDER_PATH ? null : folderIndex?.idByPath.get(path) - ) - if (resolvedFolderIds?.some((folderId) => folderId === undefined)) { - throw new OrchestrationError('not_found', 'Folder not found') - } + /** + * A path naming no active folder contributes nothing to the scope instead of + * failing the read, so `folderPaths=/live,/deleted` still returns the `/live` + * runs and `folderPaths=/deleted` alone returns an empty page. See + * {@link resolveFolderPathFilter} for why a filter's miss is an empty set. + */ + const resolvedFolderIds = input.folderPaths?.flatMap((path) => { + if (!folderIndex) return [] + const filter = resolveFolderPathFilter(folderIndex, path) + return filter.kind === 'folder' ? [filter.folderId] : [] + }) const folderIds = resolvedFolderIds?.filter( (folderId): folderId is string => typeof folderId === 'string' diff --git a/apps/sim/lib/logs/application/public-log-use-cases.test.ts b/apps/sim/lib/logs/application/public-log-use-cases.test.ts index 7c3e2df47bd..d30000748dd 100644 --- a/apps/sim/lib/logs/application/public-log-use-cases.test.ts +++ b/apps/sim/lib/logs/application/public-log-use-cases.test.ts @@ -33,6 +33,12 @@ vi.mock('@/lib/logs/public-queries', () => ({ vi.mock('@/lib/folders/queries', () => ({ loadActiveFolderPathIndex: mocks.loadFolders, + resolveFolderPathFilter: (index: { idByPath: Map }, path: string | undefined) => { + if (path === undefined) return { kind: 'unfiltered' } + if (path === '/') return { kind: 'folder', folderId: null } + const folderId = index.idByPath.get(path) + return folderId === undefined ? { kind: 'noMatch' } : { kind: 'folder', folderId } + }, })) /** @@ -290,23 +296,50 @@ describe('public log application use cases', () => { expect(result.items).toHaveLength(1) }) - it('returns a typed not-found for a missing folder', async () => { - await expect( - listPublicLogs.execute({ - principal: workspacePrincipal, - input: { - workspaceId: 'workspace-1', - filters: {}, - folderPaths: ['/missing'], - limit: 50, - includeFullDetails: false, - includeFinalOutput: false, - includeTraceSpans: false, - }, - }) - ).rejects.toMatchObject({ code: 'not_found' }) + /** + * Every other `/logs` filter answers a value nothing matches with an empty + * page, and this one used to answer `404 Folder not found` — which also made + * the list a folder-existence oracle. The scope must still reach the query: + * dropping the unresolved path and sending no scope at all would return the + * whole workspace's logs. + */ + it('returns an empty page for a folder path that matches nothing', async () => { + const result = await listPublicLogs.execute({ + principal: workspacePrincipal, + input: { + workspaceId: 'workspace-1', + filters: {}, + folderPaths: ['/missing'], + limit: 50, + includeFullDetails: false, + includeFinalOutput: false, + includeTraceSpans: false, + }, + }) + + expect(mocks.listLogs).toHaveBeenCalledWith( + expect.objectContaining({ folderScope: { includesRoot: false, folderIds: [] } }) + ) + expect(result.nextCursor).toBeNull() + }) + + it('keeps the folders that do resolve when one path in the set does not', async () => { + await listPublicLogs.execute({ + principal: workspacePrincipal, + input: { + workspaceId: 'workspace-1', + filters: {}, + folderPaths: ['/agents', '/missing'], + limit: 50, + includeFullDetails: false, + includeFinalOutput: false, + includeTraceSpans: false, + }, + }) - expect(mocks.listLogs).not.toHaveBeenCalled() + expect(mocks.listLogs).toHaveBeenCalledWith( + expect.objectContaining({ folderScope: { includesRoot: false, folderIds: ['folder-1'] } }) + ) }) it('propagates run-store failures', async () => { diff --git a/apps/sim/lib/logs/public-queries.test.ts b/apps/sim/lib/logs/public-queries.test.ts index 499bac77ac0..014ae6f97c1 100644 --- a/apps/sim/lib/logs/public-queries.test.ts +++ b/apps/sim/lib/logs/public-queries.test.ts @@ -1,8 +1,19 @@ /** * @vitest-environment node */ -import { describe, expect, it } from 'vitest' -import { decodePublicLogCursor, encodePublicLogCursor } from '@/lib/logs/public-queries' +import { + dbChainMockFns, + flattenMockConditions, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { + decodePublicLogCursor, + encodePublicLogCursor, + listPublicWorkflowLogs, +} from '@/lib/logs/public-queries' describe('public log cursor', () => { const cursor = { @@ -28,3 +39,48 @@ describe('public log cursor', () => { expect(decodePublicLogCursor(legacyCursor, 'asc')).toEqual({ ...cursor, order: 'asc' }) }) }) + +/** + * The folder scope is resolved by the adapter, so this query sees only ids. A + * scope that resolved to nothing has to be expressed as a predicate that matches + * nothing: `or(undefined, undefined)` is `undefined`, which silently drops the + * filter and returns the workspace's whole log set. + */ +describe('public workflow log folder scope', () => { + const lastWhere = () => flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0]) + const isUnsatisfiable = (node: Record) => + (node.strings as readonly string[] | undefined)?.[0] === 'false' + + beforeEach(() => { + resetDbChainMock() + queueTableRows(schemaMock.workflowExecutionLogs, []) + }) + + async function list(folderScope?: { includesRoot: boolean; folderIds: string[] }) { + await listPublicWorkflowLogs({ + filters: { workspaceId: 'workspace-1' }, + limit: 50, + includeExecutionData: false, + folderScope, + }) + } + + it('matches no rows when the scope names neither the root nor a folder', async () => { + await list({ includesRoot: false, folderIds: [] }) + + expect(lastWhere().some(isUnsatisfiable)).toBe(true) + }) + + it('constrains to the resolved folders when the scope names some', async () => { + await list({ includesRoot: false, folderIds: ['folder-1'] }) + + expect(lastWhere().some(isUnsatisfiable)).toBe(false) + expect(lastWhere().some((node) => node.type === 'inArray')).toBe(true) + }) + + it('adds no folder predicate when the caller sent no folder filter', async () => { + await list() + + expect(lastWhere().some(isUnsatisfiable)).toBe(false) + }) +}) diff --git a/apps/sim/lib/logs/public-queries.ts b/apps/sim/lib/logs/public-queries.ts index 25bc964f92d..ada0d01b73d 100644 --- a/apps/sim/lib/logs/public-queries.ts +++ b/apps/sim/lib/logs/public-queries.ts @@ -7,7 +7,7 @@ import { workflowExecutionLogs, workflowExecutionSnapshots, } from '@sim/db/schema' -import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm' +import { and, eq, inArray, isNull, or, type SQL, sql } from 'drizzle-orm' import { workflowExecutionOriginSql } from '@/lib/logs/execution-origin' import { buildLogFilters, getOrderBy, type LogFilters } from '@/lib/logs/public-filters' @@ -54,6 +54,27 @@ export interface ListPublicWorkflowLogsInput { } } +/** + * The root/non-root predicate for a resolved folder scope. + * + * A scope carrying neither the root nor any folder id is a `folderPaths` filter + * that matched no active folder, and it must match no rows — hence the explicit + * unsatisfiable predicate. Building it by `or`-ing two optional halves instead + * would hand the empty case to `or(undefined, undefined)`, which is `undefined` + * in Drizzle: the filter drops out of the surrounding `and(...)` and the query + * returns the workspace's entire log set, the exact opposite of what was asked. + */ +function folderScopeCondition(scope: { includesRoot: boolean; folderIds: string[] }): SQL { + const parts = [ + scope.includesRoot ? isNull(workflow.folderId) : undefined, + scope.folderIds.length > 0 ? inArray(workflow.folderId, scope.folderIds) : undefined, + ].filter((part): part is SQL => part !== undefined) + + if (parts.length === 0) return sql`false` + if (parts.length === 1) return parts[0] + return or(...parts) ?? sql`false` +} + /** * Reads the workflow-execution log page shared by the v1 and v2 public * adapters. Folder path resolution remains an adapter concern; this query takes @@ -62,14 +83,7 @@ export interface ListPublicWorkflowLogsInput { export async function listPublicWorkflowLogs(input: ListPublicWorkflowLogsInput) { const filters = input.folderScope ? { ...input.filters, folderIds: undefined } : input.filters const conditions = buildLogFilters(filters) - const folderCondition = input.folderScope - ? or( - input.folderScope.includesRoot ? isNull(workflow.folderId) : undefined, - input.folderScope.folderIds.length > 0 - ? inArray(workflow.folderId, input.folderScope.folderIds) - : undefined - ) - : undefined + const folderCondition = input.folderScope ? folderScopeCondition(input.folderScope) : undefined const rows = await db .select({ diff --git a/apps/sim/lib/table/application/tables.ts b/apps/sim/lib/table/application/tables.ts index 8f904d3b22c..466463ddadb 100644 --- a/apps/sim/lib/table/application/tables.ts +++ b/apps/sim/lib/table/application/tables.ts @@ -5,7 +5,7 @@ import type { CursorKey, ListSortOrder } from '@/lib/api/list-query' import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' -import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { loadActiveFolderPathIndex, resolveFolderPathFilter } from '@/lib/folders/queries' import { createTable, deleteTable, @@ -45,18 +45,13 @@ export const listTablesUseCase = defineAuthorizedTableUseCase({ const folderIndex = await loadActiveFolderPathIndex(context.workspaceId, 'table', undefined, { maxRows: MAX_FOLDERS_PER_WORKSPACE, }) - const folderId = - input.folderPath === undefined - ? undefined - : input.folderPath === '/' - ? null - : folderIndex.idByPath.get(input.folderPath) - if (input.folderPath !== undefined && folderId === undefined) { - throw new OrchestrationError('not_found', 'Folder not found') + const folderFilter = resolveFolderPathFilter(folderIndex, input.folderPath) + if (folderFilter.kind === 'noMatch') { + return { tables: [], nextKeys: null, sortBy: input.sortBy, sortOrder: input.sortOrder } } const { tables, nextKeys } = await queryTables(context.workspaceId, { - folderId, + folderId: folderFilter.kind === 'folder' ? folderFilter.folderId : undefined, search: input.search, sortBy: input.sortBy, sortOrder: input.sortOrder, diff --git a/apps/sim/lib/workflows/application/list-workflows.ts b/apps/sim/lib/workflows/application/list-workflows.ts index cf92b3863f2..9257735ec80 100644 --- a/apps/sim/lib/workflows/application/list-workflows.ts +++ b/apps/sim/lib/workflows/application/list-workflows.ts @@ -1,8 +1,7 @@ import { createLogger } from '@sim/logger' import type { CursorKey } from '@/lib/api/list-query' -import { OrchestrationError } from '@/lib/core/orchestration/types' import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' -import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { loadActiveFolderPathIndex, resolveFolderPathFilter } from '@/lib/folders/queries' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' import { resolveActiveWorkspaceApplicationContext } from '@/lib/workflows/application/context' import { workflowOperations } from '@/lib/workflows/application/operations' @@ -37,19 +36,19 @@ export const listWorkflows = defineAuthorizedWorkflowUseCase({ undefined, { maxRows: MAX_FOLDERS_PER_WORKSPACE } ) - const folderId = - input.folderPath === undefined - ? undefined - : input.folderPath === '/' - ? null - : folderIndex.idByPath.get(input.folderPath) - if (input.folderPath !== undefined && folderId === undefined) { - throw new OrchestrationError('not_found', 'Folder not found') + const folderFilter = resolveFolderPathFilter(folderIndex, input.folderPath) + if (folderFilter.kind === 'noMatch') { + return { + workflows: [], + nextCursorKeys: null, + sortBy: input.sortBy, + sortOrder: input.sortOrder, + } } const page = await listWorkspaceWorkflows({ workspaceId: context.workspaceId, - folderId, + folderId: folderFilter.kind === 'folder' ? folderFilter.folderId : undefined, deployedOnly: input.deployedOnly, search: input.search, sortBy: input.sortBy, diff --git a/apps/sim/lib/workspace-files/application/list-workspace-files.ts b/apps/sim/lib/workspace-files/application/list-workspace-files.ts index 2358e52f6f5..ac855b1ed57 100644 --- a/apps/sim/lib/workspace-files/application/list-workspace-files.ts +++ b/apps/sim/lib/workspace-files/application/list-workspace-files.ts @@ -1,8 +1,7 @@ import type { CursorKey } from '@/lib/api/list-query' import { OrchestrationError } from '@/lib/core/orchestration/types' import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' -import { ROOT_FOLDER_PATH } from '@/lib/folders/paths' -import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { loadActiveFolderPathIndex, resolveFolderPathFilter } from '@/lib/folders/queries' import { getWorkspaceShares } from '@/lib/public-shares/share-manager' import { listWorkspaceFiles, @@ -57,26 +56,19 @@ export const queryWorkspaceFilePage = defineAuthorizedWorkspaceFileUseCase({ * Capped the way the workflow, table, and knowledge lists cap theirs. A * truncated index does not fail — it silently loses paths, and the only * consumer here is the `folderPath` filter, so a real folder outside the - * read rows resolves to `undefined` and the caller gets "Folder not found" - * for a folder that exists. The cap turns that into the same 413 the - * sibling lists answer. + * read rows would resolve to nothing and the caller would get an empty page + * for a folder that has files in it. The cap turns that into the same 413 + * the sibling lists answer. */ const folderIndex = await loadActiveFolderPathIndex(context.workspaceId, 'file', undefined, { maxRows: MAX_FOLDERS_PER_WORKSPACE, }) - const folderId = - input.folderPath === undefined - ? undefined - : input.folderPath === ROOT_FOLDER_PATH - ? null - : folderIndex.idByPath.get(input.folderPath) - if (input.folderPath !== undefined && folderId === undefined) { - throw new OrchestrationError('not_found', 'Folder not found') - } + const folderFilter = resolveFolderPathFilter(folderIndex, input.folderPath) + if (folderFilter.kind === 'noMatch') return { files: [], nextKeys: null } const { files, nextKeys } = await queryWorkspaceFiles(context.workspaceId, { scope: input.scope, - folderId, + folderId: folderFilter.kind === 'folder' ? folderFilter.folderId : undefined, search: input.search, sortBy: input.sortBy, sortOrder: input.sortOrder, From b50f4788199a0f60ab818909da394d0ef5b22127 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 18:46:06 -0700 Subject: [PATCH 20/56] chore(v2): regenerate the specs from the merged sources The four spec conflicts in the wave-3 merge were resolved by taking one side, which left them describing neither branch. Regenerated so the published documents match the contracts they are built from. --- apps/docs/openapi-v2-files-audit.json | 10 +++++----- apps/docs/openapi-v2-knowledge.json | 8 ++++---- apps/docs/openapi-v2-tables.json | 6 +++--- apps/docs/openapi-v2-workflows.json | 6 +++--- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index eaaf52c9eb2..447ccfea359 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -58,9 +58,9 @@ "name": "folderPath", "in": "query", "required": false, - "description": "Restrict results to files directly inside this folder.", + "description": "Restrict results to files directly inside this folder. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", "schema": { - "description": "Restrict results to files directly inside this folder.", + "description": "Restrict results to files directly inside this folder. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", "$ref": "#/components/schemas/FolderPathInput" } }, @@ -68,10 +68,10 @@ "name": "scope", "in": "query", "required": false, - "description": "Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted and that `POST /files/{fileId}/restore` can bring back. `folderPath` resolves against active folders only, so combining it with `scope=archived` returns 404 when the containing folder was archived too.", + "description": "Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted and that `POST /files/{fileId}/restore` can bring back. `folderPath` resolves against active folders only, so combining it with `scope=archived` returns an empty page when the containing folder was archived too.", "schema": { "default": "active", - "description": "Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted and that `POST /files/{fileId}/restore` can bring back. `folderPath` resolves against active folders only, so combining it with `scope=archived` returns 404 when the containing folder was archived too.", + "description": "Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted and that `POST /files/{fileId}/restore` can bring back. `folderPath` resolves against active folders only, so combining it with `scope=archived` returns an empty page when the containing folder was archived too.", "type": "string", "enum": ["active", "archived"] } @@ -2074,7 +2074,7 @@ }, "responses": { "BadRequest": { - "description": "The request is invalid.", + "description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.", "content": { "application/json": { "schema": { diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index a6bb105ad89..e44f631798d 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -36,7 +36,7 @@ "get": { "operationId": "listKnowledgeBases", "summary": "List Knowledge Bases", - "description": "List knowledge bases in a workspace with folder filtering, search, and sorting. Paginate with `limit` and `cursor`, stopping when `nextCursor` is null. An unknown `folderPath` is a 404. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", + "description": "List knowledge bases in a workspace with folder filtering, search, and sorting. Paginate with `limit` and `cursor`, stopping when `nextCursor` is null. An unknown `folderPath` returns an empty page. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -54,9 +54,9 @@ "name": "folderPath", "in": "query", "required": false, - "description": "Restrict results to knowledge bases in this folder.", + "description": "Restrict results to knowledge bases in this folder. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", "schema": { - "description": "Restrict results to knowledge bases in this folder.", + "description": "Restrict results to knowledge bases in this folder. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", "$ref": "#/components/schemas/FolderPathInput" } }, @@ -2035,7 +2035,7 @@ }, "responses": { "BadRequest": { - "description": "The request is invalid.", + "description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.", "content": { "application/json": { "schema": { diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 4a69bb2f468..3add5807255 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -54,9 +54,9 @@ "name": "folderPath", "in": "query", "required": false, - "description": "Restrict results to tables in this folder.", + "description": "Restrict results to tables in this folder. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", "schema": { - "description": "Restrict results to tables in this folder.", + "description": "Restrict results to tables in this folder. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", "$ref": "#/components/schemas/FolderPathInput" } }, @@ -3805,7 +3805,7 @@ }, "responses": { "BadRequest": { - "description": "The request is invalid.", + "description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.", "content": { "application/json": { "schema": { diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 289661473a2..8cfb8ed6112 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -58,9 +58,9 @@ "name": "folderPath", "in": "query", "required": false, - "description": "Restrict results to workflows in this folder path.", + "description": "Restrict results to workflows in this folder path. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", "schema": { - "description": "Restrict results to workflows in this folder path.", + "description": "Restrict results to workflows in this folder path. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", "$ref": "#/components/schemas/FolderPathInput" } }, @@ -2121,7 +2121,7 @@ }, "responses": { "BadRequest": { - "description": "The request is invalid.", + "description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.", "content": { "application/json": { "schema": { From 4313202a1afeb0e2eba513594c2af6d6eae87b13 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 19:34:24 -0700 Subject: [PATCH 21/56] docs(v2): give a built-in skill's id its real form The contract said a built-in skill uses its name as the id. The ids are `builtin-` plus the name, so a client following the description asks for /skills/research and gets a 404 where the spec promises the skill. --- apps/docs/openapi-v2-resources.json | 16 ++++++++-------- apps/sim/lib/api/contracts/v2/skills.ts | 8 ++++++-- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index dcc0f048526..7bf8aeb439d 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -917,11 +917,11 @@ "name": "id", "in": "path", "required": true, - "description": "Skill to retrieve, update, or delete. Built-in skills use their name as the id.", + "description": "Skill to retrieve, update, or delete. A built-in skill is `builtin-` followed by its name, for example `builtin-research`.", "schema": { "type": "string", "minLength": 1, - "description": "Skill to retrieve, update, or delete. Built-in skills use their name as the id." + "description": "Skill to retrieve, update, or delete. A built-in skill is `builtin-` followed by its name, for example `builtin-research`." } }, { @@ -991,11 +991,11 @@ "name": "id", "in": "path", "required": true, - "description": "Skill to retrieve, update, or delete. Built-in skills use their name as the id.", + "description": "Skill to retrieve, update, or delete. A built-in skill is `builtin-` followed by its name, for example `builtin-research`.", "schema": { "type": "string", "minLength": 1, - "description": "Skill to retrieve, update, or delete. Built-in skills use their name as the id." + "description": "Skill to retrieve, update, or delete. A built-in skill is `builtin-` followed by its name, for example `builtin-research`." } } ], @@ -1071,11 +1071,11 @@ "name": "id", "in": "path", "required": true, - "description": "Skill to retrieve, update, or delete. Built-in skills use their name as the id.", + "description": "Skill to retrieve, update, or delete. A built-in skill is `builtin-` followed by its name, for example `builtin-research`.", "schema": { "type": "string", "minLength": 1, - "description": "Skill to retrieve, update, or delete. Built-in skills use their name as the id." + "description": "Skill to retrieve, update, or delete. A built-in skill is `builtin-` followed by its name, for example `builtin-research`." } }, { @@ -3107,7 +3107,7 @@ "properties": { "id": { "type": "string", - "description": "Unique skill identifier. Built-in skills use their name as the id." + "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`." }, "name": { "type": "string", @@ -3186,7 +3186,7 @@ "properties": { "id": { "type": "string", - "description": "Unique skill identifier. Built-in skills use their name as the id." + "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`." }, "name": { "type": "string", diff --git a/apps/sim/lib/api/contracts/v2/skills.ts b/apps/sim/lib/api/contracts/v2/skills.ts index ea2f67bf2a1..79ddce2784a 100644 --- a/apps/sim/lib/api/contracts/v2/skills.ts +++ b/apps/sim/lib/api/contracts/v2/skills.ts @@ -36,7 +36,11 @@ import { /** List item — everything but the skill body. */ export const v2SkillSummarySchema = z .object({ - id: z.string().describe('Unique skill identifier. Built-in skills use their name as the id.'), + id: z + .string() + .describe( + 'Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`.' + ), name: z.string().describe('Kebab-case name that agents use to reference the skill.'), description: z.string().describe('One-line summary of when the skill applies.'), /** True for built-in template skills, which ship with Sim and cannot be written to. */ @@ -83,7 +87,7 @@ export type V2SkillDeleteData = z.output export const v2SkillParamsSchema = z.object({ id: nonEmptyIdSchema.describe( - 'Skill to retrieve, update, or delete. Built-in skills use their name as the id.' + 'Skill to retrieve, update, or delete. A built-in skill is `builtin-` followed by its name, for example `builtin-research`.' ), }) export type V2SkillParams = z.output From 46c8fcb0bae4ccf880de383b5754fa4988231f42 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 19:49:00 -0700 Subject: [PATCH 22/56] fix(uploads): keep local upload artifacts inside NAME_MAX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `POST /api/v2/files/uploads` accepted a name of up to 255 characters, returned 201, and handed back a transfer URL that could never succeed: the PUT against it 500'd and `complete` then reported the object missing. The local provider named its staged object after the destination — `{key}.{uploadId}-{uuid}.tmp` plus a `.upload-metadata.json` sidecar — so the staged component was the key's length plus ~99 bytes of fixed overhead. Past roughly 125 characters of name that crossed POSIX `NAME_MAX`, and `ENAMETOOLONG` is not a `LocalUploadBodyError`, so it escaped as a 500. Multipart `complete` built the same name and failed the same way. Only local storage is affected; S3, Azure, and GCS have no per-component limit. `buildStorageKeySegment` already budgeted the key to 255, one layer above where the overflow happened. Two changes close it at the layers that own each suffix: - Staged artifacts move to a `.staging` root and are named from the upload id alone. A name derived from the destination inherits its length and then adds to it; a fixed-width one removes the arithmetic instead of re-budgeting it, so no suffix added here later can depend on the caller's file name. The staging root is a cleanup sweep root, which also reclaims artifacts that used to be orphaned beside the destination. - The durable sidecar is reserved out of the key budget centrally. `LOCAL_UPLOAD_METADATA_SUFFIX` moves next to the budget that must account for it, and the budget is derived from a list of sidecar suffixes, so adding one shrinks every key builder at once. The declared `maxLength: 255` stays honest: a 255-character name now completes PUT and `complete` end to end. --- apps/sim/lib/uploads/core/storage-key.test.ts | 27 +++++- apps/sim/lib/uploads/core/storage-key.ts | 42 ++++++++- apps/sim/lib/uploads/core/storage-service.ts | 4 +- .../uploads/upload-session/cleanup.test.ts | 25 +++++ .../sim/lib/uploads/upload-session/cleanup.ts | 3 +- .../uploads/upload-session/provider.test.ts | 92 +++++++++++++++++++ .../lib/uploads/upload-session/provider.ts | 53 +++++++++-- 7 files changed, 227 insertions(+), 19 deletions(-) diff --git a/apps/sim/lib/uploads/core/storage-key.test.ts b/apps/sim/lib/uploads/core/storage-key.test.ts index 1bfd9e9bfc4..613a503b979 100644 --- a/apps/sim/lib/uploads/core/storage-key.test.ts +++ b/apps/sim/lib/uploads/core/storage-key.test.ts @@ -8,7 +8,11 @@ import { generateUniqueExecutionFileKey, } from '@/lib/uploads/contexts/execution/utils' import { generateKnowledgeBaseFileKey } from '@/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager' -import { buildStorageKeySegment } from '@/lib/uploads/core/storage-key' +import { + buildStorageKeySegment, + LOCAL_UPLOAD_METADATA_SUFFIX, + MAX_STORAGE_KEY_NAME_BYTES, +} from '@/lib/uploads/core/storage-key' /** Bytes in the last path component — what POSIX `NAME_MAX` actually bounds. */ function lastSegmentBytes(key: string): number { @@ -18,6 +22,9 @@ function lastSegmentBytes(key: string): number { /** Longest name the workspace-file and knowledge-document contracts admit. */ const MAX_CONTRACT_NAME = `${'a'.repeat(251)}.txt` +/** POSIX `NAME_MAX` — what every derived component is ultimately measured against. */ +const NAME_MAX = 255 + describe('storage key segments', () => { it('keeps the name when it already fits, sanitizing only', () => { expect(buildStorageKeySegment('123-abc-', 'quarterly report.csv')).toBe( @@ -28,7 +35,7 @@ describe('storage key segments', () => { it('reserves the prefix out of the segment budget', () => { const segment = buildStorageKeySegment('123-abc-', MAX_CONTRACT_NAME) - expect(Buffer.byteLength(segment, 'utf-8')).toBe(255) + expect(Buffer.byteLength(segment, 'utf-8')).toBe(MAX_STORAGE_KEY_NAME_BYTES) expect(segment.startsWith('123-abc-')).toBe(true) expect(segment.endsWith('.txt')).toBe(true) }) @@ -36,7 +43,15 @@ describe('storage key segments', () => { it('drops an extension that would consume the whole budget', () => { const segment = buildStorageKeySegment('', `name.${'x'.repeat(300)}`) - expect(Buffer.byteLength(segment, 'utf-8')).toBe(255) + expect(Buffer.byteLength(segment, 'utf-8')).toBe(MAX_STORAGE_KEY_NAME_BYTES) + }) + + it('leaves room for the sidecar local storage writes beside the object', () => { + const segment = buildStorageKeySegment('123-abc-', MAX_CONTRACT_NAME) + + expect( + Buffer.byteLength(`${segment}${LOCAL_UPLOAD_METADATA_SUFFIX}`, 'utf-8') + ).toBeLessThanOrEqual(NAME_MAX) }) it('refuses a prefix that leaves no room for a name', () => { @@ -61,7 +76,9 @@ describe('storage key segments', () => { 'p' ), ], - ])('bounds the last component of a %s key', (_label, generate) => { - expect(lastSegmentBytes(generate())).toBeLessThanOrEqual(255) + ])('bounds the last component of a %s key, sidecar included', (_label, generate) => { + expect(lastSegmentBytes(generate()) + LOCAL_UPLOAD_METADATA_SUFFIX.length).toBeLessThanOrEqual( + NAME_MAX + ) }) }) diff --git a/apps/sim/lib/uploads/core/storage-key.ts b/apps/sim/lib/uploads/core/storage-key.ts index 4c9d146a720..ee751e4b1d1 100644 --- a/apps/sim/lib/uploads/core/storage-key.ts +++ b/apps/sim/lib/uploads/core/storage-key.ts @@ -8,6 +8,40 @@ import { sanitizeFileName } from '@/executor/constants' */ const MAX_STORAGE_KEY_SEGMENT_BYTES = 255 +/** Sidecar attached to local objects promoted through the upload-session transport. */ +export const LOCAL_UPLOAD_METADATA_SUFFIX = '.upload-metadata.json' + +/** + * Every suffix local storage appends to a stored object's own path component. + * + * The key's last component is not the only component derived from a file name. + * Local storage writes siblings named after the object plus a fixed suffix, and + * `NAME_MAX` bounds those siblings too — so the budget a name may spend is + * `255 − the longest suffix`, not 255. Reserving that here is what makes the + * reservation survive a second sidecar: adding an entry to this list shrinks + * every key builder's budget at once, while a suffix invented at the write site + * silently reopens the overflow this module exists to close. + * + * Transient artifacts are deliberately absent. The local upload provider stages + * them under a path derived from the upload id alone, so no temporary name + * inherits the file name's length and none needs a reservation here. + * + * Every entry is ASCII, so `length` is its byte count. + */ +const LOCAL_OBJECT_SIDECAR_SUFFIXES = [LOCAL_UPLOAD_METADATA_SUFFIX] as const + +const MAX_SIDECAR_SUFFIX_BYTES = Math.max( + ...LOCAL_OBJECT_SIDECAR_SUFFIXES.map((suffix) => suffix.length) +) + +/** + * Bytes a key's last component may occupy, sidecars accounted for. + * + * Exported so a store-shaped test can assert the invariant end to end rather + * than restate the arithmetic. + */ +export const MAX_STORAGE_KEY_NAME_BYTES = MAX_STORAGE_KEY_SEGMENT_BYTES - MAX_SIDECAR_SUFFIX_BYTES + /** * Longest trailing `.ext` worth preserving through a truncation. Beyond this * the dot is part of the name, not a type marker, and keeping it would eat the @@ -54,15 +88,19 @@ function fitStorageKeyName(safeName: string, budget: number): string { * a store rejects. The name in a key is a debugging convenience — the row's * `originalName` is the identity — so truncating it costs nothing. * + * The budget is {@link MAX_STORAGE_KEY_NAME_BYTES}, not `NAME_MAX` itself: local + * storage stores sidecars beside the object under the object's own name, and a + * component that fills `NAME_MAX` exactly leaves its sidecar nowhere to go. + * * @param prefix Fixed leading text of the component (uniquifier, timestamp). * Must itself leave room for at least one character of the name. * @param fileName Raw caller-supplied name; sanitized here. */ export function buildStorageKeySegment(prefix: string, fileName: string): string { - const budget = MAX_STORAGE_KEY_SEGMENT_BYTES - prefix.length + const budget = MAX_STORAGE_KEY_NAME_BYTES - prefix.length if (budget < 1) { throw new Error( - `Storage key prefix of ${prefix.length} bytes leaves no room for a file name within ${MAX_STORAGE_KEY_SEGMENT_BYTES} bytes` + `Storage key prefix of ${prefix.length} bytes leaves no room for a file name within ${MAX_STORAGE_KEY_NAME_BYTES} bytes` ) } return `${prefix}${fitStorageKeyName(sanitizeFileName(fileName), budget)}` diff --git a/apps/sim/lib/uploads/core/storage-service.ts b/apps/sim/lib/uploads/core/storage-service.ts index 499b603eec5..26e2a83d6f2 100644 --- a/apps/sim/lib/uploads/core/storage-service.ts +++ b/apps/sim/lib/uploads/core/storage-service.ts @@ -8,6 +8,7 @@ import { USE_GCS_STORAGE, USE_S3_STORAGE, } from '@/lib/uploads/config' +import { LOCAL_UPLOAD_METADATA_SUFFIX } from '@/lib/uploads/core/storage-key' import type { AzureMultipartPart, BlobConfig } from '@/lib/uploads/providers/blob/types' import type { GcsConfig, GcsMultipartPart } from '@/lib/uploads/providers/gcs/types' import type { S3Config, S3MultipartPart } from '@/lib/uploads/providers/s3/types' @@ -25,9 +26,6 @@ import { sanitizeFileKey } from '@/lib/uploads/utils/file-utils' const logger = createLogger('StorageService') -/** Sidecar attached to local objects promoted through the upload-session transport. */ -export const LOCAL_UPLOAD_METADATA_SUFFIX = '.upload-metadata.json' - /** * Create a Blob config from StorageConfig * @throws Error if required properties are missing diff --git a/apps/sim/lib/uploads/upload-session/cleanup.test.ts b/apps/sim/lib/uploads/upload-session/cleanup.test.ts index 586b76ea702..c9f9d534777 100644 --- a/apps/sim/lib/uploads/upload-session/cleanup.test.ts +++ b/apps/sim/lib/uploads/upload-session/cleanup.test.ts @@ -38,6 +38,21 @@ describe('local upload artifact cleanup', () => { await expect(stat(`${testUploadDirectory}/.multipart/fresh`)).resolves.toBeDefined() }) + // A PUT or multipart assembly that dies mid-write leaves a staged object + // behind. Staged artifacts used to be written next to their destination, + // outside every sweep root, so nothing ever reclaimed them. + it('reclaims abandoned staged objects', async () => { + const now = Date.UTC(2026, 7, 4, 12) + await createStagedObject('abandoned.tmp', now - LOCAL_UPLOAD_ARTIFACT_TTL_MS - 1) + await createStagedObject('in-flight.tmp', now) + + await expect(sweepLocalUploadArtifacts({ now })).resolves.toEqual({ scanned: 2, removed: 1 }) + await expect(stat(`${testUploadDirectory}/.staging/abandoned.tmp`)).rejects.toMatchObject({ + code: 'ENOENT', + }) + await expect(stat(`${testUploadDirectory}/.staging/in-flight.tmp`)).resolves.toBeDefined() + }) + it('bounds each sweep by the requested entry count', async () => { const now = Date.UTC(2026, 7, 4, 12) await createArtifact('.multipart/one', now - LOCAL_UPLOAD_ARTIFACT_TTL_MS - 1) @@ -83,6 +98,16 @@ describe('local upload artifact cleanup', () => { }) }) +/** Staged objects are files, not the per-upload directories multipart leaves. */ +async function createStagedObject(name: string, modifiedAt: number): Promise { + const directory = `${testUploadDirectory}/.staging` + await mkdir(directory, { recursive: true }) + const path = `${directory}/${name}` + await writeFile(path, 'test') + const time = new Date(modifiedAt) + await utimes(path, time, time) +} + async function createArtifact(relativePath: string, modifiedAt: number): Promise { const path = `${testUploadDirectory}/${relativePath}` await mkdir(path, { recursive: true }) diff --git a/apps/sim/lib/uploads/upload-session/cleanup.ts b/apps/sim/lib/uploads/upload-session/cleanup.ts index b31cc9558e2..22e473781ff 100644 --- a/apps/sim/lib/uploads/upload-session/cleanup.ts +++ b/apps/sim/lib/uploads/upload-session/cleanup.ts @@ -2,6 +2,7 @@ import type { Dirent } from 'node:fs' import { opendir, rm, stat } from 'node:fs/promises' import { join } from 'node:path' import { UPLOAD_DIR_SERVER } from '@/lib/uploads/core/setup.server' +import { LOCAL_MULTIPART_ROOT, LOCAL_STAGING_ROOT } from '@/lib/uploads/upload-session/provider' export const LOCAL_UPLOAD_CLEANUP_INTERVAL_MS = 15 * 60 * 1000 export const LOCAL_UPLOAD_ARTIFACT_TTL_MS = 25 * 60 * 60 * 1000 @@ -15,7 +16,7 @@ export interface LocalUploadCleanupResult { let activeCleanup: Promise | null = null let lastCleanupAt = 0 -const CLEANUP_ROOTS = ['.multipart'] as const +const CLEANUP_ROOTS = [LOCAL_MULTIPART_ROOT, LOCAL_STAGING_ROOT] as const interface CleanupRootState { directory: Awaited> | null diff --git a/apps/sim/lib/uploads/upload-session/provider.test.ts b/apps/sim/lib/uploads/upload-session/provider.test.ts index b8426003135..9e12995f487 100644 --- a/apps/sim/lib/uploads/upload-session/provider.test.ts +++ b/apps/sim/lib/uploads/upload-session/provider.test.ts @@ -26,6 +26,7 @@ vi.mock('@/lib/uploads/providers/s3/client', () => ({ getS3MultipartPartUrls: mockS3PartUrls, })) +import { buildStorageKeySegment } from '@/lib/uploads/core/storage-key' import { completeMultipartProviderUpload, createPutProviderTransfer, @@ -39,6 +40,14 @@ import { } from '@/lib/uploads/upload-session/provider' const CONTEXT = 'workspace' as const + +/** Longest name the file contracts admit, in the key shape workspace files use. */ +const MAX_LENGTH_KEY = `workspace/workspace-1/${buildStorageKeySegment( + '1700000000000-0123456789abcdef-', + `${'a'.repeat(251)}.txt` +)}` + +const NAME_MAX = 255 const METADATA = { uploadId: 'upload-1', userId: 'user-1', @@ -212,6 +221,70 @@ describe('local upload-session provider', () => { ) await expect(stat(localPath('.multipart/upload-1'))).rejects.toMatchObject({ code: 'ENOENT' }) }) + + // The staged object and its sidecar used to be named after the destination, so + // a key the contract's longest name produces overflowed `NAME_MAX` and the + // whole session became unusable: `POST /uploads` issued a transfer URL, the + // PUT against it 500'd, and `complete` then reported the object missing. + it('stores a PUT under the longest key the name contract can produce', async () => { + await writeLocalPutObject({ + uploadId: '11111111-1111-4111-8111-111111111111', + key: MAX_LENGTH_KEY, + body: byteStream('abc'), + expectedSize: 3, + contentType: 'text/plain', + metadata: METADATA, + }) + + await expect(readFile(localPath(MAX_LENGTH_KEY), 'utf8')).resolves.toBe('abc') + await expect( + headProviderObject({ provider: 'local', key: MAX_LENGTH_KEY, context: CONTEXT }) + ).resolves.toMatchObject({ size: 3, contentType: 'text/plain' }) + expect(await temporaryFiles('workspace/workspace-1')).toEqual([]) + expect(await allEntries('.staging')).toEqual([]) + }) + + it('assembles multipart parts under the longest key the name contract can produce', async () => { + await writeLocalMultipartPart({ + uploadId: 'upload-1', + partNumber: 1, + body: byteStream('abc'), + expectedSize: 3, + }) + + await completeMultipartProviderUpload({ + provider: 'local', + providerUploadId: null, + uploadId: 'upload-1', + key: MAX_LENGTH_KEY, + contentType: 'text/plain', + context: CONTEXT, + parts: [{ partNumber: 1, size: 3 }], + metadata: METADATA, + }) + + await expect(readFile(localPath(MAX_LENGTH_KEY), 'utf8')).resolves.toBe('abc') + expect(await allEntries('.staging')).toEqual([]) + }) + + // The reservation only holds while every local path stays inside one + // component's budget, staged names included. + it('keeps every path component it writes within NAME_MAX', async () => { + await writeLocalPutObject({ + uploadId: '11111111-1111-4111-8111-111111111111', + key: MAX_LENGTH_KEY, + body: byteStream('abc'), + expectedSize: 3, + contentType: 'text/plain', + metadata: METADATA, + }) + + for (const path of await walk(testUploadDirectory)) { + for (const component of path.split('/')) { + expect(Buffer.byteLength(component, 'utf-8')).toBeLessThanOrEqual(NAME_MAX) + } + } + }) }) /** Mirrors `UPLOAD_SESSION_TTL_MS`, imported here as a literal so this suite @@ -388,6 +461,25 @@ function localPath(key: string): string { return `${testUploadDirectory}/${key}` } +async function allEntries(relativeDirectory: string): Promise { + return readdir(localPath(relativeDirectory)).catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return [] + throw error + }) +} + +/** Every path under `directory`, relative to it, files and directories alike. */ +async function walk(directory: string, prefix = ''): Promise { + const entries = await readdir(directory, { withFileTypes: true }) + const paths: string[] = [] + for (const entry of entries) { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name + paths.push(relative) + if (entry.isDirectory()) paths.push(...(await walk(`${directory}/${entry.name}`, relative))) + } + return paths +} + async function temporaryFiles(relativeDirectory: string): Promise { const entries = await readdir(localPath(relativeDirectory)).catch( (error: NodeJS.ErrnoException) => { diff --git a/apps/sim/lib/uploads/upload-session/provider.ts b/apps/sim/lib/uploads/upload-session/provider.ts index 478f6780c40..b3aa22d76c6 100644 --- a/apps/sim/lib/uploads/upload-session/provider.ts +++ b/apps/sim/lib/uploads/upload-session/provider.ts @@ -22,11 +22,11 @@ import { USE_S3_STORAGE, } from '@/lib/uploads/config' import { UPLOAD_DIR_SERVER } from '@/lib/uploads/core/setup.server' +import { LOCAL_UPLOAD_METADATA_SUFFIX } from '@/lib/uploads/core/storage-key' import { createBlobConfig, createGcsConfig, createS3Config, - LOCAL_UPLOAD_METADATA_SUFFIX, } from '@/lib/uploads/core/storage-service' import type { StorageContext } from '@/lib/uploads/shared/types' import type { UploadStorageProvider } from '@/lib/uploads/upload-session/types' @@ -512,9 +512,11 @@ export async function writeLocalPutObject(params: { }): Promise { const { Readable, Transform } = await import('node:stream') const destination = localObjectPath(params.key) - const temporary = `${destination}.${params.uploadId}-${generateId()}.tmp` - const temporaryMetadata = `${temporary}${LOCAL_UPLOAD_METADATA_SUFFIX}` - await mkdir(dirname(destination), { recursive: true }) + const { object: temporary, metadata: temporaryMetadata } = localStagedPaths(params.uploadId) + await Promise.all([ + mkdir(dirname(destination), { recursive: true }), + mkdir(dirname(temporary), { recursive: true }), + ]) let bytes = 0 const counter = new Transform({ transform(chunk: Buffer, _encoding, callback) { @@ -602,8 +604,41 @@ export async function writeLocalMultipartPart(params: { } } +/** + * Roots the local data plane owns inside the upload directory. + * + * Both hold work-in-progress rather than stored objects, so both are swept by + * the local cleanup job. Naming them here keeps that sweep and the writers + * agreeing on one set — a root known only to its writer accumulates forever. + */ +export const LOCAL_MULTIPART_ROOT = '.multipart' +export const LOCAL_STAGING_ROOT = '.staging' + function localPartsDirectory(uploadId: string): string { - return join(UPLOAD_DIR_SERVER, '.multipart', uploadId) + return join(UPLOAD_DIR_SERVER, LOCAL_MULTIPART_ROOT, uploadId) +} + +/** + * Paths for an object being staged before it is published at its final key. + * + * Staged names are derived from the upload id alone, never from the + * destination. A temporary built as `destination + suffix` inherits the + * destination's length and then adds to it, so a key that fits `NAME_MAX` + * exactly still failed with `ENAMETOOLONG`: that is the 500 the upload-session + * PUT returned for any file name past roughly 125 characters, and the identical + * failure multipart `complete` returned while assembling one. Deriving the + * staged name from a fixed-width id removes the arithmetic rather than + * re-budgeting it — no suffix added here can depend on the caller's file name, + * so no future suffix can reintroduce the overflow. + * + * The staging root sits inside `UPLOAD_DIR_SERVER`, which keeps publication a + * same-filesystem `link` and lets the cleanup sweep reclaim what a crashed + * request left behind — artifacts written next to the destination were never + * swept at all. + */ +function localStagedPaths(uploadId: string): { object: string; metadata: string } { + const object = join(UPLOAD_DIR_SERVER, LOCAL_STAGING_ROOT, `${uploadId}-${generateId()}.tmp`) + return { object, metadata: `${object}${LOCAL_UPLOAD_METADATA_SUFFIX}` } } function localPartPath(uploadId: string, partNumber: number): string { @@ -626,9 +661,11 @@ async function assembleLocalParts( metadata: Record ): Promise { const destination = localObjectPath(key) - const temporary = `${destination}.${uploadId}-${generateId()}.tmp` - const temporaryMetadata = `${temporary}${LOCAL_UPLOAD_METADATA_SUFFIX}` - await mkdir(dirname(destination), { recursive: true }) + const { object: temporary, metadata: temporaryMetadata } = localStagedPaths(uploadId) + await Promise.all([ + mkdir(dirname(destination), { recursive: true }), + mkdir(dirname(temporary), { recursive: true }), + ]) try { for (const part of parts) { await pipeline( From aa4716619b9f430fe27b26029b1f2d96441262f0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 19:57:35 -0700 Subject: [PATCH 23/56] fix(uploads): budget every key built from a caller-supplied name Auditing the rest of the codebase for the shape that broke the upload-session PUT found five more key builders that put an unbounded name into a path component local storage writes directly. Three are on the same route as the original bug: `table_import`, `profile_picture`, and `workspace_logo` built their key inline with `sanitizeFileName`, which maps characters and never truncates, while their sibling purposes went through `buildStorageKeySegment`. A 255-character name broke `table_import` at the metadata sidecar and the other two at the object write itself. The other two are local-storage writers reached from elsewhere: knowledge-base connector sync capped the document title at 200 and then appended a timestamp, a uuid and `.txt` on top of the cap, landing at exactly 255 with no room for the sidecar; the Mistral-OCR staging and chunk keys inlined the sanitizer with no bound at all; and inbound email attachments went into a key with neither sanitizer nor bound, on a file name an outside sender chooses. All now derive their component through `buildStorageKeySegment`, so the reservation is stated once. The upload-session test asserts it for every purpose the contract admits, which is what keeps a newly added purpose from reintroducing the hand-built form. --- .../lib/knowledge/connectors/sync-engine.ts | 5 +- .../knowledge/documents/document-processor.ts | 10 ++-- apps/sim/lib/mothership/inbox/executor.ts | 6 ++- .../uploads/upload-session/service.test.ts | 51 +++++++++++++++++-- .../sim/lib/uploads/upload-session/service.ts | 8 +-- 5 files changed, 64 insertions(+), 16 deletions(-) diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.ts b/apps/sim/lib/knowledge/connectors/sync-engine.ts index 109167e2edc..d3197f88362 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.ts @@ -22,6 +22,7 @@ import type { DocumentData } from '@/lib/knowledge/documents/service' import { hardDeleteDocuments, processDocumentsWithQueue } from '@/lib/knowledge/documents/service' import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' import { StorageService } from '@/lib/uploads' +import { buildStorageKeySegment } from '@/lib/uploads/core/storage-key' import { deleteFile } from '@/lib/uploads/core/storage-service' import { deleteFileMetadata } from '@/lib/uploads/server/metadata' import { extractStorageKey } from '@/lib/uploads/utils/file-utils' @@ -1472,7 +1473,7 @@ async function addDocument( const documentId = generateId() const contentBuffer = Buffer.from(extDoc.content, 'utf-8') const safeTitle = sanitizeStorageTitle(extDoc.title) - const customKey = `kb/${Date.now()}-${documentId}-${safeTitle}.txt` + const customKey = `kb/${buildStorageKeySegment(`${Date.now()}-${documentId}-`, `${safeTitle}.txt`)}` const fileInfo = await StorageService.uploadFile({ file: contentBuffer, @@ -1561,7 +1562,7 @@ async function updateDocument( const contentBuffer = Buffer.from(extDoc.content, 'utf-8') const safeTitle = sanitizeStorageTitle(extDoc.title) - const customKey = `kb/${Date.now()}-${existingDocId}-${safeTitle}.txt` + const customKey = `kb/${buildStorageKeySegment(`${Date.now()}-${existingDocId}-`, `${safeTitle}.txt`)}` const fileInfo = await StorageService.uploadFile({ file: contentBuffer, diff --git a/apps/sim/lib/knowledge/documents/document-processor.ts b/apps/sim/lib/knowledge/documents/document-processor.ts index 7e93edeb808..5ec1a39bda7 100644 --- a/apps/sim/lib/knowledge/documents/document-processor.ts +++ b/apps/sim/lib/knowledge/documents/document-processor.ts @@ -25,6 +25,7 @@ import { getKnowledgeOpaqueModelInputRegistry, } from '@/lib/knowledge/model-input-provenance' import { StorageService } from '@/lib/uploads' +import { buildStorageKeySegment } from '@/lib/uploads/core/storage-key' import { isInternalFileUrl } from '@/lib/uploads/utils/file-utils' import { downloadFileFromUrl } from '@/lib/uploads/utils/file-utils.server' import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation' @@ -362,8 +363,7 @@ async function handleFileForOCR( const timestamp = Date.now() const uniqueId = randomBytes(8).toString('hex') - const safeFileName = filename.replace(/[^a-zA-Z0-9.-]/g, '_') - const customKey = `kb/${timestamp}-${uniqueId}-${safeFileName}` + const customKey = `kb/${buildStorageKeySegment(`${timestamp}-${uniqueId}-`, filename)}` const cloudResult = await StorageService.uploadFile({ file: buffer, @@ -659,8 +659,10 @@ async function processChunk( try { const timestamp = Date.now() const uniqueId = randomBytes(8).toString('hex') - const safeFileName = filename.replace(/[^a-zA-Z0-9.-]/g, '_') - const chunkKey = `kb/${timestamp}-${uniqueId}-chunk${chunkIndex + 1}-${safeFileName}` + const chunkKey = `kb/${buildStorageKeySegment( + `${timestamp}-${uniqueId}-chunk${chunkIndex + 1}-`, + filename + )}` // No metadata: these chunks are ephemeral OCR artifacts (deleted in the // finally below) that are fetched via a direct presigned URL, never through diff --git a/apps/sim/lib/mothership/inbox/executor.ts b/apps/sim/lib/mothership/inbox/executor.ts index dd114b8db85..02eb799d218 100644 --- a/apps/sim/lib/mothership/inbox/executor.ts +++ b/apps/sim/lib/mothership/inbox/executor.ts @@ -24,6 +24,7 @@ import * as agentmail from '@/lib/mothership/inbox/agentmail-client' import { formatEmailAsMessage } from '@/lib/mothership/inbox/format' import { sendInboxResponse } from '@/lib/mothership/inbox/response' import type { AgentMailAttachment } from '@/lib/mothership/inbox/types' +import { buildStorageKeySegment } from '@/lib/uploads/core/storage-key' import { uploadFile } from '@/lib/uploads/core/storage-service' import { createFileContent, type MessageContent } from '@/lib/uploads/utils/file-utils' import { checkWorkspaceAccess, getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -482,7 +483,10 @@ async function downloadAttachmentContents( const fileContent = createFileContent(buffer, attachment.content_type) if (!fileContent) return null - const storageKey = `copilot/${Date.now()}-${attachment.attachment_id}-${attachment.filename}` + const storageKey = `copilot/${buildStorageKeySegment( + `${Date.now()}-${attachment.attachment_id}-`, + attachment.filename + )}` const uploaded = await uploadFile({ file: buffer, fileName: attachment.filename, diff --git a/apps/sim/lib/uploads/upload-session/service.test.ts b/apps/sim/lib/uploads/upload-session/service.test.ts index 899a7b889ab..fa1ba2d3d04 100644 --- a/apps/sim/lib/uploads/upload-session/service.test.ts +++ b/apps/sim/lib/uploads/upload-session/service.test.ts @@ -34,11 +34,15 @@ vi.mock('@/lib/billing/storage', () => ({ resolveStorageBillingContext: mockResolveBillingContext, })) -vi.mock('@/lib/uploads/contexts/workspace', () => ({ - generateWorkspaceFileKey: vi.fn( - (workspaceId: string, fileName: string) => `workspace/${workspaceId}/final-${fileName}` - ), -})) +vi.mock('@/lib/uploads/contexts/workspace', async () => { + const { buildStorageKeySegment } = await import('@/lib/uploads/core/storage-key') + return { + generateWorkspaceFileKey: vi.fn( + (workspaceId: string, fileName: string) => + `workspace/${workspaceId}/${buildStorageKeySegment('final-', fileName)}` + ), + } +}) vi.mock('@/lib/uploads/upload-session/cleanup', () => ({ maybeCleanupLocalUploadArtifacts: vi.fn().mockResolvedValue({ scanned: 0, removed: 0 }), @@ -56,6 +60,7 @@ vi.mock('@/lib/uploads/upload-session/provider', () => ({ uploadStorageProvider: vi.fn(() => 's3'), })) +import { LOCAL_UPLOAD_METADATA_SUFFIX } from '@/lib/uploads/core/storage-key' import { abortUploadSession, assertUploadSessionAuthBinding, @@ -137,6 +142,42 @@ describe('upload sessions', () => { }) }) + // Local storage stores an object's metadata sidecar beside it, under the + // object's own name, so the whole key + suffix must fit one path component. + // Three purposes built their key by hand and admitted a 255-character name + // straight into it: the session was created, its transfer URL issued, and + // every request against it then failed with an unclassifiable 500. + it.each([ + ['workspace_file', {}], + ['knowledge_document', { knowledgeBaseId: 'kb-1' }], + ['table_import', {}], + ['profile_picture', {}], + ['workspace_logo', {}], + ['mothership_attachment', {}], + ['execution_attachment', { workflowId: 'workflow-1', executionId: 'execution-1' }], + ])('bounds the %s key so its local sidecar still fits', async (purpose, extra) => { + dbChainMockFns.returning.mockResolvedValue([uploadRow({ purpose })]) + + await createUploadSession({ + id: 'upload-1', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + purpose: purpose as Parameters[0]['purpose'], + fileName: `${'a'.repeat(251)}.txt`, + contentType: 'text/plain', + fileSize: 4, + localOrigin: 'http://localhost:3000', + ...extra, + } as Parameters[0]) + + const { finalKey } = dbChainMockFns.values.mock.calls[0][0] + const lastComponent = finalKey.slice(finalKey.lastIndexOf('/') + 1) + expect( + Buffer.byteLength(`${lastComponent}${LOCAL_UPLOAD_METADATA_SUFFIX}`, 'utf-8') + ).toBeLessThanOrEqual(255) + }) + it('allocates distinct keys for same-named execution attachments', async () => { dbChainMockFns.returning .mockResolvedValueOnce([ diff --git a/apps/sim/lib/uploads/upload-session/service.ts b/apps/sim/lib/uploads/upload-session/service.ts index a4386de8753..3f80269150a 100644 --- a/apps/sim/lib/uploads/upload-session/service.ts +++ b/apps/sim/lib/uploads/upload-session/service.ts @@ -15,6 +15,7 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateUniqueExecutionFileKey } from '@/lib/uploads/contexts/execution/utils' import { generateKnowledgeBaseFileKey } from '@/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager' import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace' +import { buildStorageKeySegment } from '@/lib/uploads/core/storage-key' import { MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE, MAX_WORKSPACE_FILE_SIZE, @@ -41,7 +42,6 @@ import type { UploadStorageProvider, UploadTransferMethod, } from '@/lib/uploads/upload-session/types' -import { sanitizeFileName } from '@/executor/constants' export const UPLOAD_SESSION_PUT_MAX_BYTES = 50 * 1024 * 1024 export const UPLOAD_SESSION_PART_SIZE = 8 * 1024 * 1024 @@ -1200,7 +1200,7 @@ function resolveUploadStorage( case 'table_import': return { storageContext: 'table-import', - finalKey: `table-import/${params.workspaceId}/${id}/${sanitizeFileName(params.fileName)}`, + finalKey: `table-import/${params.workspaceId}/${id}/${buildStorageKeySegment('', params.fileName)}`, } case 'knowledge_document': return { @@ -1210,12 +1210,12 @@ function resolveUploadStorage( case 'profile_picture': return { storageContext: 'profile-pictures', - finalKey: `profile-pictures/${id}-${sanitizeFileName(params.fileName)}`, + finalKey: `profile-pictures/${buildStorageKeySegment(`${id}-`, params.fileName)}`, } case 'workspace_logo': return { storageContext: 'workspace-logos', - finalKey: `workspace-logos/${params.workspaceId}/${id}-${sanitizeFileName(params.fileName)}`, + finalKey: `workspace-logos/${params.workspaceId}/${buildStorageKeySegment(`${id}-`, params.fileName)}`, } case 'mothership_attachment': return { From 710ad21e1104c0d167fd22b0921a6fd5399be4fe Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 19:58:06 -0700 Subject: [PATCH 24/56] fix(v2): stop the logs and billing reads answering 500 or a silent restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four caller-reachable failures on `GET /logs`, `GET /logs/{runId}`, and `GET /billing/logs`, each fixed at the layer that owns the guarantee. `minDurationMs`/`maxDurationMs` were published as `number` against an `integer` column, so `1.5`, `-0.5`, `2147483648`, and `1e30` all reached Postgres as bind parameters it refuses to parse. They are now whole milliseconds bounded to int4, and the generated spec says so. `0000-01-01T00:00:00Z` satisfies the published `date-time` pattern but names no instant Postgres can store, since the proleptic Gregorian calendar has no year zero. `v2RunWindowBoundSchema` now rejects it, which covers both log families and the files-audit read that share the schema. A scoped cursor whose inner token was the empty string passed the `typeof === 'string'` envelope check and then read as falsy in every domain reader, so both lists silently served page one again with a `nextCursor` inviting another lap — the exact failure `UNKNOWN_CURSOR_MESSAGE` exists to make visible. An empty inner is now unreadable, and the sibling `decodePublicLogCursor` gets the same treatment for its `id` half. The rejection message no longer names `sortBy`/`sortOrder`, which neither operation accepts. `GET /logs/{runId}` reported `folderPath: null` for both a workflow at the workspace root and a folder it could not resolve, so a caller could distinguish neither, and `null` is not a value `folderPaths` takes back as a filter. The root is now `/`, matching the workflow resources. Also, from the same audit: comma lists reject an empty entry the way `folderPaths` already did instead of dropping it; a query param sent twice is named as duplicated rather than reported absent; and the `triggers=all` sentinel, the detail-level promotion by `includeTraceSpans`/`includeFinalOutput`, and the 403/404 split against the billing family are documented where each is decided. --- apps/docs/openapi-v2-billing.json | 8 +- apps/docs/openapi-v2-files-audit.json | 8 +- apps/docs/openapi-v2-logs.json | 46 +++--- apps/docs/openapi-v2-workflows.json | 8 +- .../sim/app/api/v2/billing/logs/route.test.ts | 49 ++++++ apps/sim/app/api/v2/lib/response.ts | 19 ++- apps/sim/app/api/v2/logs/route.test.ts | 147 ++++++++++++++++++ apps/sim/app/api/v2/logs/route.ts | 3 +- apps/sim/lib/api/contracts/v2/billing.ts | 4 +- apps/sim/lib/api/contracts/v2/logs.ts | 90 +++++++++-- apps/sim/lib/api/contracts/v2/shared.ts | 14 +- apps/sim/lib/api/list-query.ts | 14 ++ apps/sim/lib/api/server/blank-query-values.ts | 32 ++++ .../lib/api/server/routes/v2-json-route.ts | 9 ++ apps/sim/lib/api/server/validation.ts | 25 ++- apps/sim/lib/logs/api/route-policies.ts | 21 +++ .../lib/logs/application/get-public-log.ts | 32 +++- .../application/public-log-use-cases.test.ts | 28 ++++ apps/sim/lib/logs/public-filters.ts | 11 ++ apps/sim/lib/logs/public-queries.ts | 11 ++ 20 files changed, 521 insertions(+), 58 deletions(-) diff --git a/apps/docs/openapi-v2-billing.json b/apps/docs/openapi-v2-billing.json index c6296a2ec0c..f6d9f002ec1 100644 --- a/apps/docs/openapi-v2-billing.json +++ b/apps/docs/openapi-v2-billing.json @@ -152,24 +152,24 @@ "name": "startDate", "in": "query", "required": false, - "description": "Only include usage events recorded at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. Requires `period=custom`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected.", + "description": "Only include usage events recorded at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. Requires `period=custom`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.", "schema": { "type": "string", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "Only include usage events recorded at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. Requires `period=custom`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected." + "description": "Only include usage events recorded at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. Requires `period=custom`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant." } }, { "name": "endDate", "in": "query", "required": false, - "description": "Only include usage events recorded at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. Requires `period=custom`, and defaults to now when omitted. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected.", + "description": "Only include usage events recorded at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. Requires `period=custom`, and defaults to now when omitted. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.", "schema": { "type": "string", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "Only include usage events recorded at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. Requires `period=custom`, and defaults to now when omitted. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected." + "description": "Only include usage events recorded at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. Requires `period=custom`, and defaults to now when omitted. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant." } }, { diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 447ccfea359..03cf8576cf4 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -1067,24 +1067,24 @@ "name": "startDate", "in": "query", "required": false, - "description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected.", + "description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.", "schema": { "type": "string", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected." + "description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant." } }, { "name": "endDate", "in": "query", "required": false, - "description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected.", + "description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.", "schema": { "type": "string", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected." + "description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant." } }, { diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index b4f74370725..8c4027c6f24 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -54,20 +54,20 @@ "name": "workflowIds", "in": "query", "required": false, - "description": "Comma-separated workflow identifiers to include.", + "description": "Comma-separated workflow identifiers to include. An empty entry is rejected.", "schema": { "type": "string", - "description": "Comma-separated workflow identifiers to include." + "description": "Comma-separated workflow identifiers to include. An empty entry is rejected." } }, { "name": "triggers", "in": "query", "required": false, - "description": "Comma-separated trigger types to include.", + "description": "Comma-separated trigger types to include. An empty entry is rejected. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`.", "schema": { "type": "string", - "description": "Comma-separated trigger types to include." + "description": "Comma-separated trigger types to include. An empty entry is rejected. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`." } }, { @@ -85,44 +85,48 @@ "name": "startDate", "in": "query", "required": false, - "description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected.", + "description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.", "schema": { "type": "string", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected." + "description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant." } }, { "name": "endDate", "in": "query", "required": false, - "description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected.", + "description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.", "schema": { "type": "string", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected." + "description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant." } }, { "name": "minDurationMs", "in": "query", "required": false, - "description": "Minimum total execution duration in milliseconds.", + "description": "Minimum total execution duration in milliseconds. Whole milliseconds from 0 to 2147483647; the stored duration is a 32-bit integer, so a fractional or out-of-range bound is rejected.", "schema": { - "type": "number", - "description": "Minimum total execution duration in milliseconds." + "type": "integer", + "minimum": 0, + "maximum": 2147483647, + "description": "Minimum total execution duration in milliseconds. Whole milliseconds from 0 to 2147483647; the stored duration is a 32-bit integer, so a fractional or out-of-range bound is rejected." } }, { "name": "maxDurationMs", "in": "query", "required": false, - "description": "Maximum total execution duration in milliseconds.", + "description": "Maximum total execution duration in milliseconds. Whole milliseconds from 0 to 2147483647; the stored duration is a 32-bit integer, so a fractional or out-of-range bound is rejected.", "schema": { - "type": "number", - "description": "Maximum total execution duration in milliseconds." + "type": "integer", + "minimum": 0, + "maximum": 2147483647, + "description": "Maximum total execution duration in milliseconds. Whole milliseconds from 0 to 2147483647; the stored duration is a 32-bit integer, so a fractional or out-of-range bound is rejected." } }, { @@ -159,21 +163,21 @@ "name": "details", "in": "query", "required": false, - "description": "Response detail level.", + "description": "Response detail level. `full` adds the `workflow` summary to every item. `includeTraceSpans=true` and `includeFinalOutput=true` each imply `full`, so either one adds `workflow` even when `details=basic` is sent explicitly.", "schema": { "default": "basic", "type": "string", "enum": ["basic", "full"], - "description": "Response detail level." + "description": "Response detail level. `full` adds the `workflow` summary to every item. `includeTraceSpans=true` and `includeFinalOutput=true` each imply `full`, so either one adds `workflow` even when `details=basic` is sent explicitly." } }, { "name": "includeTraceSpans", "in": "query", "required": false, - "description": "Whether to include block-level trace spans. Spans are stored apart from the log row and pruned on their own retention schedule, so a run whose spans have aged out returns `traceSpans: []` rather than an error — an empty array does not mean the run recorded none.", + "description": "Whether to include block-level trace spans. Implies `details=full`, so the `workflow` summary is present regardless of what `details` is set to. Spans are stored apart from the log row and pruned on their own retention schedule, so a run whose spans have aged out returns `traceSpans: []` rather than an error — an empty array does not mean the run recorded none.", "schema": { - "description": "Whether to include block-level trace spans. Spans are stored apart from the log row and pruned on their own retention schedule, so a run whose spans have aged out returns `traceSpans: []` rather than an error — an empty array does not mean the run recorded none.", + "description": "Whether to include block-level trace spans. Implies `details=full`, so the `workflow` summary is present regardless of what `details` is set to. Spans are stored apart from the log row and pruned on their own retention schedule, so a run whose spans have aged out returns `traceSpans: []` rather than an error — an empty array does not mean the run recorded none.", "type": "boolean" } }, @@ -181,9 +185,9 @@ "name": "includeFinalOutput", "in": "query", "required": false, - "description": "Whether to include the final workflow output.", + "description": "Whether to include the final workflow output. Implies `details=full`, so the `workflow` summary is present regardless of what `details` is set to.", "schema": { - "description": "Whether to include the final workflow output.", + "description": "Whether to include the final workflow output. Implies `details=full`, so the `workflow` summary is present regardless of what `details` is set to.", "type": "boolean" } }, @@ -1144,7 +1148,7 @@ "type": "null" } ], - "description": "Workflow folder path, or null when unavailable." + "description": "Canonical folder path of the workflow, in the same form `folderPaths` accepts as a filter: `/` for a workflow at the workspace root. Null only when the path cannot be resolved — the folder has been deleted, or the workflow itself no longer exists." }, "ownerEmail": { "anyOf": [ diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 8cfb8ed6112..b8e41eb74d9 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -1284,24 +1284,24 @@ "name": "startDate", "in": "query", "required": false, - "description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected.", + "description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.", "schema": { "type": "string", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected." + "description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant." } }, { "name": "endDate", "in": "query", "required": false, - "description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected.", + "description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.", "schema": { "type": "string", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected." + "description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant." } }, { diff --git a/apps/sim/app/api/v2/billing/logs/route.test.ts b/apps/sim/app/api/v2/billing/logs/route.test.ts index 7bb93e6946e..61a795ab7da 100644 --- a/apps/sim/app/api/v2/billing/logs/route.test.ts +++ b/apps/sim/app/api/v2/billing/logs/route.test.ts @@ -146,6 +146,55 @@ describe('GET /api/v2/billing/logs', () => { expect(mocks.execute).not.toHaveBeenCalled() }) + /** + * The envelope check used to accept any string as the inner token, so an + * empty one passed it and then read as falsy in the ledger reader: no cursor + * condition was applied and the caller walked the first page again — the very + * failure {@link UNKNOWN_CURSOR_MESSAGE} exists to make visible. + */ + it('rejects a cursor whose inner token is empty instead of restarting at page one', async () => { + const cursor = ledgerCursor('', { period: 'all' }) + + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/billing/logs?period=all&limit=1&cursor=${encodeURIComponent(cursor)}` + ) + ) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + /** This operation takes neither param, so naming them sends the caller nowhere. */ + it('names the params a rejected cursor is actually bound to', async () => { + const response = await GET( + new NextRequest('http://localhost:3000/api/v2/billing/logs?cursor=not-a-cursor') + ) + + const body = await response.json() + expect(body.error.message).not.toContain('sortBy') + expect(body.error.message).not.toContain('sortOrder') + }) + + /** + * `0000` satisfies the published `\d{4}` date-time pattern but names no + * instant Postgres can store, so the value has to be refused before + * `resolveDateRange` turns it into a bind parameter. + */ + it('rejects a year-0000 custom range bound before it can reach the ledger', async () => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/billing/logs?period=custom&startDate=${encodeURIComponent('0000-01-01T00:00:00Z')}` + ) + ) + + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ + error: { code: 'BAD_REQUEST', message: expect.stringContaining('startDate') }, + }) + expect(mocks.execute).not.toHaveBeenCalled() + }) + it('authenticates before rejecting invalid custom ranges', async () => { const response = await GET( new NextRequest('http://localhost:3000/api/v2/billing/logs?period=custom') diff --git a/apps/sim/app/api/v2/lib/response.ts b/apps/sim/app/api/v2/lib/response.ts index 6e446ba2ced..8134bb4a76c 100644 --- a/apps/sim/app/api/v2/lib/response.ts +++ b/apps/sim/app/api/v2/lib/response.ts @@ -5,7 +5,11 @@ import { cursorScopeKey, REFILTERED_CURSOR_MESSAGE, } from '@/lib/api/cursor-binding' -import { type CursorKey, INVALID_CURSOR_MESSAGE } from '@/lib/api/list-query' +import { + type CursorKey, + INVALID_CURSOR_MESSAGE, + UNREADABLE_CURSOR_MESSAGE, +} from '@/lib/api/list-query' import { getValidationErrorMessage, serializeZodIssues } from '@/lib/api/server' import { ADMISSION_RETRY_AFTER_SECONDS } from '@/lib/core/admission/transient-failure' import { forbiddenErrorDetails } from '@/lib/core/application' @@ -467,6 +471,15 @@ export function encodeScopedCursor(scope: string | undefined, inner: string): st * Unwraps a {@link encodeScopedCursor} token, yielding the domain codec's own * cursor, or `undefined` for page one. A token that is malformed or was minted * under a different query is the canonical 400 — the domain codec never sees it. + * + * An empty inner token is malformed, not "page one". Only an absent `cursor` + * param means page one; a present-but-empty inner passed the old + * `typeof === 'string'` envelope check and then read as falsy in every domain + * reader downstream, so no cursor condition was applied and the caller was + * handed page one again — with a `nextCursor` telling it to keep going. That is + * exactly the loop `UNKNOWN_CURSOR_MESSAGE` describes on the billing ledger, + * reached through the wrapper instead of through the token, and it slipped past + * the unresolvable-cursor 400 that exists to stop it. */ export function readScopedCursor( cursor: string | undefined, @@ -474,8 +487,8 @@ export function readScopedCursor( ): string | undefined { if (!cursor) return undefined const decoded = decodeCursor>(cursor) - if (!decoded || typeof decoded.inner !== 'string') { - throw new OrchestrationError('validation', INVALID_CURSOR_MESSAGE) + if (!decoded || typeof decoded.inner !== 'string' || decoded.inner.length === 0) { + throw new OrchestrationError('validation', UNREADABLE_CURSOR_MESSAGE) } if ((decoded.scope ?? undefined) !== (scope || undefined)) { throw new OrchestrationError('validation', REFILTERED_CURSOR_MESSAGE) diff --git a/apps/sim/app/api/v2/logs/route.test.ts b/apps/sim/app/api/v2/logs/route.test.ts index f12a8c624a0..c600524076b 100644 --- a/apps/sim/app/api/v2/logs/route.test.ts +++ b/apps/sim/app/api/v2/logs/route.test.ts @@ -25,6 +25,7 @@ vi.mock('@/lib/logs/application/list-public-logs', () => ({ })) import { OrchestrationError } from '@/lib/core/orchestration/types' +import { cursorFilterScope, encodeScopedCursor } from '@/app/api/v2/lib/response' import { GET } from '@/app/api/v2/logs/route' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' @@ -193,6 +194,152 @@ describe('GET /api/v2/logs', () => { expect(mocks.execute).not.toHaveBeenCalled() }) + /** + * The envelope check used to accept any string as the inner token, so an + * empty one passed it and then read as falsy in the domain codec: no cursor + * condition was applied and the caller silently got page one back, with a + * `nextCursor` inviting it to do the same thing forever. + */ + it('rejects a cursor whose inner token is empty instead of restarting at page one', async () => { + const cursor = encodeScopedCursor( + cursorFilterScope({ workspaceId: WORKSPACE_ID, order: 'desc' }), + '' + ) + + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&limit=1&cursor=${encodeURIComponent(cursor)}` + ) + ) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + /** Neither param exists on this operation, so naming them sends the caller nowhere. */ + it('names the params a rejected cursor is actually bound to', async () => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&cursor=not-a-cursor` + ) + ) + + const body = await response.json() + expect(body.error.message).not.toContain('sortBy') + expect(body.error.message).not.toContain('sortOrder') + }) + + /** + * `total_duration_ms` is an `integer` column, so a value that is not + * representable as int4 is rejected by Postgres itself — the request has to + * fail at the contract instead of reaching the query. + */ + it.each([ + ['minDurationMs', '1.5'], + ['maxDurationMs', '1.5'], + ['maxDurationMs', '-0.5'], + ['minDurationMs', '1e30'], + ['minDurationMs', '2147483648'], + ['minDurationMs', '999999999999999999999'], + ['maxDurationMs', '-1'], + ])('rejects %s=%s before it can reach the query', async (field, value) => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&${field}=${encodeURIComponent(value)}` + ) + ) + + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ + error: { code: 'BAD_REQUEST', message: expect.stringContaining(field) }, + }) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it.each([ + ['minDurationMs', '0'], + ['maxDurationMs', '1000000'], + ['minDurationMs', '2147483647'], + ])('accepts %s=%s', async (field, value) => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&${field}=${value}` + ) + ) + + expect(response.status).toBe(200) + }) + + /** + * `0000` satisfies the published `\d{4}` date-time pattern but names no + * instant Postgres can store — the proleptic Gregorian calendar has no year + * zero — so the value has to be refused before it becomes a bind parameter. + */ + it.each([['startDate'], ['endDate']])( + 'rejects a year-0000 %s before it can reach the query', + async (field) => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&${field}=${encodeURIComponent('0000-01-01T00:00:00Z')}` + ) + ) + + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ + error: { code: 'BAD_REQUEST', message: expect.stringContaining(field) }, + }) + expect(mocks.execute).not.toHaveBeenCalled() + } + ) + + it('accepts the earliest storable year', async () => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&startDate=${encodeURIComponent('0001-01-01T00:00:00Z')}` + ) + ) + + expect(response.status).toBe(200) + }) + + /** + * `folderPaths=/,` was already a 400 while the sibling comma lists dropped + * the empty entry, so one endpoint answered two ways to the same mistake. + */ + it.each([ + ['workflowIds', 'workflow-1,,workflow-2'], + ['workflowIds', 'workflow-1,'], + ['triggers', 'manual,'], + ['folderPaths', '/,'], + ])('rejects an empty entry in %s=%s', async (field, value) => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&${field}=${encodeURIComponent(value)}` + ) + ) + + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ + error: { code: 'BAD_REQUEST', message: expect.stringContaining(field) }, + }) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + /** A repeated param arrives as an array, which every v2 schema reads as a missing value. */ + it('names duplication when a query param is sent twice', async () => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&workspaceId=${WORKSPACE_ID}` + ) + ) + + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ + error: { code: 'BAD_REQUEST', message: expect.stringContaining('workspaceId was sent') }, + }) + expect(mocks.execute).not.toHaveBeenCalled() + }) + it.each([ ['abc', 'startDate'], ['2026-08-06', 'startDate'], diff --git a/apps/sim/app/api/v2/logs/route.ts b/apps/sim/app/api/v2/logs/route.ts index c186c40b83e..55ccdfa0c2c 100644 --- a/apps/sim/app/api/v2/logs/route.ts +++ b/apps/sim/app/api/v2/logs/route.ts @@ -4,6 +4,7 @@ import { v2ListLogsContract, v2LogStatusSchema, } from '@/lib/api/contracts/v2/logs' +import { UNREADABLE_CURSOR_MESSAGE } from '@/lib/api/list-query' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { OrchestrationError } from '@/lib/core/orchestration/types' import { v2LogErrorPolicies } from '@/lib/logs/api/route-policies' @@ -66,7 +67,7 @@ export const GET = defineV2JsonRoute({ const inner = readScopedCursor(query.cursor, logCursorFilters(query)) const decodedCursor = inner ? decodePublicLogCursor(inner, query.order ?? 'desc') : null if (inner && !decodedCursor) { - throw new OrchestrationError('validation', 'Invalid cursor') + throw new OrchestrationError('validation', UNREADABLE_CURSOR_MESSAGE) } return { workspaceId: query.workspaceId, diff --git a/apps/sim/lib/api/contracts/v2/billing.ts b/apps/sim/lib/api/contracts/v2/billing.ts index ada80300cb1..a9dc48e60e6 100644 --- a/apps/sim/lib/api/contracts/v2/billing.ts +++ b/apps/sim/lib/api/contracts/v2/billing.ts @@ -158,13 +158,13 @@ export const v2BillingLogsQuerySchema = z /** Required when `period` is `'custom'`, and rejected otherwise. */ startDate: v2RunWindowBoundSchema('startDate') .describe( - 'Only include usage events recorded at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. Requires `period=custom`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected.' + 'Only include usage events recorded at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. Requires `period=custom`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.' ) .optional(), /** Defaults to now when omitted for `'custom'`; rejected for every other period. */ endDate: v2RunWindowBoundSchema('endDate') .describe( - 'Only include usage events recorded at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. Requires `period=custom`, and defaults to now when omitted. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected.' + 'Only include usage events recorded at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. Requires `period=custom`, and defaults to now when omitted. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.' ) .optional(), ...v2PaginationFields({ description: 'Maximum usage events per page.' }), diff --git a/apps/sim/lib/api/contracts/v2/logs.ts b/apps/sim/lib/api/contracts/v2/logs.ts index 1e0540648ea..3336ff7a49f 100644 --- a/apps/sim/lib/api/contracts/v2/logs.ts +++ b/apps/sim/lib/api/contracts/v2/logs.ts @@ -157,7 +157,9 @@ export const v2LogDetailSchema = z description: z.string().nullable().describe('Workflow description, or null when unset.'), folderPath: v2FolderPathSchema .nullable() - .describe('Workflow folder path, or null when unavailable.'), + .describe( + 'Canonical folder path of the workflow, in the same form `folderPaths` accepts as a filter: `/` for a workflow at the workspace root. Null only when the path cannot be resolved — the folder has been deleted, or the workflow itself no longer exists.' + ), ownerEmail: z .email() .nullable() @@ -201,40 +203,100 @@ export const v2LogParamsSchema = z.object({ ), }) +/** + * Upper bound of `workflow_execution_logs.total_duration_ms`, whose column is a + * Postgres `integer`. + * + * The same rule `DEPLOYMENT_VERSION_MAX` states for deployment versions: a + * comparison against an `integer` column is an `integer` comparison, so a bound + * outside int4 — or one carrying a fractional part — is not a filter that + * matches nothing, it is a value Postgres refuses to parse. `1.5`, + * `2147483648`, and `1e30` each reached the query as a bind parameter and came + * back as a 500 on a read the caller had every reason to believe was well + * formed. + */ +const V2_DURATION_MS_MAX = 2147483647 + +/** + * A duration bound, in the units and range its column can hold. + * + * Whole milliseconds rather than a coerced `number`, because the column is + * `integer`: publishing `number` invited exactly the fractional value Postgres + * cannot compare. Non-negative for the same reason the column is — a run cannot + * last less than no time — so a negative bound is a caller mistake rather than a + * filter that happens to match everything or nothing. + */ +function v2DurationBoundSchema( + field: 'minDurationMs' | 'maxDurationMs', + bound: 'Minimum' | 'Maximum' +) { + return z.coerce + .number() + .int(`${field} must be a whole number of milliseconds`) + .min(0, `${field} must not be negative`) + .max(V2_DURATION_MS_MAX, `${field} must be at most ${V2_DURATION_MS_MAX}`) + .describe( + `${bound} total execution duration in milliseconds. Whole milliseconds from 0 to ${V2_DURATION_MS_MAX}; the stored duration is a 32-bit integer, so a fractional or out-of-range bound is rejected.` + ) +} + +/** + * A comma-separated filter list, with an empty entry rejected rather than dropped. + * + * `folderPaths` already refused `/,` while its two siblings on the same operation + * silently discarded the empty entry, so one endpoint answered two ways to one + * mistake. Rejecting is the half that matches the surface-wide rule for a blank + * value (`V2_PARSE_DEFAULTS.rejectBlankQueryValues`): dropping it turns a + * malformed list into a narrower filter and reports nothing, which on a log + * search reads as "those runs do not exist". + */ +function v2CommaListSchema(field: 'workflowIds' | 'triggers', description: string) { + return z + .string() + .describe(description) + .refine((value) => value.split(',').every((entry) => entry.length > 0), { + error: `${field} must not contain an empty entry`, + }) +} + export const v2ListLogsQuerySchema = v1ListLogsQuerySchema .omit({ executionId: true, folderIds: true }) .extend({ workspaceId: workspaceIdSchema.describe('Workspace whose execution logs should be returned.'), - workflowIds: z.string().describe('Comma-separated workflow identifiers to include.').optional(), - triggers: z.string().describe('Comma-separated trigger types to include.').optional(), + workflowIds: v2CommaListSchema( + 'workflowIds', + 'Comma-separated workflow identifiers to include. An empty entry is rejected.' + ).optional(), + triggers: v2CommaListSchema( + 'triggers', + 'Comma-separated trigger types to include. An empty entry is rejected. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`.' + ).optional(), level: z.enum(['info', 'error']).describe('Severity level to include.').optional(), startDate: v2RunWindowBoundSchema('startDate').optional(), endDate: v2RunWindowBoundSchema('endDate').optional(), runId: runIdSchema.describe('Exact run identifier to match.').optional(), - minDurationMs: z.coerce - .number() - .describe('Minimum total execution duration in milliseconds.') - .optional(), - maxDurationMs: z.coerce - .number() - .describe('Maximum total execution duration in milliseconds.') - .optional(), + minDurationMs: v2DurationBoundSchema('minDurationMs', 'Minimum').optional(), + maxDurationMs: v2DurationBoundSchema('maxDurationMs', 'Maximum').optional(), minCost: z.coerce.number().describe('Minimum execution cost in USD.').optional(), maxCost: z.coerce.number().describe('Maximum execution cost in USD.').optional(), model: z.string().describe('AI model used during execution.').optional(), details: z .enum(['basic', 'full']) - .describe('Response detail level.') + .describe( + 'Response detail level. `full` adds the `workflow` summary to every item. `includeTraceSpans=true` and `includeFinalOutput=true` each imply `full`, so either one adds `workflow` even when `details=basic` is sent explicitly.' + ) .optional() .default('basic'), includeTraceSpans: booleanQueryFlagSchema .describe( - 'Whether to include block-level trace spans. Spans are stored apart from the log row and pruned on their own retention schedule, so a run whose spans have aged out returns `traceSpans: []` rather than an error — an empty array does not mean the run recorded none.' + 'Whether to include block-level trace spans. Implies `details=full`, so the `workflow` summary is present regardless of what `details` is set to. Spans are stored apart from the log row and pruned on their own retention schedule, so a run whose spans have aged out returns `traceSpans: []` rather than an error — an empty array does not mean the run recorded none.' ) .optional() .default(false), includeFinalOutput: booleanQueryFlagSchema - .describe('Whether to include the final workflow output.') + .describe( + 'Whether to include the final workflow output. Implies `details=full`, so the `workflow` summary is present regardless of what `details` is set to.' + ) .optional() .default(false), ...v2PaginationFields({ diff --git a/apps/sim/lib/api/contracts/v2/shared.ts b/apps/sim/lib/api/contracts/v2/shared.ts index 6b3f4826bcd..32d8a6b1f63 100644 --- a/apps/sim/lib/api/contracts/v2/shared.ts +++ b/apps/sim/lib/api/contracts/v2/shared.ts @@ -375,14 +375,26 @@ export function v2PaginationFields(options: V2LimitOptions = {}) { * reads over the same runs, so the same timestamp must work on both — sharing * the schema is what makes that true rather than merely intended, and it is why * the descriptions say "UTC ISO 8601" instead of overpromising "ISO 8601". + * + * Format alone is not enough, which is why the year is checked on top of it. + * `date-time` publishes a four-digit year, so `0000-01-01T00:00:00Z` is a + * spec-valid value that `Date` parses happily — but the proleptic Gregorian + * calendar Postgres implements has no year zero, so the resulting bind parameter + * is refused by the server rather than by anything in the request path, and the + * caller sees a 500 for a request the published schema told it to send. Year + * `0001` upward is storable and stays accepted, which leaves `0000` the single + * value the format admits and the column cannot hold. */ export function v2RunWindowBoundSchema(field: 'startDate' | 'endDate') { const boundary = field === 'startDate' ? 'at or after' : 'at or before' return z .string() .datetime({ error: `${field} must be a UTC ISO 8601 timestamp, e.g. 2026-08-06T00:00:00Z` }) + .refine((value) => new Date(value).getUTCFullYear() >= 1, { + error: `${field} must name a storable instant; there is no year 0000`, + }) .describe( - `Only include runs started ${boundary} this UTC ISO 8601 timestamp, e.g. \`2026-08-06T00:00:00Z\`. A date without a time, or a timestamp carrying a UTC offset instead of \`Z\`, is rejected.` + `Only include runs started ${boundary} this UTC ISO 8601 timestamp, e.g. \`2026-08-06T00:00:00Z\`. A date without a time, or a timestamp carrying a UTC offset instead of \`Z\`, is rejected, as is year \`0000\`, which names no storable instant.` ) .meta({ format: 'date-time' }) } diff --git a/apps/sim/lib/api/list-query.ts b/apps/sim/lib/api/list-query.ts index e45560a78fc..8de86cd6575 100644 --- a/apps/sim/lib/api/list-query.ts +++ b/apps/sim/lib/api/list-query.ts @@ -57,6 +57,20 @@ export type CursorKey = string | number export const INVALID_CURSOR_MESSAGE = 'cursor does not match the requested sortBy/sortOrder. Restart pagination without a cursor after changing the sort.' +/** + * Caller-facing message for a cursor that cannot be read back at all. + * + * Separate from {@link INVALID_CURSOR_MESSAGE} because that one names + * `sortBy`/`sortOrder`, and the lists that mint a wrapped domain token accept + * neither param — `GET /logs` carries its direction in `order`, and + * `GET /billing/logs` takes no sort param whatsoever. Sending those callers to + * inspect a knob their operation does not have is the same wrong-signpost + * problem `UNKNOWN_CURSOR_MESSAGE` was written to avoid on the ledger. The + * actionable half — restart without a cursor — is identical. + */ +export const UNREADABLE_CURSOR_MESSAGE = + 'cursor is not a readable pagination token. Restart pagination without a cursor; a cursor is only valid for the request that issued it.' + /** * One column of a keyset ordering, with the codec that moves its value through * the opaque cursor. diff --git a/apps/sim/lib/api/server/blank-query-values.ts b/apps/sim/lib/api/server/blank-query-values.ts index 18b5bf03090..76d83c985c0 100644 --- a/apps/sim/lib/api/server/blank-query-values.ts +++ b/apps/sim/lib/api/server/blank-query-values.ts @@ -45,3 +45,35 @@ export function blankQueryValueValidationError( } return null } + +/** + * Rejects a query parameter sent more than once — `?workspaceId=X&workspaceId=X`. + * + * A repeated parameter reaches the schema as an array, and no v2 query parameter + * is declared as one: every list this surface accepts is a single + * comma-separated string. The array therefore fails the declared type, and the + * caller is told whatever that type's own message says — `workspaceId` answers + * "Workspace ID is required" for a request that sent it twice, which points at + * the wrong problem and reads as a server bug. + * + * Naming the duplication is the whole fix, and the boundary is where it belongs + * for the same reason as the blank scan above: the multiplicity exists only in + * the raw query. By the time a schema sees the value, the array is + * indistinguishable from any other wrong type. + */ +export function duplicateQueryValueValidationError( + rawQuery: Record +): ZodError | null { + for (const [name, value] of Object.entries(rawQuery)) { + if (!Array.isArray(value)) continue + return new ZodError([ + { + code: 'custom', + path: [name], + message: `${name} was sent ${value.length} times; send it at most once`, + input: undefined, + }, + ]) + } + return null +} diff --git a/apps/sim/lib/api/server/routes/v2-json-route.ts b/apps/sim/lib/api/server/routes/v2-json-route.ts index af5641ed9c6..2a4b3f0adec 100644 --- a/apps/sim/lib/api/server/routes/v2-json-route.ts +++ b/apps/sim/lib/api/server/routes/v2-json-route.ts @@ -149,6 +149,15 @@ export const V2_PARSE_DEFAULTS = { * time a schema sees the value. */ rejectBlankQueryValues: true, + /** + * `?workspaceId=X&workspaceId=X` is not `workspaceId=X`, and no v2 query + * param is declared as an array — every list on this surface is one + * comma-separated string — so a repeated param can only ever be a caller + * mistake. Without this it reached the schema as an array and drew that + * param's *absence* message ("Workspace ID is required") for a request that + * plainly sent it, which is a signpost pointing away from the actual error. + */ + rejectDuplicateQueryValues: true, } as const export interface V2ErrorPolicy { diff --git a/apps/sim/lib/api/server/validation.ts b/apps/sim/lib/api/server/validation.ts index 9f890a2d86d..25c7a9271ab 100644 --- a/apps/sim/lib/api/server/validation.ts +++ b/apps/sim/lib/api/server/validation.ts @@ -8,7 +8,10 @@ import type { ContractParams, ContractQuery, } from '@/lib/api/contracts' -import { blankQueryValueValidationError } from '@/lib/api/server/blank-query-values' +import { + blankQueryValueValidationError, + duplicateQueryValueValidationError, +} from '@/lib/api/server/blank-query-values' import { nulByteValidationError } from '@/lib/api/server/nul-bytes' import { env } from '@/lib/core/config/env' import { @@ -71,6 +74,14 @@ export interface ParseRequestOptions { * send blanks today, is unaffected. */ rejectBlankQueryValues?: boolean + /** + * Reject a query parameter sent more than once, instead of letting the + * resulting array fail the declared type with a message about the parameter + * being absent. See {@link duplicateQueryValueValidationError}. Opt-in on the + * same terms as {@link rejectBlankQueryValues}, and only sound where no query + * parameter is declared as an array — which is true of the whole v2 surface. + */ + rejectDuplicateQueryValues?: boolean } export function serializeZodIssues(error: z.ZodError): z.core.$ZodIssue[] { @@ -286,6 +297,18 @@ export async function parseRequest( body = parsedBody.data } + if (options?.rejectDuplicateQueryValues) { + const duplicated = duplicateQueryValueValidationError(rawQuery) + if (duplicated) { + return { + success: false, + response: options.validationErrorResponse + ? options.validationErrorResponse(duplicated) + : validationErrorResponse(duplicated), + } + } + } + if (options?.rejectBlankQueryValues) { const blank = blankQueryValueValidationError(rawQuery) if (blank) { diff --git a/apps/sim/lib/logs/api/route-policies.ts b/apps/sim/lib/logs/api/route-policies.ts index aa1e8da8cf1..1f97c566656 100644 --- a/apps/sim/lib/logs/api/route-policies.ts +++ b/apps/sim/lib/logs/api/route-policies.ts @@ -3,6 +3,27 @@ import { v2OrchestrationErrorPolicy, } from '@/lib/api/server/routes' +/** + * `GET /logs` and `GET /billing/logs` both take a caller-named `workspaceId` and + * answer differently when the caller cannot reach it: this list projects the + * refusal as 403, while the billing family conceals it as 404. The split is + * deliberate rather than an oversight, and worth stating because it is visible + * to anyone probing both. + * + * 403 is the v2 default for a workspace-scoped read — every sibling list that + * takes a `workspaceId` answers this way. It costs an existence bit on a random + * workspace UUID, which is a weak oracle: a workspace API key cannot name a + * workspace other than its own at all, so only a personal key can ask the + * question, and it learns nothing beyond "this id exists". In exchange, a caller + * that genuinely lost access to a workspace it already knows is told so, instead + * of being sent to hunt for a resource sitting right there. + * + * The billing family declines that trade because what it reports is a payer + * rather than workspace content; its own policy states why. Moving this list to + * 404 to match would leave it disagreeing with `GET /workflows`, `GET /tables`, + * and every other read over the same workspace — trading one visible + * inconsistency for a larger one. + */ export const v2LogErrorPolicies = { default: v2OrchestrationErrorPolicy, concealDetailAuthorization: createV2ResourceConcealmentPolicy({ diff --git a/apps/sim/lib/logs/application/get-public-log.ts b/apps/sim/lib/logs/application/get-public-log.ts index 0accc6b76e0..d1cba6648f4 100644 --- a/apps/sim/lib/logs/application/get-public-log.ts +++ b/apps/sim/lib/logs/application/get-public-log.ts @@ -1,5 +1,6 @@ import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { ROOT_FOLDER_PATH } from '@/lib/folders/paths' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { logOperations } from '@/lib/logs/application/operations' import { materializeExecutionDataForDisplay } from '@/lib/logs/execution/trace-store' @@ -26,10 +27,37 @@ export interface GetPublicLogResult { log: Omit & { workflowState: Record | null } + /** + * The run's workflow folder as a canonical path — `/` at the workspace root, + * matching what the workflow resources report for the same workflow — or + * `null` when no path can be resolved for it. + * + * The two used to collapse into `null`, which made the field unreadable in + * both directions: a caller could not tell a root-level workflow from one + * whose folder had aged out, and `null` is not a value `folderPaths` would + * take back as a filter. + */ workflowFolderPath: string | null executionData: Record } +/** + * A run's folder path, distinguishing the root from an unresolvable folder. + * + * Deliberately not `workflowFolderPathForId`, which throws on a folder missing + * from the index. That is right for a workflow read, where an unresolvable + * folder means the caller's own tree is inconsistent; it is wrong for a + * diagnostic log read, where the run may long outlive the folder it ran in and a + * 500 would withhold the whole run over one unresolvable field. + */ +function publicLogFolderPath( + pathById: ReadonlyMap, + folderId: string | null +): string | null { + if (!folderId) return ROOT_FOLDER_PATH + return pathById.get(folderId) ?? null +} + export const getPublicLog = defineAuthorizedWorkspaceUseCase({ operation: logOperations.readDetail, resolveContext: async ({ input }: { input: GetPublicLogInput }): Promise => { @@ -63,9 +91,7 @@ export const getPublicLog = defineAuthorizedWorkspaceUseCase({ } return { log: { ...log, workflowState: sanitizeExecutionSnapshotState(log.workflowState) }, - workflowFolderPath: log.workflowFolderId - ? (folderIndex.pathById.get(log.workflowFolderId) ?? null) - : null, + workflowFolderPath: publicLogFolderPath(folderIndex.pathById, log.workflowFolderId), executionData, } }, diff --git a/apps/sim/lib/logs/application/public-log-use-cases.test.ts b/apps/sim/lib/logs/application/public-log-use-cases.test.ts index d30000748dd..879afac86ea 100644 --- a/apps/sim/lib/logs/application/public-log-use-cases.test.ts +++ b/apps/sim/lib/logs/application/public-log-use-cases.test.ts @@ -169,6 +169,34 @@ describe('public log application use cases', () => { expect(mocks.recordAudit).not.toHaveBeenCalled() }) + /** + * `null` used to stand for both "at the workspace root" and "the path could + * not be resolved", so a caller could tell neither apart nor feed the value + * back to `folderPaths`. The root is `/`, exactly as the workflow resources + * report it for the same workflow. + */ + it('reports the workspace root as a path a folderPaths filter would accept', async () => { + mocks.getLog.mockResolvedValueOnce({ ...log, workflowFolderId: null }) + + const result = await getPublicLog.execute({ + principal: workspacePrincipal, + input: { runId: 'run-1' }, + }) + + expect(result.workflowFolderPath).toBe('/') + }) + + it('keeps null for a folder whose path cannot be resolved', async () => { + mocks.getLog.mockResolvedValueOnce({ ...log, workflowFolderId: 'folder-archived' }) + + const result = await getPublicLog.execute({ + principal: workspacePrincipal, + input: { runId: 'run-1' }, + }) + + expect(result.workflowFolderPath).toBeNull() + }) + it('redacts credential values from the run snapshot', async () => { mocks.getLog.mockResolvedValueOnce({ ...log, diff --git a/apps/sim/lib/logs/public-filters.ts b/apps/sim/lib/logs/public-filters.ts index 639ba77ca12..38a77ed15a9 100644 --- a/apps/sim/lib/logs/public-filters.ts +++ b/apps/sim/lib/logs/public-filters.ts @@ -6,6 +6,17 @@ export interface LogFilters { workspaceId: string workflowIds?: string[] folderIds?: string[] + /** + * Trigger types to include. `all` is a sentinel — a list containing it + * disables this filter entirely rather than matching a trigger of that name. + * + * It is safe because `all` is modelled as a sentinel rather than a value: + * `TriggerType` in `stores/logs/filters/types.ts` adds it alongside + * `CoreTriggerType`, which never contains it, so no run is recorded under it. + * It does mean the filterable vocabulary is one name smaller than the + * column's, which is why the public `triggers` param documents the sentinel + * instead of leaving a caller to discover it. + */ triggers?: string[] level?: 'info' | 'error' startDate?: Date diff --git a/apps/sim/lib/logs/public-queries.ts b/apps/sim/lib/logs/public-queries.ts index ada0d01b73d..039a8afe3d2 100644 --- a/apps/sim/lib/logs/public-queries.ts +++ b/apps/sim/lib/logs/public-queries.ts @@ -21,6 +21,16 @@ export function encodePublicLogCursor(cursor: PublicLogCursor): string { return Buffer.from(JSON.stringify(cursor)).toString('base64') } +/** + * Reads the keyset this list resumes from, or `null` for a token that names no + * position. + * + * `id` is checked for content rather than only for type: it is one half of the + * `(startedAt, id)` tuple the query compares against, so an empty one is a + * position no row can sit after, and accepting it would answer a truncated page + * as though it were a complete one. It is the same looseness the wrapping + * envelope had — see `readScopedCursor` — one layer down. + */ export function decodePublicLogCursor( cursor: string, expectedOrder: 'asc' | 'desc' @@ -31,6 +41,7 @@ export function decodePublicLogCursor( if ( typeof parsed.startedAt !== 'string' || typeof parsed.id !== 'string' || + parsed.id.length === 0 || (order !== 'asc' && order !== 'desc') || order !== expectedOrder ) { From 0748d10243500131538e8d19ab05eab31c2f7311 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 20:07:06 -0700 Subject: [PATCH 25/56] fix(v2): pin naive timestamps to UTC and close six contract divergences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Application-written timestamps reached the wire as a local wall clock labelled `Z`. Every column in `schema.ts` is `timestamp without time zone`, so the instant a value denotes was decided by whoever wrote it and whoever read it, and the writers disagreed: `now()` renders in the session's TimeZone, drizzle's `mapToDriverValue` is `toISOString()`, and a raw `Date` bound through postgres.js is cast down in the session's TimeZone. The read side disagreed the same way — postgres.js parses oid 1114 with `new Date(x)`, which is the process's local zone, while a value it hands back as a string is read as UTC by drizzle. The result passes every `date-time` check, so it silently corrupts sorts and range predicates and can place `updatedAt` before its own `createdAt`. `packages/db/timestamps.ts` removes the ambiguity at the driver boundary rather than at the call sites: the session TimeZone is pinned to UTC so all three write paths store the same wall clock, and oid 1114 is parsed as UTC so every read path recovers that instant. `withUtcTimestamps` merges both into a client's options, because `connection` is nested and a pool setting its own `application_name` would otherwise drop the TimeZone. Production already runs both in UTC, so nothing changes there; every other environment now behaves the way production does. Alongside it, six places where the published contract and the code disagreed: - Multi-select `ncontains` was documented as "the exception" that excludes nulls. It never did, and no test claimed it did — `data` is never NULL, so containment is false for an absent key and the negation is true, exactly like every other negation. The sentence was wrong. - `recursive` published twelve lowercase spellings while `z.stringbool()` folded case, so the server honoured `recursive=True` as a destructive recursive delete that a generated client would have refused to send. Narrowed to case-sensitive: accept exactly what is published. - The upload data plane answered with a bare `{ error: string }`. Being absent from the OpenAPI documents is a statement about addressability, not about behaviour; both PUTs now use the canonical envelope, and what the transfer step promises is published on `transfer.url`. - Full-set lists told callers to "send it back as `cursor`" on a `.strict()` query that rejects `cursor`. `v2CursorListResponse` now takes `paged`. - A `HEAD` on a download skips the read that produces `Content-Length`, so it cannot size a download; the description says so. - The upsert conflict-target rejection echoed the storage id a name-keyed surface had already translated to, and the scoped-cursor 400 named `sortBy`/`sortOrder` params `/audit-logs` does not accept. --- apps/docs/openapi-v2-files-audit.json | 12 +-- apps/docs/openapi-v2-knowledge.json | 14 +-- apps/docs/openapi-v2-resources.json | 4 +- apps/docs/openapi-v2-tables.json | 16 +-- apps/docs/openapi-v2-workflows.json | 8 +- apps/realtime/src/database/operations.ts | 25 +++-- apps/sim/app/api/v2/lib/response.ts | 18 +++- .../parts/[partNumber]/route.test.ts | 6 +- .../[uploadId]/parts/[partNumber]/route.ts | 40 ++++--- .../api/v2/uploads/[uploadId]/route.test.ts | 18 +++- .../app/api/v2/uploads/[uploadId]/route.ts | 49 ++++++--- .../api/contracts/tables-predicate.test.ts | 16 +++ apps/sim/lib/api/contracts/tables.ts | 8 +- .../v2/__tests__/list-pagination.test.ts | 45 ++++++++ .../api/contracts/v2/__tests__/shared.test.ts | 28 ++--- apps/sim/lib/api/contracts/v2/files.ts | 2 +- apps/sim/lib/api/contracts/v2/knowledge.ts | 4 +- apps/sim/lib/api/contracts/v2/mcp-servers.ts | 2 +- .../api/contracts/v2/openapi/files-audit.ts | 3 +- .../v2/openapi/head-not-safe.test.ts | 26 ++++- .../lib/api/contracts/v2/openapi/knowledge.ts | 2 +- .../lib/api/contracts/v2/openapi/shared.ts | 16 ++- apps/sim/lib/api/contracts/v2/shared.ts | 64 ++++++++--- apps/sim/lib/api/contracts/v2/tables.ts | 6 +- apps/sim/lib/api/contracts/v2/uploads.ts | 22 +++- apps/sim/lib/api/contracts/v2/workflows.ts | 5 +- apps/sim/lib/api/cursor-binding.test.ts | 11 +- apps/sim/lib/api/cursor-binding.ts | 14 +++ apps/sim/lib/table/__tests__/sql.test.ts | 12 ++- .../lib/table/__tests__/update-row.test.ts | 33 ++++++ apps/sim/lib/table/rows/service.ts | 12 ++- packages/db/db.ts | 18 ++-- packages/db/package.json | 4 + packages/db/timestamps.test.ts | 81 ++++++++++++++ packages/db/timestamps.ts | 100 ++++++++++++++++++ scripts/check-openapi-specs.ts | 9 +- 36 files changed, 627 insertions(+), 126 deletions(-) create mode 100644 packages/db/timestamps.test.ts create mode 100644 packages/db/timestamps.ts diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 447ccfea359..946dd197e47 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -605,7 +605,7 @@ "get": { "operationId": "downloadFile", "summary": "Download File", - "description": "Download the current file bytes from a workspace. A generated document is served as its compiled artifact, so it answers `409` while that artifact is still compiling and `413` if it renders past the size ceiling. Downloading records an audit event, so it is not a safe read. A `HEAD` skips the effect but is authorized exactly as the `GET` is, so it answers `400`, `401`, `403`, or `404` wherever the `GET` would and an empty `200` otherwise.", + "description": "Download the current file bytes from a workspace. A generated document is served as its compiled artifact, so it answers `409` while that artifact is still compiling and `413` if it renders past the size ceiling. Downloading records an audit event, so it is not a safe read. A `HEAD` skips the effect but is authorized exactly as the `GET` is, so it answers `400`, `401`, `403`, or `404` wherever the `GET` would and an empty `200` otherwise. Skipping the effect means skipping the read that produces the payload, so that `200` carries none of the response headers documented below — it answers whether the `GET` would be allowed, not what the `GET` would return. In particular a `HEAD` does not report `Content-Length`, so it cannot be used to size a download in advance; read the size from the file resource instead.", "tags": ["Files"], "parameters": [ { @@ -1915,9 +1915,9 @@ "name": "recursive", "in": "query", "required": false, - "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. Any listed spelling is accepted in any case, and any other value is rejected.", + "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", "schema": { - "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. Any listed spelling is accepted in any case, and any other value is rejected.", + "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", "enum": [ "true", "1", @@ -2553,7 +2553,7 @@ "url": { "type": "string", "format": "uri", - "description": "Signed URL to which the file bytes are uploaded." + "description": "Signed URL to which the file bytes are uploaded. Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. Success is `204` with an empty body. A failure is the same `{ \"error\": { \"code\", \"message\" } }` envelope as every other v2 response: `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. The URL is signed and self-describing — construct it from this field only, never by hand." }, "headers": { "type": "object", @@ -2706,7 +2706,7 @@ "url": { "type": "string", "format": "uri", - "description": "Signed URL for this upload part." + "description": "Signed URL for this upload part. Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. Success is `204` with an empty body. A failure is the same `{ \"error\": { \"code\", \"message\" } }` envelope as every other v2 response: `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. The URL is signed and self-describing — construct it from this field only, never by hand." }, "headers": { "type": "object", @@ -3650,7 +3650,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." } }, "required": ["data", "nextCursor"], diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index e44f631798d..4f299d59813 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -1898,9 +1898,9 @@ "name": "recursive", "in": "query", "required": false, - "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. Any listed spelling is accepted in any case, and any other value is rejected.", + "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", "schema": { - "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. Any listed spelling is accepted in any case, and any other value is rejected.", + "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", "enum": [ "true", "1", @@ -2902,7 +2902,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." } }, "required": ["data", "nextCursor"], @@ -3362,7 +3362,7 @@ "url": { "type": "string", "format": "uri", - "description": "Signed URL to which the file bytes are uploaded." + "description": "Signed URL to which the file bytes are uploaded. Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. Success is `204` with an empty body. A failure is the same `{ \"error\": { \"code\", \"message\" } }` envelope as every other v2 response: `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. The URL is signed and self-describing — construct it from this field only, never by hand." }, "headers": { "type": "object", @@ -3565,7 +3565,7 @@ "url": { "type": "string", "format": "uri", - "description": "Signed URL for this upload part." + "description": "Signed URL for this upload part. Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. Success is `204` with an empty body. A failure is the same `{ \"error\": { \"code\", \"message\" } }` envelope as every other v2 response: `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. The URL is signed and self-describing — construct it from this field only, never by hand." }, "headers": { "type": "object", @@ -4064,13 +4064,13 @@ "type": "null" } ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." } }, "required": ["data", "nextCursor"], "additionalProperties": false, "title": "Knowledge folder list response", - "description": "A cursor-paginated page of knowledge-base folders." + "description": "The whole bounded set of knowledge-base folders, in one page." }, "V2KnowledgeFolderResponse": { "type": "object", diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 7bf8aeb439d..aa04de79ab6 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -629,7 +629,7 @@ "get": { "operationId": "listMcpServerTools", "summary": "List MCP Server Tools", - "description": "Connect to a registered MCP server and return the tools it exposes, completing onboarding without opening the Sim UI. Unlike most reads this one has side effects: it opens a live connection to the third-party server and writes `connectionStatus`, `toolCount`, `lastError`, and `lastToolsRefresh` on the server resource. A `HEAD` skips the effect but is authorized exactly as the `GET` is, so it answers `400`, `401`, `403`, or `404` wherever the `GET` would and an empty `200` otherwise. Discovery itself bounds the set at 1,000 tools and 5 MB of tool payload per server. The bounded set is returned in one page with `nextCursor` always null; there is no second page to fetch. An unreachable, slow, or cooling-down server is a `503`; a stored OAuth grant that no longer works is a `409` with `error.details.code` `MCP_SERVER_REAUTHORIZATION_REQUIRED`, which only a human reauthorizing in Sim can clear. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.", + "description": "Connect to a registered MCP server and return the tools it exposes, completing onboarding without opening the Sim UI. Unlike most reads this one has side effects: it opens a live connection to the third-party server and writes `connectionStatus`, `toolCount`, `lastError`, and `lastToolsRefresh` on the server resource. A `HEAD` skips the effect but is authorized exactly as the `GET` is, so it answers `400`, `401`, `403`, or `404` wherever the `GET` would and an empty `200` otherwise. Skipping the effect means skipping the read that produces the payload, so that `200` carries none of the response headers documented below — it answers whether the `GET` would be allowed, not what the `GET` would return. Discovery itself bounds the set at 1,000 tools and 5 MB of tool payload per server. The bounded set is returned in one page with `nextCursor` always null; there is no second page to fetch. An unreachable, slow, or cooling-down server is a `503`; a stored OAuth grant that no longer works is a `409` with `error.details.code` `MCP_SERVER_REAUTHORIZATION_REQUIRED`, which only a human reauthorizing in Sim can clear. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.", "tags": ["MCP Servers"], "parameters": [ { @@ -3071,7 +3071,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." } }, "required": ["data", "nextCursor"], diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 3add5807255..ae3328b95e7 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -3665,9 +3665,9 @@ "name": "recursive", "in": "query", "required": false, - "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. Any listed spelling is accepted in any case, and any other value is rejected.", + "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", "schema": { - "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. Any listed spelling is accepted in any case, and any other value is rejected.", + "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", "enum": [ "true", "1", @@ -5022,7 +5022,7 @@ }, "TablePredicate": { "title": "Table predicate", - "description": "Recursive predicate tree. Each group node is exactly one non-empty `all` or `any` array whose members are further groups or `{ field, op, value }` conditions; the root must be a group, not a bare condition. At most 100 members per group, 10 levels of nesting, and 500 nodes in total. The negating operators include nulls: `ne`, `nin`, `ncontains`, `nlike`, and `nilike` match rows whose column is null or absent, so \"not X\" is not the complement of \"X\" over a nullable column. Multi-select `ncontains` is the exception and excludes nulls. Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Membership: `in`, `nin` (array operand). Emptiness: `isEmpty`, `isNotEmpty`, `isNull`, `isNotNull` (no operand). Substring, always case-insensitive, operand matched literally: `contains`, `ncontains`, `startsWith`, `endsWith`. Pattern: `like`/`nlike` (case-sensitive), `ilike`/`nilike` (case-insensitive). **`*` is the only wildcard** and stands for any run of characters; `%`, `_`, and backslash match themselves. Use `like: \"Hi*\"`, not `like: \"Hi%\"`. A `select` column compares by option id and restricts its operators: single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names are accepted as operands and resolved to ids.", + "description": "Recursive predicate tree. Each group node is exactly one non-empty `all` or `any` array whose members are further groups or `{ field, op, value }` conditions; the root must be a group, not a bare condition. At most 100 members per group, 10 levels of nesting, and 500 nodes in total. The negating operators include nulls: `ne`, `nin`, `ncontains`, `nlike`, and `nilike` match rows whose column is null or absent, so \"not X\" is not the complement of \"X\" over a nullable column. That holds for every column type, multi-select included. To exclude nulls, `all`-combine the negation with `isNotEmpty` (multi-select) or `isNotNull`. Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Membership: `in`, `nin` (array operand). Emptiness: `isEmpty`, `isNotEmpty`, `isNull`, `isNotNull` (no operand). Substring, always case-insensitive, operand matched literally: `contains`, `ncontains`, `startsWith`, `endsWith`. Pattern: `like`/`nlike` (case-sensitive), `ilike`/`nilike` (case-insensitive). **`*` is the only wildcard** and stands for any run of characters; `%`, `_`, and backslash match themselves. Use `like: \"Hi*\"`, not `like: \"Hi%\"`. A `select` column compares by option id and restricts its operators: single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names are accepted as operands and resolved to ids.", "type": "object", "oneOf": [ { @@ -5724,7 +5724,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." } }, "required": ["data", "nextCursor"], @@ -6371,7 +6371,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." } }, "required": ["data", "nextCursor"], @@ -7398,7 +7398,7 @@ "url": { "type": "string", "format": "uri", - "description": "Signed URL to which the file bytes are uploaded." + "description": "Signed URL to which the file bytes are uploaded. Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. Success is `204` with an empty body. A failure is the same `{ \"error\": { \"code\", \"message\" } }` envelope as every other v2 response: `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. The URL is signed and self-describing — construct it from this field only, never by hand." }, "headers": { "type": "object", @@ -7966,7 +7966,7 @@ "url": { "type": "string", "format": "uri", - "description": "Signed URL for this upload part." + "description": "Signed URL for this upload part. Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. Success is `204` with an empty body. A failure is the same `{ \"error\": { \"code\", \"message\" } }` envelope as every other v2 response: `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. The URL is signed and self-describing — construct it from this field only, never by hand." }, "headers": { "type": "object", @@ -8367,7 +8367,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." } }, "required": ["data", "nextCursor"], diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 8cfb8ed6112..f2842c8e9a3 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -951,7 +951,7 @@ "get": { "operationId": "exportWorkflow", "summary": "Export Workflow", - "description": "Export a portable, secret-sanitized workflow. Workspace-scoped bindings must be selected again after import. Exporting records an audit event, so it is not a safe read. A `HEAD` skips the effect but is authorized exactly as the `GET` is, so it answers `400`, `401`, `403`, or `404` wherever the `GET` would and an empty `200` otherwise. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", + "description": "Export a portable, secret-sanitized workflow. Workspace-scoped bindings must be selected again after import. Exporting records an audit event, so it is not a safe read. A `HEAD` skips the effect but is authorized exactly as the `GET` is, so it answers `400`, `401`, `403`, or `404` wherever the `GET` would and an empty `200` otherwise. Skipping the effect means skipping the read that produces the payload, so that `200` carries none of the response headers documented below — it answers whether the `GET` would be allowed, not what the `GET` would return. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", "tags": ["Workflows"], "parameters": [ { @@ -1981,9 +1981,9 @@ "name": "recursive", "in": "query", "required": false, - "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. Any listed spelling is accepted in any case, and any other value is rejected.", + "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", "schema": { - "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. Any listed spelling is accepted in any case, and any other value is rejected.", + "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", "enum": [ "true", "1", @@ -4779,7 +4779,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." } }, "required": ["data", "nextCursor"], diff --git a/apps/realtime/src/database/operations.ts b/apps/realtime/src/database/operations.ts index 1cf601ae965..4e7baa85d78 100644 --- a/apps/realtime/src/database/operations.ts +++ b/apps/realtime/src/database/operations.ts @@ -8,6 +8,7 @@ import { workflowEdges, workflowSubflows, } from '@sim/db' +import { withUtcTimestamps } from '@sim/db/timestamps' import { createLogger } from '@sim/logger' import { getActiveWorkflowContext } from '@sim/platform-authz/workflow' import { @@ -222,16 +223,20 @@ const connectionString = // Realtime process footprint = this socketDb pool + the shared @sim/db pool. const socketDb = drizzle( instrumentPoolClient( - postgres(connectionString, { - prepare: false, - // See `packages/db/db.ts` — skips the per-connection pg_type roundtrip. - fetch_types: false, - idle_timeout: 10, - connect_timeout: 20, - max: 10, - onnotice: () => {}, - connection: { application_name: process.env.DB_APP_NAME ?? 'sim-realtime' }, - }), + postgres( + connectionString, + // `withUtcTimestamps` — see `packages/db/timestamps.ts`. + withUtcTimestamps({ + prepare: false, + // See `packages/db/db.ts` — skips the per-connection pg_type roundtrip. + fetch_types: false, + idle_timeout: 10, + connect_timeout: 20, + max: 10, + onnotice: () => {}, + connection: { application_name: process.env.DB_APP_NAME ?? 'sim-realtime' }, + }) + ), 'socketDb' ), { schema } diff --git a/apps/sim/app/api/v2/lib/response.ts b/apps/sim/app/api/v2/lib/response.ts index 6e446ba2ced..4710600f41c 100644 --- a/apps/sim/app/api/v2/lib/response.ts +++ b/apps/sim/app/api/v2/lib/response.ts @@ -4,6 +4,7 @@ import { type CursorScopePart, cursorScopeKey, REFILTERED_CURSOR_MESSAGE, + UNREADABLE_CURSOR_MESSAGE, } from '@/lib/api/cursor-binding' import { type CursorKey, INVALID_CURSOR_MESSAGE } from '@/lib/api/list-query' import { getValidationErrorMessage, serializeZodIssues } from '@/lib/api/server' @@ -218,6 +219,21 @@ export function v2HttpError(error: HttpError): NextResponse { return v2Error(code, error.message) } +/** + * The 500 of the local-storage upload data plane, in the canonical envelope. + * + * `PUT /api/v2/uploads/{uploadId}` and its `/parts/{partNumber}` sibling are + * deliberately outside the public OpenAPI documents (see + * `UNDOCUMENTED_V2_ROUTES`), but they are still v2 routes a caller reaches + * through a URL a documented operation handed it. Being undocumented is a + * reason not to publish them; it was never a reason to answer in a different + * error shape. They do not run `admitV2Request`, so they cannot reuse the JSON + * builder's handler — this is the one piece of it they need. + */ +export function v2UploadDataPlaneError(): NextResponse { + return v2Error('INTERNAL_ERROR', 'Internal server error') +} + /** Render a contract `ZodError` as the v2 error envelope. */ export function v2ValidationError(error: ZodError): NextResponse { return v2Error('BAD_REQUEST', getValidationErrorMessage(error, 'Invalid request'), { @@ -475,7 +491,7 @@ export function readScopedCursor( if (!cursor) return undefined const decoded = decodeCursor>(cursor) if (!decoded || typeof decoded.inner !== 'string') { - throw new OrchestrationError('validation', INVALID_CURSOR_MESSAGE) + throw new OrchestrationError('validation', UNREADABLE_CURSOR_MESSAGE) } if ((decoded.scope ?? undefined) !== (scope || undefined)) { throw new OrchestrationError('validation', REFILTERED_CURSOR_MESSAGE) diff --git a/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.test.ts b/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.test.ts index f5cc527da7d..c63a810e141 100644 --- a/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.test.ts +++ b/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.test.ts @@ -70,7 +70,7 @@ describe('PUT /api/v2/uploads/[uploadId]/parts/[partNumber]', () => { expect(response.status).toBe(400) await expect(response.json()).resolves.toMatchObject({ - error: 'Part 1 has 2 bytes; expected 3', + error: { code: 'BAD_REQUEST', message: 'Part 1 has 2 bytes; expected 3' }, }) }) @@ -93,7 +93,9 @@ describe('PUT /api/v2/uploads/[uploadId]/parts/[partNumber]', () => { const response = await request() expect(response.status).toBe(409) - await expect(response.json()).resolves.toEqual({ error: 'Upload session has expired' }) + await expect(response.json()).resolves.toEqual({ + error: { code: 'CONFLICT', message: 'Upload session has expired' }, + }) expect(mockExpectedUploadPartSize).not.toHaveBeenCalled() expect(mockWriteLocalMultipartPart).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.ts b/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.ts index 934e64d839e..d60c52ca30d 100644 --- a/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.ts +++ b/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.ts @@ -1,6 +1,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { localUploadPartContract } from '@/lib/api/contracts/upload-sessions' import { parseRequest } from '@/lib/api/server' +import { V2_PARSE_DEFAULTS } from '@/lib/api/server/routes' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { LocalUploadBodyError, @@ -11,6 +12,12 @@ import { type UploadSessionRecord, verifyUploadSessionToken, } from '@/lib/uploads/upload-session/service' +import { + v2Error, + v2HttpError, + v2UploadDataPlaneError, + v2ValidationError, +} from '@/app/api/v2/lib/response' interface LocalPartRouteParams { params: Promise<{ uploadId: string; partNumber: string }> @@ -19,6 +26,11 @@ interface LocalPartRouteParams { /** * Local-storage data plane for signed multipart PUT URLs. Cloud deployments return provider URLs * instead, so this route is never in the cloud byte path. + * + * Absent from the public OpenAPI documents by design — see + * `UNDOCUMENTED_V2_ROUTES` in `scripts/check-openapi-specs.ts` — but it answers + * in the canonical `{ error: { code, message } }` envelope like the rest of the + * surface, for the reason given on the whole-object PUT beside it. */ export const PUT = withRouteHandler( async (request: NextRequest, context: LocalPartRouteParams): Promise => { @@ -28,45 +40,49 @@ export const PUT = withRouteHandler( try { session = await verifyUploadSessionToken(token) } catch { - return NextResponse.json({ error: 'Invalid or expired upload token' }, { status: 403 }) + return v2Error('FORBIDDEN', 'Invalid or expired upload token') } - const parsed = await parseRequest(localUploadPartContract, request, context) + const parsed = await parseRequest(localUploadPartContract, request, context, { + ...V2_PARSE_DEFAULTS, + validationErrorResponse: v2ValidationError, + }) if (!parsed.success) return parsed.response if (session.id !== uploadId || session.storageProvider !== 'local') { - return NextResponse.json({ error: 'Upload URL does not match this session' }, { status: 403 }) + return v2Error('FORBIDDEN', 'Upload URL does not match this session') } if (session.status !== 'uploading') { - return NextResponse.json({ error: `Upload session is ${session.status}` }, { status: 409 }) + return v2Error('CONFLICT', `Upload session is ${session.status}`) } if (session.expiresAt.getTime() <= Date.now()) { - return NextResponse.json({ error: 'Upload session has expired' }, { status: 409 }) + return v2Error('CONFLICT', 'Upload session has expired') } if (session.method !== 'multipart') { - return NextResponse.json({ error: 'PUT upload sessions do not have parts' }, { status: 409 }) + return v2Error('CONFLICT', 'PUT upload sessions do not have parts') } const { partNumber } = parsed.data.params const expectedSize = expectedUploadPartSize(session, partNumber) const contentLength = request.headers.get('content-length') if (contentLength !== null && Number(contentLength) !== expectedSize) { - return NextResponse.json( - { error: `Part ${partNumber} must contain exactly ${expectedSize} bytes` }, - { status: 400 } - ) + return v2Error('BAD_REQUEST', `Part ${partNumber} must contain exactly ${expectedSize} bytes`) } if (!request.body) { - return NextResponse.json({ error: 'Upload part body is required' }, { status: 400 }) + return v2Error('BAD_REQUEST', 'Upload part body is required') } try { await writeLocalMultipartPart({ uploadId, partNumber, body: request.body, expectedSize }) } catch (error) { if (error instanceof LocalUploadBodyError) { - return NextResponse.json({ error: error.message }, { status: 400 }) + return v2Error('BAD_REQUEST', error.message) } throw error } return new NextResponse(null, { status: 204 }) + }, + { + typedErrorResponse: ({ error }) => v2HttpError(error), + unhandledErrorResponse: () => v2UploadDataPlaneError(), } ) diff --git a/apps/sim/app/api/v2/uploads/[uploadId]/route.test.ts b/apps/sim/app/api/v2/uploads/[uploadId]/route.test.ts index 71d92a6d22d..31cd0e3c254 100644 --- a/apps/sim/app/api/v2/uploads/[uploadId]/route.test.ts +++ b/apps/sim/app/api/v2/uploads/[uploadId]/route.test.ts @@ -99,17 +99,27 @@ describe('PUT /api/v2/uploads/[uploadId]', () => { expect(response.status).toBe(400) await expect(response.json()).resolves.toMatchObject({ - error: 'Upload must contain exactly 3 bytes', + error: { code: 'BAD_REQUEST', message: 'Upload must contain exactly 3 bytes' }, }) expect(mockWriteLocalPut).not.toHaveBeenCalled() }) - it('rejects a URL whose token names a non-local or multipart session', async () => { + /** + * This route is deliberately absent from the OpenAPI documents, which is a + * statement about addressability rather than about behaviour. It used to + * answer with a bare `{ error: string }`, which made the one step of an + * upload that actually moves the bytes the one step a caller could not parse + * with its v2 error handling. + */ + it('rejects a URL whose token names a non-local or multipart session, in the v2 envelope', async () => { mockGetOwnedUploadSession.mockReturnValue({ ...SESSION, method: 'multipart' }) const response = await request() expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ + error: { code: 'FORBIDDEN', message: 'Upload URL does not match this session' }, + }) expect(mockWriteLocalPut).not.toHaveBeenCalled() }) @@ -119,7 +129,9 @@ describe('PUT /api/v2/uploads/[uploadId]', () => { const response = await request({ contentLength: null }) expect(response.status).toBe(400) - await expect(response.json()).resolves.toMatchObject({ error: 'Upload exceeds 3 bytes' }) + await expect(response.json()).resolves.toMatchObject({ + error: { code: 'BAD_REQUEST', message: 'Upload exceeds 3 bytes' }, + }) }) }) diff --git a/apps/sim/app/api/v2/uploads/[uploadId]/route.ts b/apps/sim/app/api/v2/uploads/[uploadId]/route.ts index 52ec40a4cdb..a388c99a77c 100644 --- a/apps/sim/app/api/v2/uploads/[uploadId]/route.ts +++ b/apps/sim/app/api/v2/uploads/[uploadId]/route.ts @@ -1,21 +1,40 @@ import { type NextRequest, NextResponse } from 'next/server' import { localPutUploadContract } from '@/lib/api/contracts/upload-sessions' import { parseRequest } from '@/lib/api/server' +import { V2_PARSE_DEFAULTS } from '@/lib/api/server/routes' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { LocalUploadBodyError, writeLocalPutObject } from '@/lib/uploads/upload-session/provider' import { getOwnedUploadSession, uploadSessionObjectMetadata, } from '@/lib/uploads/upload-session/service' +import { + v2Error, + v2HttpError, + v2UploadDataPlaneError, + v2ValidationError, +} from '@/app/api/v2/lib/response' interface LocalPutRouteParams { params: Promise<{ uploadId: string }> } -/** Local-storage data plane for a signed whole-object PUT upload session. */ +/** + * Local-storage data plane for a signed whole-object PUT upload session. + * + * Absent from the public OpenAPI documents by design — see + * `UNDOCUMENTED_V2_ROUTES` in `scripts/check-openapi-specs.ts` — but the error + * envelope is not part of that exemption. This is the one step that moves the + * bytes, and a caller that cannot parse its failures the way it parses every + * other v2 failure has to special-case the whole upload flow, so it renders the + * canonical `{ error: { code, message } }` like the rest of the surface. + */ export const PUT = withRouteHandler( async (request: NextRequest, context: LocalPutRouteParams): Promise => { - const parsed = await parseRequest(localPutUploadContract, request, context) + const parsed = await parseRequest(localPutUploadContract, request, context, { + ...V2_PARSE_DEFAULTS, + validationErrorResponse: v2ValidationError, + }) if (!parsed.success) return parsed.response let session @@ -25,35 +44,29 @@ export const PUT = withRouteHandler( uploadToken: parsed.data.headers['upload-token'], }) } catch { - return NextResponse.json({ error: 'Invalid or expired upload token' }, { status: 403 }) + return v2Error('FORBIDDEN', 'Invalid or expired upload token') } if (session.storageProvider !== 'local' || session.method !== 'put') { - return NextResponse.json({ error: 'Upload URL does not match this session' }, { status: 403 }) + return v2Error('FORBIDDEN', 'Upload URL does not match this session') } if (session.status !== 'uploading') { - return NextResponse.json({ error: `Upload session is ${session.status}` }, { status: 409 }) + return v2Error('CONFLICT', `Upload session is ${session.status}`) } if (session.expiresAt.getTime() <= Date.now()) { - return NextResponse.json({ error: 'Upload session has expired' }, { status: 409 }) + return v2Error('CONFLICT', 'Upload session has expired') } const contentType = request.headers.get('content-type') if (contentType !== session.contentType) { - return NextResponse.json( - { error: `Content-Type must be ${session.contentType}` }, - { status: 400 } - ) + return v2Error('BAD_REQUEST', `Content-Type must be ${session.contentType}`) } const contentLength = request.headers.get('content-length') if (contentLength !== null && Number(contentLength) !== session.fileSize) { - return NextResponse.json( - { error: `Upload must contain exactly ${session.fileSize} bytes` }, - { status: 400 } - ) + return v2Error('BAD_REQUEST', `Upload must contain exactly ${session.fileSize} bytes`) } if (!request.body) { - return NextResponse.json({ error: 'Upload body is required' }, { status: 400 }) + return v2Error('BAD_REQUEST', 'Upload body is required') } try { @@ -67,10 +80,14 @@ export const PUT = withRouteHandler( }) } catch (error) { if (error instanceof LocalUploadBodyError) { - return NextResponse.json({ error: error.message }, { status: 400 }) + return v2Error('BAD_REQUEST', error.message) } throw error } return new NextResponse(null, { status: 204 }) + }, + { + typedErrorResponse: ({ error }) => v2HttpError(error), + unhandledErrorResponse: () => v2UploadDataPlaneError(), } ) diff --git a/apps/sim/lib/api/contracts/tables-predicate.test.ts b/apps/sim/lib/api/contracts/tables-predicate.test.ts index 23e2d2e7019..fb273a38a9d 100644 --- a/apps/sim/lib/api/contracts/tables-predicate.test.ts +++ b/apps/sim/lib/api/contracts/tables-predicate.test.ts @@ -336,4 +336,20 @@ describe('the published predicate schema', () => { true ) }) + + /** + * The description published a null rule the compiler never implemented: + * multi-select `ncontains` was called "the exception" that excludes nulls, + * while `sql.ts` emits a bare `NOT (data @> …)` — TRUE for an absent key, + * exactly like every other negation. A wrong null rule is worse than none: + * it reads as deliberate, so a caller writes a predicate that silently + * returns rows it was told were excluded. + */ + it('does not claim a multi-select null exception the compiler never had', () => { + const description = String(published.description) + + expect(description).toContain('The negating operators include nulls') + expect(description).not.toMatch(/exception/i) + expect(description).toMatch(/multi-select included/i) + }) }) diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index 83ac0190d96..0de605c5516 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -639,10 +639,16 @@ const predicateGroupsJsonSchema = (selfRef: string) => * emit an explicit `IS NULL OR NOT …` arm, and `ne`/`nin` negate a JSONB * containment test that is false for an absent key, so all of them return rows * whose column is null. + * + * Multi-select is not an exception to that, though the published sentence used + * to claim it was: its `ncontains` is `NOT (data @> '{"tags":["opt"]}')`, and + * `data` is never NULL, so an absent or null cell makes the containment test + * false and the negation true — the same include-nulls behaviour as every other + * negation. Pinned by `__tests__/sql.test.ts`. */ const PREDICATE_TREE_DESCRIPTION = [ `Recursive predicate tree. Each group node is exactly one non-empty \`all\` or \`any\` array whose members are further groups or \`{ field, op, value }\` conditions; the root must be a group, not a bare condition. At most ${MAX_PREDICATE_GROUP_SIZE} members per group, ${MAX_PREDICATE_DEPTH} levels of nesting, and ${MAX_PREDICATE_NODES} nodes in total.`, - 'The negating operators include nulls: `ne`, `nin`, `ncontains`, `nlike`, and `nilike` match rows whose column is null or absent, so "not X" is not the complement of "X" over a nullable column. Multi-select `ncontains` is the exception and excludes nulls.', + 'The negating operators include nulls: `ne`, `nin`, `ncontains`, `nlike`, and `nilike` match rows whose column is null or absent, so "not X" is not the complement of "X" over a nullable column. That holds for every column type, multi-select included. To exclude nulls, `all`-combine the negation with `isNotEmpty` (multi-select) or `isNotNull`.', PREDICATE_OPERATOR_GRAMMAR, ].join(' ') diff --git a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts index 87f09e368ed..a80ee298cec 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts @@ -370,6 +370,24 @@ interface V2ListContract { strictQuery: boolean | null | undefined /** Whether a fractional `limit` draws a validation issue on `limit` itself. */ rejectsFractionalLimit: boolean + /** Published description of `nextCursor`, as a caller reads it in the spec. */ + nextCursorDescription: string +} + +/** + * The `nextCursor` description the generated spec carries. + * + * Read off the JSON Schema rather than the Zod node because that is the + * artifact a caller and a generated client actually see — an envelope that is + * right in TypeScript but publishes the wrong sentence is exactly the + * divergence this exists to catch. + */ +function nextCursorDescription(schema: z.ZodType | undefined): string { + if (!schema) return '' + const published = z.toJSONSchema(schema, { io: 'output', unrepresentable: 'any' }) as { + properties?: Record + } + return published.properties?.nextCursor?.description ?? '' } /** @@ -402,6 +420,7 @@ async function sweepV2ListContracts(): Promise { inputKeys: [...new Set(variants.flat())].sort(), strictQuery: value.query ? rejectsUnknownKeys(value.query) : undefined, rejectsFractionalLimit: rejectsFractionalLimit(value), + nextCursorDescription: nextCursorDescription(value.response?.schema), }) } } @@ -456,6 +475,32 @@ describe('v2 list pagination split', () => { } }) + /** + * The envelope is shared by both kinds of list, so its `nextCursor` sentence + * has to say which one the caller is holding. Both kinds published the paged + * sentence — "Send it back as `cursor`" — on lists whose `.strict()` query + * declares no `cursor`, so following the response's own instruction is a 400, + * and `nextCursor` is `null` by construction anyway. The description is the + * only part of the envelope that can carry the difference. + */ + it('documents nextCursor as the kind of cursor the list actually has', 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)?.nextCursorDescription, + `${key} returns its whole set but publishes the paged nextCursor sentence, which sends a caller to replay a token its query rejects. Build the response with v2CursorListResponse(item, { paged: false }).` + ).not.toMatch(/send it back as/i) + } + for (const key of PAGED_LISTS) { + expect( + byKey.get(key)?.nextCursorDescription, + `${key} is paged, so its nextCursor must document how to fetch the next page.` + ).toMatch(/send it back as/i) + } + }) + it('makes every v2 list query reject a param it does not implement', async () => { const contracts = await loadV2ListContracts() const byKey = new Map(contracts.map((c) => [c.key, c])) diff --git a/apps/sim/lib/api/contracts/v2/__tests__/shared.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/shared.test.ts index 4abed1858c7..892e681a97f 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/shared.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/shared.test.ts @@ -54,9 +54,13 @@ describe('v2 folder path contracts', () => { * vocabulary, which contributes nothing to JSON Schema. Parsing every listed * spelling here is what keeps the restatement honest through a Zod upgrade — * and this is a destructive switch, so a spelling the spec advertises but the - * server rejects is worse than an undocumented one. + * server rejects is worse than an undocumented one — and on a destructive + * switch so is the reverse: a spelling the server honours but the spec does + * not list is a recursive delete a generated client would have refused to + * send. `recursive` is therefore case-SENSITIVE, accepting exactly the twelve + * published spellings and nothing else. */ - it('accepts every spelling of `recursive` it publishes, in any case', () => { + it('accepts every spelling of `recursive` it publishes, and only those', () => { const published = z.toJSONSchema(v2DeleteFolderQuerySchema, { io: 'input', unrepresentable: 'any', @@ -76,17 +80,15 @@ describe('v2 folder path contracts', () => { .recursive ).toBe(false) } - expect( - v2DeleteFolderQuerySchema.parse({ workspaceId: WORKSPACE_ID, path: '/R', recursive: 'YES' }) - .recursive - ).toBe(true) - expect( - v2DeleteFolderQuerySchema.safeParse({ - workspaceId: WORKSPACE_ID, - path: '/R', - recursive: 'maybe', - }).success - ).toBe(false) + for (const value of ['True', 'TRUE', 'YES', 'On', 'Y', 'ENABLED', 'maybe']) { + expect( + v2DeleteFolderQuerySchema.safeParse({ + workspaceId: WORKSPACE_ID, + path: '/R', + recursive: value, + }).success + ).toBe(false) + } }) /** diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts index 45688358007..e69ce357183 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -434,7 +434,7 @@ export const v2ListFileFoldersContract = defineRouteContract({ method: 'GET', path: '/api/v2/files/folders', query: v2ListFoldersQuerySchema, - response: { mode: 'json', schema: v2CursorListResponse(v2FolderSchema) }, + response: { mode: 'json', schema: v2CursorListResponse(v2FolderSchema, { paged: false }) }, }) export const v2CreateFileFolderContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/v2/knowledge.ts b/apps/sim/lib/api/contracts/v2/knowledge.ts index 15c76e15b0c..3240380cbd4 100644 --- a/apps/sim/lib/api/contracts/v2/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/knowledge.ts @@ -761,7 +761,7 @@ export const v2ListKnowledgeFoldersContract = defineRouteContract({ method: 'GET', path: '/api/v2/knowledge/folders', query: v2ListFoldersQuerySchema, - response: { mode: 'json', schema: v2CursorListResponse(v2FolderSchema) }, + response: { mode: 'json', schema: v2CursorListResponse(v2FolderSchema, { paged: false }) }, }) export const v2CreateKnowledgeFolderContract = defineRouteContract({ @@ -1097,7 +1097,7 @@ export const v2ListKnowledgeTagsContract = defineRouteContract({ .strict(), response: { mode: 'json', - schema: v2CursorListResponse(v2KnowledgeTagSchema), + schema: v2CursorListResponse(v2KnowledgeTagSchema, { paged: false }), }, }) diff --git a/apps/sim/lib/api/contracts/v2/mcp-servers.ts b/apps/sim/lib/api/contracts/v2/mcp-servers.ts index 60111ac2442..4953e9624ff 100644 --- a/apps/sim/lib/api/contracts/v2/mcp-servers.ts +++ b/apps/sim/lib/api/contracts/v2/mcp-servers.ts @@ -444,6 +444,6 @@ export const v2ListMcpServerToolsContract = defineRouteContract({ query: v2ListMcpServerToolsQuerySchema, response: { mode: 'json', - schema: v2CursorListResponse(v2McpToolSchema), + schema: v2CursorListResponse(v2McpToolSchema, { paged: false }), }, }) diff --git a/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts b/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts index aba11d0c5cb..97644dfa08c 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts @@ -29,6 +29,7 @@ import { FOLDER_TREE_TOO_LARGE, FULL_SET_LIST, HEAD_MIRRORS_GET, + HEAD_OMITS_PAYLOAD_HEADERS, RATE_LIMIT_HEADERS, RESOURCE_CONFLICT_ERRORS, RESOURCE_ERRORS, @@ -332,7 +333,7 @@ const routes = [ filesOperation({ operationId: 'downloadFile', summary: 'Download File', - description: `Download the current file bytes from a workspace. A generated document is served as its compiled artifact, so it answers \`409\` while that artifact is still compiling and \`413\` if it renders past the size ceiling. Downloading records an audit event, so it is not a safe read. ${HEAD_MIRRORS_GET}`, + description: `Download the current file bytes from a workspace. A generated document is served as its compiled artifact, so it answers \`409\` while that artifact is still compiling and \`413\` if it renders past the size ceiling. Downloading records an audit event, so it is not a safe read. ${HEAD_MIRRORS_GET} ${HEAD_OMITS_PAYLOAD_HEADERS}`, errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], success: { description: 'The file bytes.', diff --git a/apps/sim/lib/api/contracts/v2/openapi/head-not-safe.test.ts b/apps/sim/lib/api/contracts/v2/openapi/head-not-safe.test.ts index 6204cc45afb..497fb8dc52d 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/head-not-safe.test.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/head-not-safe.test.ts @@ -6,7 +6,7 @@ import path from 'node:path' import { describe, expect, it } from 'vitest' import { filesAuditOpenApiDocument } from '@/lib/api/contracts/v2/openapi/files-audit' import { resourcesOpenApiDocument } from '@/lib/api/contracts/v2/openapi/resources' -import { HEAD_MIRRORS_GET } from '@/lib/api/contracts/v2/openapi/shared' +import { HEAD_MIRRORS_GET, HEAD_OMITS_PAYLOAD_HEADERS } from '@/lib/api/contracts/v2/openapi/shared' import { tablesOpenApiDocument } from '@/lib/api/contracts/v2/openapi/tables' import { workflowsOpenApiDocument } from '@/lib/api/contracts/v2/openapi/workflows' import type { OpenApiDocumentDefinition, OpenApiRouteDefinition } from '@/lib/api/openapi/types' @@ -56,6 +56,30 @@ describe('operations whose GET declares headSafe: false', () => { ).toEqual([]) }) + /** + * The `200` documents `Content-Type`, `Content-Length`, and + * `Content-Disposition`, and the `HEAD` short-circuit answers before the read + * that produces any of them — so all three are absent on a `HEAD` the spec's + * own success object appears to promise them for. A caller sizing a download + * from `Content-Length` gets nothing back and no way to have known that. + */ + it('says a HEAD omits the payload headers its 200 documents', () => { + /** Rate-limit headers ARE emitted on a HEAD; only these three are not. */ + const payloadHeaders = ['Content-Type', 'Content-Length', 'Content-Disposition'] + const withPayloadHeaders = DOCUMENTS.flatMap((document) => document.routes) + .filter(declaresHeadNotSafe) + .filter((route) => + (route.operation.success?.headers ?? []).some((header) => payloadHeaders.includes(header)) + ) + + expect(withPayloadHeaders.length).toBeGreaterThan(0) + expect( + withPayloadHeaders + .filter((route) => !route.operation.description.includes(HEAD_OMITS_PAYLOAD_HEADERS)) + .map((route) => `${route.operation.operationId} (GET ${route.contract.path})`) + ).toEqual([]) + }) + it('never claims a HEAD is answered without an authorization check', () => { const claiming = DOCUMENTS.flatMap((document) => document.routes) .filter((route) => diff --git a/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts b/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts index 93b75850306..bbfba36eb4d 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts @@ -661,7 +661,7 @@ const routes = [ v2ListKnowledgeFoldersContract.response.schema, 'V2KnowledgeFolderListResponse', 'Knowledge folder list response', - 'A cursor-paginated page of knowledge-base folders.' + 'The whole bounded set of knowledge-base folders, in one page.' ), } ), diff --git a/apps/sim/lib/api/contracts/v2/openapi/shared.ts b/apps/sim/lib/api/contracts/v2/openapi/shared.ts index f7462b93c23..d0a82743814 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/shared.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/shared.ts @@ -231,7 +231,21 @@ export const FULL_SET_LIST = * Pinned by `contracts/v2/openapi/head-not-safe.test.ts`. */ export const HEAD_MIRRORS_GET = - 'A `HEAD` skips the effect but is authorized exactly as the `GET` is, so it answers `400`, `401`, `403`, or `404` wherever the `GET` would and an empty `200` otherwise.' + 'A `HEAD` skips the effect but is authorized exactly as the `GET` is, so it answers `400`, `401`, `403`, or `404` wherever the `GET` would and an empty `200` otherwise. Skipping the effect means skipping the read that produces the payload, so that `200` carries none of the response headers documented below — it answers whether the `GET` would be allowed, not what the `GET` would return.' + +/** + * Appended where the skipped payload headers are the ones a caller is most + * likely to have wanted from a `HEAD`. + * + * `Content-Length` on a `HEAD` is the standard way to size a download before + * fetching it, and this surface cannot serve it: the byte length comes from the + * same read that records the download audit event, which is the effect + * `headSafe: false` exists to skip. Naming the alternative is the difference + * between a documented limitation and a caller discovering an absent header at + * runtime. + */ +export const HEAD_OMITS_PAYLOAD_HEADERS = + 'In particular a `HEAD` does not report `Content-Length`, so it cannot be used to size a download in advance; read the size from the file resource instead.' /** * Appended to an operation whose semantic operation sets `workspaceApiKey: 'deny'`. diff --git a/apps/sim/lib/api/contracts/v2/shared.ts b/apps/sim/lib/api/contracts/v2/shared.ts index 6b3f4826bcd..2b0065120f0 100644 --- a/apps/sim/lib/api/contracts/v2/shared.ts +++ b/apps/sim/lib/api/contracts/v2/shared.ts @@ -21,15 +21,19 @@ import { * - list: `{ data: T[], nextCursor: string | null }` * - error: `{ error: { code, message, details? } }` * - * Every documented v2 operation uses that family. The two exceptions are the - * local-storage upload data plane — `PUT /api/v2/uploads/{uploadId}` and - * `PUT /api/v2/uploads/{uploadId}/parts/{partNumber}` — which emit a bare - * `{ error: string }` body. They are authenticated by a short-lived upload - * token rather than an API key, are deliberately absent from the public - * OpenAPI specs (see `UNDOCUMENTED_V2_ROUTES` in - * `scripts/check-openapi-specs.ts`), and are only ever reached through a URL - * handed back by a documented operation, so no caller writes against them - * from docs. + * Every v2 route uses that error family, including the two that are not + * published: the local-storage upload data plane — `PUT /api/v2/uploads/{uploadId}` + * and `PUT /api/v2/uploads/{uploadId}/parts/{partNumber}`. Those two are + * authenticated by a short-lived upload token rather than an API key and are + * deliberately absent from the public OpenAPI specs (see + * `UNDOCUMENTED_V2_ROUTES` in `scripts/check-openapi-specs.ts`), because their + * URL is signed, short-lived, and only ever reached through a documented + * operation's response. They used to answer with a bare `{ error: string }` + * instead, which made the one step that actually moves the bytes the one step a + * caller could not parse with its v2 error handling. Not being in the document + * is a reason not to publish a route; it was never a reason to answer in a + * different shape. What that step promises — method, headers, `204`, and which + * codes mean what — is published on `transfer.url` in `contracts/v2/uploads.ts`. * * Every list returns the opaque-cursor envelope (Stripe/Slack-style) * `{ data, nextCursor }`, but not every list is *paged*. A paged list also @@ -226,15 +230,38 @@ export type V2ErrorResponse = z.output export const v2DataResponse = (dataSchema: T) => z.object({ data: dataSchema.describe('Response data.') }) -/** `{ data: T[], nextCursor: string | null }` — the v2 list envelope. */ -export const v2CursorListResponse = (itemSchema: T) => +interface V2ListResponseOptions { + /** + * `false` for a full-set list — one that shares the envelope but declares no + * `cursor`/`limit` and always answers `null`. Defaults to `true`. + */ + paged?: boolean +} + +/** + * `{ data: T[], nextCursor: string | null }` — the v2 list envelope. + * + * `paged` selects the `nextCursor` documentation, and exists because the two + * cases had been publishing the same sentence. A full-set list accepts no + * `cursor` param — its query schema is `.strict()`, so the token the envelope + * told the caller to "send back as `cursor`" is a `400` — and its `nextCursor` + * is `null` by construction, so the instruction described a loop that could + * never run. The envelope stays shared either way: that is what lets a + * full-set list gain real pages later without a contract change. + */ +export const v2CursorListResponse = ( + itemSchema: T, + options: V2ListResponseOptions = {} +) => z.object({ data: z.array(itemSchema).describe('Items in the current page.'), nextCursor: z .string() .nullable() .describe( - 'Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself.' + options.paged === false + ? 'Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change.' + : 'Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself.' ), }) @@ -611,12 +638,21 @@ export const v2DeleteFolderQuerySchema = z * deleting a subtree, and the accepted vocabulary is closed — an * out-of-vocabulary value is a `400`, not a silent `false` — so leaving it * undeclared hid a destructive switch behind a guess. + * + * `case: 'sensitive'` is what makes "closed" true. `z.stringbool()` folds + * case by default, so the server honoured `recursive=True`, `TRUE`, `YES` + * and `ENABLED` as a recursive delete while publishing only the twelve + * lowercase spellings — a generated client validates against the `enum` and + * would reject a request the server would have executed destructively. + * Accepting exactly what is published is the safe direction to close that + * gap: an unpublished spelling now fails the request instead of deleting a + * subtree. */ recursive: z - .stringbool() + .stringbool({ case: 'sensitive' }) .prefault('false') .describe( - "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. Any listed spelling is accepted in any case, and any other value is rejected." + "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected." ) .meta({ enum: [...V2_TRUE_VALUES, ...V2_FALSE_VALUES] }), }) diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index 3add6bfefca..c8e4f97f88c 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -534,7 +534,7 @@ export const v2ListTableFoldersContract = defineRouteContract({ method: 'GET', path: '/api/v2/tables/folders', query: v2ListFoldersQuerySchema, - response: { mode: 'json', schema: v2CursorListResponse(v2FolderSchema) }, + response: { mode: 'json', schema: v2CursorListResponse(v2FolderSchema, { paged: false }) }, }) export const v2CreateTableFolderContract = defineRouteContract({ @@ -1135,7 +1135,7 @@ export const v2ListTableViewsContract = defineRouteContract({ query: v2TableWorkspaceQuerySchema, response: { mode: 'json', - schema: v2CursorListResponse(v2ApiViewSchema), + schema: v2CursorListResponse(v2ApiViewSchema, { paged: false }), }, }) @@ -1262,7 +1262,7 @@ export const v2ListWorkflowGroupsContract = defineRouteContract({ query: v2TableWorkspaceQuerySchema, response: { mode: 'json', - schema: v2CursorListResponse(v2WorkflowGroupSchema), + schema: v2CursorListResponse(v2WorkflowGroupSchema, { paged: false }), }, }) diff --git a/apps/sim/lib/api/contracts/v2/uploads.ts b/apps/sim/lib/api/contracts/v2/uploads.ts index 8cb2d1803eb..feb2f6543bf 100644 --- a/apps/sim/lib/api/contracts/v2/uploads.ts +++ b/apps/sim/lib/api/contracts/v2/uploads.ts @@ -25,10 +25,28 @@ export const v2OptionalUploadTokenHeadersSchema = z.object({ 'upload-token': z.string().min(1, 'upload-token header cannot be empty').optional(), }) +/** + * What a caller needs about the transfer step, stated in the published document + * rather than only in the source. + * + * The URL a transfer hands back can point at object storage or, on a + * self-hosted deployment, at Sim's own local data plane — so the endpoint is + * described by this field rather than by an operation of its own, and no + * OpenAPI document declares it. That is deliberate (the URL is signed, + * short-lived, and never constructed from docs), but it left the one step that + * actually moves the bytes with no published status codes at all. This is that + * contract. + */ +const TRANSFER_STEP_CONTRACT = + 'Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. Success is `204` with an empty body. A failure is the same `{ "error": { "code", "message" } }` envelope as every other v2 response: `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. The URL is signed and self-describing — construct it from this field only, never by hand.' + export const v2PutUploadTransferSchema = z .object({ method: z.literal('put').describe('Upload strategy discriminator.'), - url: z.string().url().describe('Signed URL to which the file bytes are uploaded.'), + url: z + .string() + .url() + .describe(`Signed URL to which the file bytes are uploaded. ${TRANSFER_STEP_CONTRACT}`), headers: z .record(z.string(), z.string()) .describe('Headers that must be included with the upload request.'), @@ -82,7 +100,7 @@ export type V2PartUrlsBody = z.input export const v2UploadPartUrlSchema = z .object({ partNumber: z.number().int().min(1).describe('Multipart part number.'), - url: z.string().url().describe('Signed URL for this upload part.'), + url: z.string().url().describe(`Signed URL for this upload part. ${TRANSFER_STEP_CONTRACT}`), headers: z .record(z.string(), z.string()) .describe('Headers that must be included with the part upload.'), diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 5f53a288f67..ce0b3b88d66 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -546,7 +546,10 @@ export const v2ListWorkflowFoldersContract = defineRouteContract({ method: 'GET', path: '/api/v2/workflows/folders', query: v2ListFoldersQuerySchema, - response: { mode: 'json', schema: v2CursorListResponse(v2WorkflowFolderSchema) }, + response: { + mode: 'json', + schema: v2CursorListResponse(v2WorkflowFolderSchema, { paged: false }), + }, }) export const v2CreateWorkflowFolderContract = defineRouteContract({ diff --git a/apps/sim/lib/api/cursor-binding.test.ts b/apps/sim/lib/api/cursor-binding.test.ts index 2048b907223..e0a8dc82e78 100644 --- a/apps/sim/lib/api/cursor-binding.test.ts +++ b/apps/sim/lib/api/cursor-binding.test.ts @@ -144,8 +144,15 @@ describe('v2 cursor binding', () => { expect(readScopedCursor(undefined, scope)).toBeUndefined() }) - it('rejects a token that is not valid base64-JSON', () => { - expect(() => readScopedCursor('not-a-cursor', scope)).toThrow() + /** + * The three lists that mint their own tokens do not share a sort knob — + * `GET /audit-logs` declares neither `sortBy` nor `sortOrder`, and its query + * schema is `.strict()` — so an undecodable token must not send the caller + * to adjust params that would themselves be rejected. + */ + it('rejects a token that is not valid base64-JSON without naming a sort param', () => { + expect(() => readScopedCursor('not-a-cursor', scope)).toThrow(/not a valid pagination cursor/) + expect(() => readScopedCursor('not-a-cursor', scope)).not.toThrow(/sort/i) }) }) diff --git a/apps/sim/lib/api/cursor-binding.ts b/apps/sim/lib/api/cursor-binding.ts index bceb060ba35..62e286966d9 100644 --- a/apps/sim/lib/api/cursor-binding.ts +++ b/apps/sim/lib/api/cursor-binding.ts @@ -26,6 +26,20 @@ import { createHash } from 'node:crypto' export const REFILTERED_CURSOR_MESSAGE = 'cursor does not match the requested filters. Restart pagination without a cursor after changing a filter.' +/** + * Caller-facing message for a token that cannot be decoded at all. + * + * Distinct from {@link REFILTERED_CURSOR_MESSAGE} and from + * `INVALID_CURSOR_MESSAGE` for the same reason those two are distinct from each + * other: an undecodable token says nothing about which param changed, and the + * lists that raise it do not all have a sort to name. `GET /audit-logs` + * declares neither `sortBy` nor `sortOrder` and its query schema is `.strict()`, + * so sending a caller to adjust them answers one 400 with advice that earns a + * second. + */ +export const UNREADABLE_CURSOR_MESSAGE = + 'cursor is not a valid pagination cursor. Restart pagination without a cursor.' + /** A scalar a list filter can be expressed as, before canonicalization. */ export type CursorScopePart = | string diff --git a/apps/sim/lib/table/__tests__/sql.test.ts b/apps/sim/lib/table/__tests__/sql.test.ts index 9b597e4aacf..b9999efa4f3 100644 --- a/apps/sim/lib/table/__tests__/sql.test.ts +++ b/apps/sim/lib/table/__tests__/sql.test.ts @@ -565,10 +565,20 @@ describe('SQL Builder', () => { expect(out).not.toContain('ILIKE') }) - it('negates multiselect membership for $ncontains', () => { + /** + * Multi-select `$ncontains` keeps null and absent cells, like every other + * negation on the surface: `data` itself is never NULL, so containment is + * FALSE — not NULL — for a missing key, and the negation is therefore TRUE. + * The published `TablePredicate` description used to call multi-select the + * one exception that excluded nulls; it never was, and the description now + * says so. + */ + it('negates multiselect membership for $ncontains, keeping null and absent cells', () => { const out = render(buildFilterClause({ tags: { $ncontains: 'opt_a' } }, TABLE, [tagsCol])) expect(out).toContain('NOT (') expect(out).toContain('"tags":["opt_a"]') + expect(out).not.toContain('IS NOT NULL') + expect(out).not.toContain("? 'tags'") }) it('rejects explicit equality on a multiselect — it could never match', () => { diff --git a/apps/sim/lib/table/__tests__/update-row.test.ts b/apps/sim/lib/table/__tests__/update-row.test.ts index 91bdef534c9..c9cf83348b8 100644 --- a/apps/sim/lib/table/__tests__/update-row.test.ts +++ b/apps/sim/lib/table/__tests__/update-row.test.ts @@ -265,6 +265,39 @@ describe('insertRow — position race safety (migration 0198 + advisory lock)', expect(findExecutedSqlContaining('pg_advisory_xact_lock')).toBe(false) }) + /** + * The v2 surface is column-NAME-keyed and resolves `conflictTarget` to its + * storage id before this call, so the rejection has to translate back — a + * caller that sent `email` cannot act on a `col_…` id it has never seen. + */ + it('upsertRow names the conflict column the caller does, not its storage id', async () => { + const table: TableDefinition = { + ...TABLE, + schema: { + columns: [ + { id: 'col_9934c202', name: 'email', type: 'string' }, + { id: 'col_2f1a', name: 'slug', type: 'string', unique: true }, + ], + }, + } + vi.mocked(getUniqueColumns).mockReturnValue([ + { id: 'col_2f1a', name: 'slug', type: 'string', unique: true }, + ]) + + await expect( + upsertRow( + { + tableId: 'tbl-1', + workspaceId: 'ws-1', + data: { col_9934c202: 'a@b.test' }, + conflictTarget: 'col_9934c202', + }, + table, + 'req-1' + ) + ).rejects.toThrow('Column "email" is not a unique column. Available unique columns: slug') + }) + it('upsertRow acquires the advisory lock on the insert path (no match)', async () => { vi.mocked(getUniqueColumns).mockReturnValue([{ name: 'name', type: 'string', unique: true }]) // Initial existing-row check + post-lock re-check both find no match. diff --git a/apps/sim/lib/table/rows/service.ts b/apps/sim/lib/table/rows/service.ts index f3e790cefc9..939b8c2b553 100644 --- a/apps/sim/lib/table/rows/service.ts +++ b/apps/sim/lib/table/rows/service.ts @@ -612,9 +612,19 @@ export async function upsertRow( (c) => getColumnId(c) === data.conflictTarget || c.name === data.conflictTarget ) if (!col) { + /** + * Name the column the way the caller does. A name-keyed surface resolves + * `conflictTarget` to its storage id before this call, so echoing the + * argument verbatim answers a request naming `email` with a `col_…` id + * the caller has never seen and cannot map back. Same rule as the + * missing-value branch below. + */ + const requested = + schema.columns.find((c) => getColumnId(c) === data.conflictTarget)?.name ?? + data.conflictTarget throw new OrchestrationError( 'validation', - `Column "${data.conflictTarget}" is not a unique column. Available unique columns: ${uniqueColumns.map((c) => c.name).join(', ')}` + `Column "${requested}" is not a unique column. Available unique columns: ${uniqueColumns.map((c) => c.name).join(', ')}` ) } targetColumnKey = getColumnId(col) diff --git a/packages/db/db.ts b/packages/db/db.ts index 8cdc4d477e3..c1f033a1728 100644 --- a/packages/db/db.ts +++ b/packages/db/db.ts @@ -3,6 +3,7 @@ import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' import { resolveDbUrl } from './connection-url' import * as schema from './schema' +import { withUtcTimestamps } from './timestamps' import { instrumentPoolClient } from './tx-tripwire' const logger = createLogger('Db') @@ -71,14 +72,14 @@ if (!connectionString) { * Pinned by apps/sim/lib/execution/payloads/prune-metadata-sql.test.ts, which renders * the real statements and asserts no bind parameter is an array. */ -const poolOptions = { +const poolOptions = withUtcTimestamps({ prepare: false, fetch_types: false, idle_timeout: 20, connect_timeout: 30, onnotice: () => {}, connection: { application_name: process.env.DB_APP_NAME ?? profile.appName }, -} +}) const postgresClient = instrumentPoolClient( postgres(connectionString, { ...poolOptions, max: profile.primaryMax }), @@ -154,11 +155,14 @@ export function dbFor(role: SubProcessDbRole): typeof db { const subProfile = DB_POOL_PROFILES[role] const client = drizzle( instrumentPoolClient( - postgres(url, { - ...poolOptions, - max: subProfile.primaryMax, - connection: { application_name: subProfile.appName }, - }), + postgres( + url, + withUtcTimestamps({ + ...poolOptions, + max: subProfile.primaryMax, + connection: { application_name: subProfile.appName }, + }) + ), role ), { schema } diff --git a/packages/db/package.json b/packages/db/package.json index c30bb96a4bd..bc3eedfd895 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -16,6 +16,10 @@ "./schema": { "types": "./schema.ts", "default": "./schema.ts" + }, + "./timestamps": { + "types": "./timestamps.ts", + "default": "./timestamps.ts" } }, "scripts": { diff --git a/packages/db/timestamps.test.ts b/packages/db/timestamps.test.ts new file mode 100644 index 00000000000..a49ac6218a6 --- /dev/null +++ b/packages/db/timestamps.test.ts @@ -0,0 +1,81 @@ +/** + * @vitest-environment node + * + * These assertions are only meaningful when the process is NOT running in UTC: + * a local-time defect is invisible when local time *is* UTC. `TZ` is therefore + * pinned to a non-UTC zone, and {@link isProcessInUtc} fails the suite outright + * if the runtime ignored it, rather than letting the file pass vacuously. The + * assignment sits below the imports because ESM hoists them regardless; what + * matters is that it runs before any `Date` is constructed, and every `Date` + * here is built inside a test body. + */ + +import { + UTC_CONNECTION_PARAMETERS, + UTC_TIMESTAMP_TYPES, + withUtcTimestamps, +} from '@sim/db/timestamps' +import postgres from 'postgres' +import { describe, expect, it } from 'vitest' + +process.env.TZ = 'Asia/Tokyo' + +/** Postgres oid of `timestamp without time zone`. */ +const TIMESTAMP_OID = 1114 + +/** A naive `timestamp` value exactly as Postgres renders it on the wire. */ +const NAIVE_WIRE_VALUE = '2026-08-13 02:44:03.42' +const NAIVE_WIRE_INSTANT = '2026-08-13T02:44:03.420Z' + +function isProcessInUtc(): boolean { + return new Date().getTimezoneOffset() === 0 +} + +/** + * The parser postgres.js would actually apply to oid 1114 for a client built + * with `options`. Constructing a client does not open a connection, so this + * reads the real resolved configuration without touching a database. + */ +function resolveTimestampParser(options: Parameters[1]) { + const client = postgres('postgres://user@localhost:5432/db', options) + return (client.options as { parsers: Record unknown> }).parsers[ + TIMESTAMP_OID + ] +} + +describe('naive timestamp UTC pinning', () => { + it('runs outside UTC, so a local-time defect is observable', () => { + expect(isProcessInUtc()).toBe(false) + }) + + it('pins the session TimeZone so every writer stores the same wall clock', () => { + expect(UTC_CONNECTION_PARAMETERS.TimeZone).toBe('UTC') + }) + + it('reads a naive timestamp as UTC rather than the process zone', () => { + const parsed = UTC_TIMESTAMP_TYPES.utcTimestamp.parse(NAIVE_WIRE_VALUE) + expect(parsed.toISOString()).toBe(NAIVE_WIRE_INSTANT) + }) + + it('round-trips an instant through the naive wire form unchanged', () => { + const instant = new Date('2026-08-13T02:44:03.420Z') + const serialized = UTC_TIMESTAMP_TYPES.utcTimestamp.serialize(instant) + /** Postgres discards the offset designator when parsing into a naive column. */ + const storedWallClock = serialized.replace('T', ' ').replace('Z', '') + expect(UTC_TIMESTAMP_TYPES.utcTimestamp.parse(storedWallClock).getTime()).toBe( + instant.getTime() + ) + }) + + it('is what a client built through withUtcTimestamps actually applies', () => { + const parse = resolveTimestampParser( + withUtcTimestamps({ connection: { application_name: 'test' } }) + ) + expect((parse(NAIVE_WIRE_VALUE) as Date).toISOString()).toBe(NAIVE_WIRE_INSTANT) + }) + + it('keeps the session TimeZone when a caller sets its own connection params', () => { + const merged = withUtcTimestamps({ connection: { application_name: 'sub-pool' } }) + expect(merged.connection).toEqual({ application_name: 'sub-pool', TimeZone: 'UTC' }) + }) +}) diff --git a/packages/db/timestamps.ts b/packages/db/timestamps.ts new file mode 100644 index 00000000000..93b895fdde2 --- /dev/null +++ b/packages/db/timestamps.ts @@ -0,0 +1,100 @@ +/** + * UTC pinning for `timestamp without time zone` columns. + * + * Every timestamp column in `schema.ts` is declared as bare `timestamp(...)`, + * which is Postgres `timestamp without time zone`: the column stores a naive + * wall-clock reading with no offset, so the instant it denotes is decided + * entirely by whoever writes it and whoever reads it. Nothing in the type + * system pins that decision, and the three writers in this codebase did not + * agree: + * + * - `defaultNow()` / `now()` — Postgres renders the current instant in the + * **session's** `TimeZone`, so the stored wall clock is UTC only when the + * session happens to be UTC. + * - drizzle-mapped writes (`updatedAt: new Date()`) — `PgTimestamp`'s + * `mapToDriverValue` is `value.toISOString()`, and Postgres **discards** the + * trailing `Z` when parsing into a naive column, so the stored wall clock is + * always UTC. + * - a raw `Date` bound through postgres.js — inferred as `timestamptz` (oid + * 1184) and cast down to the column type in the **session's** `TimeZone`. + * + * The read side disagreed the same way: postgres.js parses oid 1114 with + * `new Date(x)`, and `new Date('2026-08-13 02:44:03.42')` is interpreted in the + * **Node process's** local zone, while a value postgres.js hands back as a + * string is interpreted as UTC by drizzle (`value + '+0000'`). So the same + * column read through two paths yielded instants an offset apart. + * + * The compound effect is a timestamp that is a *local* wall clock serialized + * with `toISOString()` — a `Z`-labelled string naming the wrong instant. It + * passes every `date-time` format check, so it is silently wrong: it corrupts + * any sort or range predicate over the field, and can place an `updatedAt` + * before its own `createdAt`. + * + * This module removes the ambiguity at the driver boundary rather than at the + * call sites, so no future writer can reintroduce it: + * + * - {@link UTC_CONNECTION_PARAMETERS} forces every session's `TimeZone` to + * `UTC`, so all three write paths store the same wall clock — UTC. + * - {@link UTC_TIMESTAMP_TYPES} parses oid 1114 as UTC regardless of the Node + * process's zone, so every read path recovers that instant exactly. + * + * Together they make naive-timestamp round-trips independent of both the + * database session zone and the process zone. Production already runs both in + * UTC, so this changes nothing there and makes every other environment behave + * the way production does. + * + * `timestamptz` columns (oid 1184) are deliberately untouched: they already + * carry an offset on the wire and round-trip correctly on their own. + */ + +/** Postgres oid of `timestamp without time zone`. */ +const TIMESTAMP_OID = 1114 + +/** + * postgres.js startup parameters that pin the session's `TimeZone`. + * + * Applied to every client so `now()` and any `timestamptz → timestamp` cast + * render UTC wall clocks, matching what drizzle's `toISOString()` write already + * stores. + */ +export const UTC_CONNECTION_PARAMETERS = { TimeZone: 'UTC' } as const + +/** + * postgres.js `types` entry that reads and writes oid 1114 as UTC. + * + * `parse` appends the explicit `Z` that the naive wire form omits, which is what + * makes the recovered instant independent of the process's local zone. `to` is + * never selected by postgres.js's type inference (a `Date` infers as 1184), so + * the serializer exists only to keep the entry self-consistent for an explicit + * `sql.typed` bind. + */ +export const UTC_TIMESTAMP_TYPES = { + utcTimestamp: { + to: TIMESTAMP_OID, + from: [TIMESTAMP_OID], + serialize: (value: Date | string): string => + (value instanceof Date ? value : new Date(value)).toISOString(), + parse: (value: string): Date => new Date(`${value}Z`), + }, +} + +interface PostgresConnectionOptions { + connection?: Record +} + +/** + * Applies the UTC pinning to a postgres.js options object. + * + * Every client is built through this rather than spreading the two constants by + * hand, because `connection` is a nested object: a client that sets its own + * `application_name` replaces the whole sub-object and would silently drop the + * session `TimeZone`. Merging in one place is what makes "a new pool is + * UTC-correct" true by construction instead of by review. + */ +export function withUtcTimestamps(options: T) { + return { + ...options, + connection: { ...options.connection, ...UTC_CONNECTION_PARAMETERS }, + types: UTC_TIMESTAMP_TYPES, + } +} diff --git a/scripts/check-openapi-specs.ts b/scripts/check-openapi-specs.ts index d13fff64786..4dbae563db4 100644 --- a/scripts/check-openapi-specs.ts +++ b/scripts/check-openapi-specs.ts @@ -59,12 +59,17 @@ const SPEC_FILES = OPENAPI_SPEC_FILES * A stale entry — one whose contract no longer exists, or which has since * been documented — also fails, so the list cannot rot into a blanket * exemption. + * + * Being unpublished is about *addressability*, not about behaviour: both + * entries below answer in the canonical `{ error: { code, message } }` envelope + * like every documented route, and what a caller needs in order to perform the + * transfer is published on `transfer.url` in `contracts/v2/uploads.ts`. */ const UNDOCUMENTED_V2_ROUTES: Readonly> = { 'PUT /api/v2/uploads/{uploadId}': - 'Local-storage data plane for a signed whole-object upload. Authenticated by the short-lived upload-token minted by the documented session-create operation, not by an API key; carries no v2 feature gate and returns bare error bodies rather than the canonical v2 envelope. The URL is handed to the client by the session response and is never constructed from docs.', + 'Local-storage data plane for a signed whole-object upload. Authenticated by the short-lived upload-token minted by the documented session-create operation, not by an API key, so it carries neither the v2 API-key security scheme nor the rate-limit and feature-gate responses `checkV2Conventions` requires of a published operation. On a cloud deployment the same field points at object storage instead, so the endpoint is described by `transfer.url` — which publishes its method, headers, success status, and error codes — rather than by an operation of its own.', 'PUT /api/v2/uploads/{uploadId}/parts/{partNumber}': - 'Local-storage data plane for a signed multipart part upload. Authenticated by a per-part signed `token` query param minted by the documented part-URL operation, not by an API key; same non-canonical envelope and self-describing URL as the whole-object PUT above.', + 'Local-storage data plane for a signed multipart part upload. Authenticated by a per-part signed `token` query param minted by the documented part-URL operation, not by an API key; same reasoning and same published `transfer.url` contract as the whole-object PUT above.', } /** From 3ed6ed6f88a7e9a7a08e207b7329d85510989212 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 20:07:44 -0700 Subject: [PATCH 26/56] improvement(v2): cut the extraneous half out of the published descriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v2 spec's description median was already healthy at 42 characters; the tail was not. 174 descriptions ran past 200 characters and 13 past 700, almost all of it rationale, cross-references, and constraints restated on the wrong object. Trim the shared error, folder-path, retention, pagination, and workspace-key constants first, since each is published on between two and twenty-seven operations. `FOLDER_TREE_TOO_LARGE` dropped the clause explaining why the tree has to load, `FULL_SET_LIST` dropped a second sentence restating its first, `RUN_RETENTION` dropped the `runCount` caveat that already lives on `runCount`, and the 503 and 499 descriptions dropped the paragraphs narrating why they are documented at all. That reasoning belongs in the TSDoc beside each constant, which is where it now is. Then the operations. Execute Workflow and List Runs each restated a rule their own parameters already carry — the `X-Run-Id` uniqueness claim and the `order` sort deviation — so both moved to the parameter that owns them. The run-status enum sent a caller to `paused.automaticResumeWaitingReason` and then explained that field in place of describing it; the explanation moved onto the field, which previously said only that it was "the reason automatic resume is waiting". Align the parameter vocabulary a caller meets in every family. One `cursor` description had forked on the table row query, one `sortBy` on knowledge documents, and the table row `limit` published neither its bounds nor its default. `nameSortCollation` is now a function of the column it names, so the knowledge document list can state the caveat about `filename` without claiming a `name` field it does not have. `scripts/openapi/documents.test.ts` pins `cursor` and `sortOrder` to one string each, and the retention window to both reads that publish it. Distribution over the seven documents: mean 71 to 67, p95 223 to 199, p99 453 to 370. Over 200 characters 174 to 147, over 300 94 to 59, over 400 54 to 20, over 700 13 to 9. The median is unchanged at 42. --- apps/docs/openapi-v2-billing.json | 12 +-- apps/docs/openapi-v2-files-audit.json | 48 +++++------ apps/docs/openapi-v2-knowledge.json | 56 ++++++------ apps/docs/openapi-v2-logs.json | 36 ++++---- apps/docs/openapi-v2-resources.json | 78 ++++++++--------- apps/docs/openapi-v2-tables.json | 66 +++++++------- apps/docs/openapi-v2-workflows.json | 86 +++++++++---------- apps/sim/lib/api/contracts/v2/custom-tools.ts | 2 +- apps/sim/lib/api/contracts/v2/files.ts | 10 +-- apps/sim/lib/api/contracts/v2/knowledge.ts | 9 +- apps/sim/lib/api/contracts/v2/logs.ts | 10 +-- apps/sim/lib/api/contracts/v2/mcp-servers.ts | 2 +- .../api/contracts/v2/openapi/files-audit.ts | 10 +-- .../lib/api/contracts/v2/openapi/knowledge.ts | 16 ++-- apps/sim/lib/api/contracts/v2/openapi/logs.ts | 4 +- .../lib/api/contracts/v2/openapi/resources.ts | 14 +-- .../lib/api/contracts/v2/openapi/shared.ts | 30 +++---- .../lib/api/contracts/v2/openapi/tables.ts | 20 ++--- .../lib/api/contracts/v2/openapi/workflows.ts | 10 +-- apps/sim/lib/api/contracts/v2/shared.ts | 16 ++-- apps/sim/lib/api/contracts/v2/skills.ts | 2 +- apps/sim/lib/api/contracts/v2/tables.ts | 18 ++-- apps/sim/lib/api/contracts/v2/workflows.ts | 22 ++--- apps/sim/lib/api/contracts/v2/workspaces.ts | 2 +- apps/sim/lib/api/contracts/workflows.ts | 4 +- scripts/openapi/documents.test.ts | 56 ++++++++++++ 26 files changed, 350 insertions(+), 289 deletions(-) diff --git a/apps/docs/openapi-v2-billing.json b/apps/docs/openapi-v2-billing.json index 3613a96dc8c..d32f23998a4 100644 --- a/apps/docs/openapi-v2-billing.json +++ b/apps/docs/openapi-v2-billing.json @@ -285,13 +285,13 @@ } }, "Retry-After": { - "description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset.", + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset.", "schema": { "type": "integer", "minimum": 0, "maximum": 9007199254740991, "title": "Retry after", - "description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset." + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset." } }, "X-Run-Id": { @@ -366,7 +366,7 @@ } }, "RunIdConflict": { - "description": "The run cannot be started. Two causes share this status, distinguished by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already associated with a different request, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has already reached the maximum workflow-to-workflow call depth.", + "description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.", "headers": { "X-Run-Id": { "$ref": "#/components/headers/X-Run-Id" @@ -381,7 +381,7 @@ } }, "PayloadTooLarge": { - "description": "The request, or a resource collection it must materialize, exceeds the allowed size. Besides an oversized request body, this covers a generated artifact that renders past the download ceiling and a workspace folder tree too large to load in full.", + "description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.", "content": { "application/json": { "schema": { @@ -426,7 +426,7 @@ } }, "ClientClosedRequest": { - "description": "The client closed the connection before the response was produced. The response is written to a connection that is already gone, so the caller that caused it never reads it; it is documented only on operations where an abort can leave work running. There, `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", + "description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", "content": { "application/json": { "schema": { @@ -446,7 +446,7 @@ } }, "ServiceUnavailable": { - "description": "A required service is temporarily unavailable. The condition is transient, so the response normally carries `Retry-After` with the number of seconds to wait; treat that value as a floor and add jitter before retrying. One case deliberately omits the header: when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, the run may already have started, so retrying could start and bill a second run. Reconcile against the returned run id instead of retrying.", + "description": "A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.", "headers": { "Retry-After": { "$ref": "#/components/headers/Retry-After" diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index cb91d164f0b..c6b5588002e 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Sim API v2 — Files & Audit Logs", - "description": "Version 2 of the Sim REST API for workspace files and organization audit logs. Lists use opaque cursors, and rate-limit state is returned in response headers. Download File streams raw bytes as `application/octet-stream`; every other response uses the canonical v2 data, cursor-list, or error envelope.", + "description": "Version 2 of the Sim REST API for workspace files, resumable uploads, public shares, and organization audit logs.", "version": "2.0.0", "contact": { "name": "Sim Support", @@ -40,7 +40,7 @@ "get": { "operationId": "listFiles", "summary": "List Files", - "description": "List workspace files with search, sorting, folder filtering, and opaque cursor pagination. Defaults to active files; pass `scope=archived` to page over soft-deleted files, whose `deletedAt` is non-null and which `POST /files/{fileId}/restore` can bring back. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", + "description": "List workspace files with search, sorting, folder filtering, and opaque cursor pagination. Defaults to active files; pass `scope=archived` to page over soft-deleted ones. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Files"], "parameters": [ { @@ -68,10 +68,10 @@ "name": "scope", "in": "query", "required": false, - "description": "Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted and that `POST /files/{fileId}/restore` can bring back. `folderPath` resolves against active folders only, so combining it with `scope=archived` returns an empty page when the containing folder was archived too.", + "description": "Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", "schema": { "default": "active", - "description": "Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted and that `POST /files/{fileId}/restore` can bring back. `folderPath` resolves against active folders only, so combining it with `scope=archived` returns an empty page when the containing folder was archived too.", + "description": "Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", "type": "string", "enum": ["active", "archived"] } @@ -697,7 +697,7 @@ "delete": { "operationId": "deleteFile", "summary": "Delete File", - "description": "Archive a workspace file. This is a soft delete: the row is retained with a deletion timestamp, the file stops appearing in the default listing and is no longer readable through the API, and its stored bytes are never removed. Archiving an already-archived file is a `404`, not a no-op. List archived files with `GET /files?scope=archived` and reverse the delete with `POST /files/{fileId}/restore`.", + "description": "Archive a workspace file. This is a soft delete: the file stops appearing in the default listing and is no longer readable through the API, but its stored bytes are never removed. Archiving an already-archived file is a `404`, not a no-op. List archived files with `GET /files?scope=archived`, and reverse the delete with `POST /files/{fileId}/restore`.", "tags": ["Files"], "parameters": [ { @@ -857,7 +857,7 @@ "post": { "operationId": "restoreFile", "summary": "Restore File", - "description": "Reverse a soft delete and return the file to the workspace. Restore is not a pure undo — the file comes back at the workspace root, and gains a `_restored` suffix when another file there already holds its name — so read `folderPath` and `name` off the response. Restoring an already-active file is a no-op that returns it, so a retry is safe. An archived workspace is a 400, and a name the restore could not free is a 409.", + "description": "Reverse a soft delete and return the file to the workspace. Not a pure undo: the file comes back at the workspace root, and gains a `_restored` suffix when another file there already holds its name, so read `folderPath` and `name` off the response. Restoring an already-active file returns it unchanged, so a retry is safe. An archived workspace is a `400`, and a name the restore could not free is a `409`.", "tags": ["Files"], "parameters": [ { @@ -1019,7 +1019,7 @@ "get": { "operationId": "listAuditLogs", "summary": "List Audit Logs", - "description": "List an organization audit trail with filters and opaque cursor pagination. Requires an Enterprise subscription and organization admin or owner access. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.", + "description": "List an organization audit trail with filters and opaque cursor pagination. Requires an Enterprise subscription and organization admin or owner access. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Audit Logs"], "parameters": [ { @@ -1192,7 +1192,7 @@ "get": { "operationId": "getAuditLog", "summary": "Get Audit Log", - "description": "Return one organization audit-log entry. Requires an Enterprise subscription and organization admin or owner access. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.", + "description": "Return one organization audit-log entry. Requires an Enterprise subscription and organization admin or owner access. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Audit Logs"], "parameters": [ { @@ -1413,7 +1413,7 @@ "patch": { "operationId": "upsertFileShare", "summary": "Enable or Disable File Share", - "description": "Create or partially update a server-tokenized public share. Only `isActive` is required; each other field states what enabling a mode does to it. A file that has never been shared has nothing stored to fall back on, so enabling any mode other than `public` must carry its credential in the same request. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.", + "description": "Create or partially update a server-tokenized public share. Only `isActive` is required; each other field states what enabling a mode does to it. Enabling any mode other than `public` on a file that has never been shared must carry its credential in the same request. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Files"], "parameters": [ { @@ -1644,7 +1644,7 @@ "get": { "operationId": "listFilesFolders", "summary": "List Folders", - "description": "List workspace file folders with optional parent-path filtering and sorting. The bounded set is returned in one page with `nextCursor` always null; there is no second page to fetch.", + "description": "List workspace file folders with optional parent-path filtering and sorting. The bounded set is returned in one page; `nextCursor` is always null.", "tags": ["Files"], "parameters": [ { @@ -2053,13 +2053,13 @@ } }, "Retry-After": { - "description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset.", + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset.", "schema": { "type": "integer", "minimum": 0, "maximum": 9007199254740991, "title": "Retry after", - "description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset." + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset." } }, "X-Run-Id": { @@ -2134,7 +2134,7 @@ } }, "RunIdConflict": { - "description": "The run cannot be started. Two causes share this status, distinguished by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already associated with a different request, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has already reached the maximum workflow-to-workflow call depth.", + "description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.", "headers": { "X-Run-Id": { "$ref": "#/components/headers/X-Run-Id" @@ -2149,7 +2149,7 @@ } }, "PayloadTooLarge": { - "description": "The request, or a resource collection it must materialize, exceeds the allowed size. Besides an oversized request body, this covers a generated artifact that renders past the download ceiling and a workspace folder tree too large to load in full.", + "description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.", "content": { "application/json": { "schema": { @@ -2194,7 +2194,7 @@ } }, "ClientClosedRequest": { - "description": "The client closed the connection before the response was produced. The response is written to a connection that is already gone, so the caller that caused it never reads it; it is documented only on operations where an abort can leave work running. There, `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", + "description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", "content": { "application/json": { "schema": { @@ -2214,7 +2214,7 @@ } }, "ServiceUnavailable": { - "description": "A required service is temporarily unavailable. The condition is transient, so the response normally carries `Retry-After` with the number of seconds to wait; treat that value as a floor and add jitter before retrying. One case deliberately omits the header: when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, the run may already have started, so retrying could start and bill a second run. Reconcile against the returned run id instead of retrying.", + "description": "A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.", "headers": { "Retry-After": { "$ref": "#/components/headers/Retry-After" @@ -2268,7 +2268,7 @@ }, "FolderPathInput": { "title": "Folder path input", - "description": "Folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. Unicode is supported encoded. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", + "description": "Folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", "maxLength": 4096, "type": "string" }, @@ -2288,12 +2288,12 @@ "size": { "type": "number", "minimum": 0, - "description": "Size in bytes of the stored file. For a generated document (docx, pptx, pdf, xlsx) the stored file is the generation source rather than the rendered document, so this does not predict how many bytes `GET /files/{fileId}` returns — that endpoint serves the compiled artifact, which is typically much larger.", + "description": "Size in bytes of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source, not the rendered document, so it does not predict how many bytes `GET /files/{fileId}` returns.", "examples": [1024] }, "type": { "type": "string", - "description": "MIME type of the stored file. For a generated document (docx, pptx, pdf, xlsx) the stored file is the generation source, so this describes the source and not what `GET /files/{fileId}` serves — that endpoint returns the compiled artifact under the rendered document type.", + "description": "MIME type of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source type, not the rendered document type `GET /files/{fileId}` serves.", "examples": ["text/csv"] }, "key": { @@ -2457,7 +2457,7 @@ }, "content": { "default": "", - "description": "Initial file content. Omit or send an empty string for a zero-byte file. The 70,000,000-character bound is a JSON-envelope guard, not the file-size limit: the decoded bytes must be at most 50 MiB, so a longer base64 payload is admitted here and then rejected with 413. Use an upload session for anything larger.", + "description": "Initial file content. Omit or send an empty string for a zero-byte file. The 70,000,000-character bound guards the JSON envelope; the decoded bytes must be at most 50 MiB, and a longer base64 payload is rejected with `413`. Use an upload session for anything larger.", "type": "string", "maxLength": 70000000 }, @@ -2970,12 +2970,12 @@ "size": { "type": "number", "minimum": 0, - "description": "Size in bytes of the stored file. For a generated document (docx, pptx, pdf, xlsx) the stored file is the generation source rather than the rendered document, so this does not predict how many bytes `GET /files/{fileId}` returns — that endpoint serves the compiled artifact, which is typically much larger.", + "description": "Size in bytes of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source, not the rendered document, so it does not predict how many bytes `GET /files/{fileId}` returns.", "examples": [1024] }, "type": { "type": "string", - "description": "MIME type of the stored file. For a generated document (docx, pptx, pdf, xlsx) the stored file is the generation source, so this describes the source and not what `GET /files/{fileId}` serves — that endpoint returns the compiled artifact under the rendered document type.", + "description": "MIME type of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source type, not the rendered document type `GET /files/{fileId}` serves.", "examples": ["text/csv"] }, "key": { @@ -3501,7 +3501,7 @@ "content": { "type": "string", "maxLength": 70000000, - "description": "Complete replacement content for the file. The 70,000,000-character bound is a JSON-envelope guard, not the file-size limit: the decoded bytes must be at most 50 MiB, so a longer base64 payload is admitted here and then rejected with 413." + "description": "Complete replacement content for the file. The 70,000,000-character bound guards the JSON envelope; the decoded bytes must be at most 50 MiB, and a longer base64 payload is rejected with `413`." }, "encoding": { "default": "utf-8", @@ -3673,7 +3673,7 @@ }, "NonRootFolderPathInput": { "title": "Non-root folder path input", - "description": "Non-root folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. Unicode is supported encoded. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", + "description": "Non-root folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", "maxLength": 4096, "type": "string" }, diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index 776b52483c7..a2bbd086504 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -36,7 +36,7 @@ "get": { "operationId": "listKnowledgeBases", "summary": "List Knowledge Bases", - "description": "List knowledge bases in a workspace with folder filtering, search, and sorting. Paginate with `limit` and `cursor`, stopping when `nextCursor` is null. An unknown `folderPath` returns an empty page. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", + "description": "List knowledge bases in a workspace with folder filtering, search, sorting, and opaque cursor pagination. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -64,9 +64,9 @@ "name": "search", "in": "query", "required": false, - "description": "Case-insensitive substring search on the resource name.", + "description": "Case-insensitive substring match against the resource name.", "schema": { - "description": "Case-insensitive substring search on the resource name.", + "description": "Case-insensitive substring match against the resource name.", "type": "string", "minLength": 1, "maxLength": 200 @@ -172,7 +172,7 @@ "post": { "operationId": "createKnowledgeBase", "summary": "Create Knowledge Base", - "description": "Create a knowledge base in a workspace with optional folder placement and chunking configuration. An unknown `folderPath` is a 404. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", + "description": "Create a knowledge base in a workspace with optional folder placement and chunking configuration. An unknown `folderPath` is a `404`. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Knowledge Bases"], "requestBody": { "required": true, @@ -241,7 +241,7 @@ "get": { "operationId": "getKnowledgeBase", "summary": "Get Knowledge Base", - "description": "Retrieve a knowledge base by identifier. Inaccessible knowledge bases are reported as not found. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", + "description": "Retrieve a knowledge base by identifier. Inaccessible knowledge bases are reported as not found. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -318,7 +318,7 @@ "patch": { "operationId": "updateKnowledgeBase", "summary": "Update Knowledge Base", - "description": "Update a knowledge base name, description, chunking configuration, or folder placement. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", + "description": "Update a knowledge base name, description, chunking configuration, or folder placement. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -474,7 +474,7 @@ "post": { "operationId": "searchKnowledge", "summary": "Search Knowledge", - "description": "Search one or more knowledge bases with semantic vector retrieval, optional hybrid full-text retrieval, and structured tag filters. Every result names the `knowledgeBaseId` it came from. The request body is capped at 2 MiB; a larger body is a 413.", + "description": "Search one or more knowledge bases with semantic vector retrieval, optional hybrid full-text retrieval, and structured tag filters. Every result names the `knowledgeBaseId` it came from. A request body over 2 MiB is a `413`.", "tags": ["Knowledge Bases"], "requestBody": { "required": true, @@ -543,7 +543,7 @@ "get": { "operationId": "listKnowledgeTags", "summary": "List Tags", - "description": "List the knowledge base's tag vocabulary: each tag's display name, the slot it is stored in, and its field type. Filters and document reads use display names; document writes address slots, each in its declared type. The vocabulary is bounded by the fixed slot table. The bounded set is returned in one page with `nextCursor` always null; there is no second page to fetch.", + "description": "List the knowledge base's tag vocabulary: each tag's display name, the slot it is stored in, and its field type. Filters and document reads use display names; document writes address slots. The bounded set is returned in one page; `nextCursor` is always null.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -619,7 +619,7 @@ "get": { "operationId": "listKnowledgeDocuments", "summary": "List Documents", - "description": "List documents in a knowledge base with filename search, state filtering, tag filtering, sorting, and opaque cursor pagination. Each document carries its tag values keyed by tag display name; resolve those names to write slots with `GET /api/v2/knowledge/{id}/tags`.", + "description": "List documents in a knowledge base with filename search, state filtering, tag filtering, sorting, and opaque cursor pagination. Tag values are keyed by display name; resolve those to write slots with `GET /api/v2/knowledge/{id}/tags`.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -685,10 +685,10 @@ "name": "sortBy", "in": "query", "required": false, - "description": "Document field used to sort results.", + "description": "Field used to sort the result. Sorting by `filename` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "schema": { "default": "uploadedAt", - "description": "Document field used to sort results.", + "description": "Field used to sort the result. Sorting by `filename` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "type": "string", "enum": [ "filename", @@ -786,7 +786,7 @@ "patch": { "operationId": "bulkUpdateKnowledgeDocuments", "summary": "Bulk Enable or Disable Documents", - "description": "Enable or disable many documents in one request, either by identifier or, with `selectAll`, every document in the knowledge base. Bulk delete is deliberately not offered: the bulk path records no audit entries, so deletions go through `DELETE /api/v2/knowledge/{id}/documents/{documentId}`, which audits each one. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.", + "description": "Enable or disable many documents in one request, either by identifier or, with `selectAll`, every document in the knowledge base. Bulk delete is not offered; delete documents one at a time with `DELETE /api/v2/knowledge/{id}/documents/{documentId}`. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -1449,7 +1449,7 @@ "patch": { "operationId": "updateKnowledgeDocument", "summary": "Update Document", - "description": "Rename a document, enable or disable it for search, set any of its 17 tag slots, or requeue it for processing. Absent fields are unchanged, and derived indexing state — `chunkCount`, `tokenCount`, `characterCount`, `processingStatus`, `processingError` — is written by the processing pipeline and cannot be asserted here. Resolve a tag display name to its slot with `GET /api/v2/knowledge/{id}/tags`. The returned document omits the connector provenance the detail read carries. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.", + "description": "Rename a document, enable or disable it for search, set any of its 17 tag slots, or requeue it for processing. Absent fields are unchanged, and derived indexing state is read-only. Resolve a tag display name to its slot with `GET /api/v2/knowledge/{id}/tags`. The returned document omits the connector provenance the detail read carries. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -1537,7 +1537,7 @@ "delete": { "operationId": "deleteKnowledgeDocument", "summary": "Delete Document", - "description": "Remove one document from a knowledge base. What that means depends on the document. A directly uploaded document is deleted outright along with its indexed chunks. A connector-backed document is instead excluded: its row survives, marked excluded and disabled so it stops being searchable and a later connector sync does not re-add it, and its embeddings are not deleted. Either way the document no longer appears in listings or search results.", + "description": "Remove one document from a knowledge base. An uploaded document is deleted outright with its indexed chunks. A connector-backed document is instead excluded — its row and embeddings survive, but it stops being searchable and a later sync does not re-add it. Either way it no longer appears in listings or search results.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -1624,7 +1624,7 @@ "get": { "operationId": "listKnowledgeFolders", "summary": "List Folders", - "description": "List folders in the knowledge-base folder tree with filtering and sorting. The bounded set is returned in one page with `nextCursor` always null; there is no second page to fetch. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", + "description": "List folders in the knowledge-base folder tree with filtering and sorting. The bounded set is returned in one page; `nextCursor` is always null. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -1736,7 +1736,7 @@ "post": { "operationId": "createKnowledgeFolder", "summary": "Create Folder", - "description": "Create a folder in the knowledge-base folder tree. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", + "description": "Create a folder in the knowledge-base folder tree. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Knowledge Bases"], "requestBody": { "required": true, @@ -1803,7 +1803,7 @@ "patch": { "operationId": "relocateKnowledgeFolder", "summary": "Rename or Move Folder", - "description": "Rename or move a folder and atomically rewrite descendant paths. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", + "description": "Rename or move a folder and atomically rewrite descendant paths. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Knowledge Bases"], "requestBody": { "required": true, @@ -2014,13 +2014,13 @@ } }, "Retry-After": { - "description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset.", + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset.", "schema": { "type": "integer", "minimum": 0, "maximum": 9007199254740991, "title": "Retry after", - "description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset." + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset." } }, "X-Run-Id": { @@ -2095,7 +2095,7 @@ } }, "RunIdConflict": { - "description": "The run cannot be started. Two causes share this status, distinguished by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already associated with a different request, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has already reached the maximum workflow-to-workflow call depth.", + "description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.", "headers": { "X-Run-Id": { "$ref": "#/components/headers/X-Run-Id" @@ -2110,7 +2110,7 @@ } }, "PayloadTooLarge": { - "description": "The request, or a resource collection it must materialize, exceeds the allowed size. Besides an oversized request body, this covers a generated artifact that renders past the download ceiling and a workspace folder tree too large to load in full.", + "description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.", "content": { "application/json": { "schema": { @@ -2155,7 +2155,7 @@ } }, "ClientClosedRequest": { - "description": "The client closed the connection before the response was produced. The response is written to a connection that is already gone, so the caller that caused it never reads it; it is documented only on operations where an abort can leave work running. There, `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", + "description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", "content": { "application/json": { "schema": { @@ -2175,7 +2175,7 @@ } }, "ServiceUnavailable": { - "description": "A required service is temporarily unavailable. The condition is transient, so the response normally carries `Retry-After` with the number of seconds to wait; treat that value as a floor and add jitter before retrying. One case deliberately omits the header: when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, the run may already have started, so retrying could start and bill a second run. Reconcile against the returned run id instead of retrying.", + "description": "A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.", "headers": { "Retry-After": { "$ref": "#/components/headers/Retry-After" @@ -2229,7 +2229,7 @@ }, "FolderPathInput": { "title": "Folder path input", - "description": "Folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. Unicode is supported encoded. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", + "description": "Folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", "maxLength": 4096, "type": "string" }, @@ -2699,7 +2699,7 @@ "rerankerStatus": { "type": "string", "enum": ["not_requested", "skipped", "unavailable", "applied"], - "description": "What the reranker did on this search. `applied` means it ordered the results, which carry `rerankerScore`. `unavailable` means reranking was requested and attempted but could not complete, so results are in vector order and carry no `rerankerScore` — the search still succeeded, and the request is worth retrying. `skipped` means there was nothing to rank: a tag-only search, or no matching chunks. `not_requested` means `rerankerEnabled` was absent or false.", + "description": "What the reranker did on this search. `applied` means it ordered the results, which carry `rerankerScore`. `unavailable` means it was attempted but could not complete, so results are in vector order with no `rerankerScore` — the search still succeeded, and is worth retrying. `skipped` means there was nothing to rank. `not_requested` means `rerankerEnabled` was absent or false.", "examples": ["applied"] } }, @@ -2818,7 +2818,7 @@ "maximum": 100 }, "tagFilters": { - "description": "Structured tag filters. Supported across multiple knowledge bases, but each filtered tag must resolve to the same slot and field type in every knowledge base selected; a tag missing from one of them, or defined inconsistently across them, is rejected and those knowledge bases must be searched separately. A tag name defined in none of the selected knowledge bases is rejected, never ignored; list the available names with GET /api/v2/knowledge/{id}/tags.", + "description": "Structured tag filters. Each filtered tag must resolve to the same slot and field type in every knowledge base selected; one missing from any of them, or defined inconsistently across them, is rejected rather than ignored, and those knowledge bases must be searched separately. List the available names with `GET /api/v2/knowledge/{id}/tags`.", "type": "array", "items": { "$ref": "#/components/schemas/V2KnowledgeSearchTagFilter" @@ -2838,7 +2838,7 @@ ] }, "rerankerEnabled": { - "description": "Re-order retrieved chunks with a reranking model before truncating to `topK`. Ignored for a tag-only search, which has no query to rank against. Reranking is billed as an additional search unit. Whether it actually ran is reported by `rerankerStatus` on the response: reranking is best-effort, and a provider failure falls back to vector ordering rather than failing the search.", + "description": "Re-order retrieved chunks with a reranking model before truncating to `topK`. Ignored for a tag-only search, and billed as an additional search unit. Reranking is best-effort — a provider failure falls back to vector ordering, so check `rerankerStatus` on the response.", "type": "boolean" }, "rerankerModel": { @@ -4087,7 +4087,7 @@ }, "NonRootFolderPathInput": { "title": "Non-root folder path input", - "description": "Non-root folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. Unicode is supported encoded. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", + "description": "Non-root folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", "maxLength": 4096, "type": "string" }, diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index 8392980e38e..7f9395febdd 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -36,7 +36,7 @@ "get": { "operationId": "listLogs", "summary": "List Logs", - "description": "List workflow execution logs for a workspace with filters, selectable detail, and opaque cursor pagination. This list predates the shared sort convention: it has no `sortBy` (the sort column is fixed to execution start time) and spells the direction `order` rather than `sortOrder`. Runs are hard-deleted once they pass the payer's log retention window, so an older run is absent from this list rather than reported as removed. The window is 30 days from run start on the free plan; Pro and Team have none configured and keep runs indefinitely; Enterprise sets its own per organization, with an optional per-workspace override, and is also unbounded until configured. A workflow's `runCount` is never reduced by this deletion, so a workflow can report runs while this list is empty.", + "description": "List workflow execution logs for a workspace with filters, selectable detail, and opaque cursor pagination. Runs are hard-deleted once they pass the payer's log retention window, so an older run is simply absent rather than reported as removed. The window is 30 days from run start on the free plan, unbounded on Pro and Team, and set per organization on Enterprise with an optional per-workspace override.", "tags": ["Logs"], "parameters": [ { @@ -171,9 +171,9 @@ "name": "includeTraceSpans", "in": "query", "required": false, - "description": "Whether to include block-level trace spans. Spans are stored apart from the log row and pruned on their own retention schedule, so a run whose spans have aged out returns `traceSpans: []` rather than an error — an empty array does not mean the run recorded none.", + "description": "Whether to include block-level trace spans. Spans are pruned on their own retention schedule, so a run whose spans have aged out returns `traceSpans: []` rather than an error.", "schema": { - "description": "Whether to include block-level trace spans. Spans are stored apart from the log row and pruned on their own retention schedule, so a run whose spans have aged out returns `traceSpans: []` rather than an error — an empty array does not mean the run recorded none.", + "description": "Whether to include block-level trace spans. Spans are pruned on their own retention schedule, so a run whose spans have aged out returns `traceSpans: []` rather than an error.", "type": "boolean" } }, @@ -213,10 +213,10 @@ "name": "order", "in": "query", "required": false, - "description": "Sort direction by execution start time. This operation deviates from the v2 `sortBy` + `sortOrder` convention: logs are sortable only by start time, so the direction is carried by this single `order` param and `sortBy`/`sortOrder` are not accepted.", + "description": "Sort direction by execution start time. This list is sortable only by execution start time, so it takes `order` in place of `sortBy`/`sortOrder`, which it rejects.", "schema": { "default": "desc", - "description": "Sort direction by execution start time. This operation deviates from the v2 `sortBy` + `sortOrder` convention: logs are sortable only by start time, so the direction is carried by this single `order` param and `sortBy`/`sortOrder` are not accepted.", + "description": "Sort direction by execution start time. This list is sortable only by execution start time, so it takes `order` in place of `sortBy`/`sortOrder`, which it rejects.", "type": "string", "enum": ["asc", "desc"] } @@ -295,20 +295,20 @@ "get": { "operationId": "getLog", "summary": "Get Log", - "description": "Retrieve the diagnostic representation of a run, including its workflow snapshot, trace spans, final output, and cost. Trace spans are stored apart from the log row and pruned on their own retention schedule, so an empty `traceSpans` array does not mean the run recorded none.", + "description": "Retrieve the diagnostic representation of a run, including its workflow snapshot, trace spans, final output, and cost. Trace spans are pruned on their own retention schedule, so an empty `traceSpans` array does not mean the run recorded none.", "tags": ["Logs"], "parameters": [ { "name": "runId", "in": "path", "required": true, - "description": "The unique run identifier shared by lifecycle and diagnostic resources.", + "description": "Unique workflow run identifier.", "schema": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[A-Za-z0-9._:-]+$", - "description": "The unique run identifier shared by lifecycle and diagnostic resources." + "description": "Unique workflow run identifier." } } ], @@ -400,13 +400,13 @@ } }, "Retry-After": { - "description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset.", + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset.", "schema": { "type": "integer", "minimum": 0, "maximum": 9007199254740991, "title": "Retry after", - "description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset." + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset." } }, "X-Run-Id": { @@ -481,7 +481,7 @@ } }, "RunIdConflict": { - "description": "The run cannot be started. Two causes share this status, distinguished by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already associated with a different request, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has already reached the maximum workflow-to-workflow call depth.", + "description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.", "headers": { "X-Run-Id": { "$ref": "#/components/headers/X-Run-Id" @@ -496,7 +496,7 @@ } }, "PayloadTooLarge": { - "description": "The request, or a resource collection it must materialize, exceeds the allowed size. Besides an oversized request body, this covers a generated artifact that renders past the download ceiling and a workspace folder tree too large to load in full.", + "description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.", "content": { "application/json": { "schema": { @@ -541,7 +541,7 @@ } }, "ClientClosedRequest": { - "description": "The client closed the connection before the response was produced. The response is written to a connection that is already gone, so the caller that caused it never reads it; it is documented only on operations where an abort can leave work running. There, `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", + "description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", "content": { "application/json": { "schema": { @@ -561,7 +561,7 @@ } }, "ServiceUnavailable": { - "description": "A required service is temporarily unavailable. The condition is transient, so the response normally carries `Retry-After` with the number of seconds to wait; treat that value as a floor and add jitter before retrying. One case deliberately omits the header: when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, the run may already have started, so retrying could start and bill a second run. Reconcile against the returned run id instead of retrying.", + "description": "A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.", "headers": { "Retry-After": { "$ref": "#/components/headers/Retry-After" @@ -653,7 +653,7 @@ "failed", "cancelled" ], - "description": "Current execution status, reported as persisted. `redacting` is transient while run output is scrubbed. `paused` is reported only when a resume attempt did not run to completion and the run is waiting to be resumed again. **This differs from the run resources for the same run:** `GET /api/v2/workflows/{id}/runs` and `GET /api/v2/workflows/{id}/runs/{runId}` additionally report `paused` for a run held at a human-in-the-loop pause point, which this field reports as `pending`. Use the run resources when the pause state matters." + "description": "Current execution status, reported as persisted. `redacting` is transient while run output is scrubbed. `paused` is reported only when a resume attempt did not complete; a run held at a human-in-the-loop pause point reads `pending` here, and `paused` on the workflow run resources. Use those when the pause state matters." }, "level": { "type": "string", @@ -1049,7 +1049,7 @@ "failed", "cancelled" ], - "description": "Current execution status, reported as persisted. `redacting` is transient while run output is scrubbed. `paused` is reported only when a resume attempt did not run to completion and the run is waiting to be resumed again. **This differs from the run resources for the same run:** `GET /api/v2/workflows/{id}/runs` and `GET /api/v2/workflows/{id}/runs/{runId}` additionally report `paused` for a run held at a human-in-the-loop pause point, which this field reports as `pending`. Use the run resources when the pause state matters." + "description": "Current execution status, reported as persisted. `redacting` is transient while run output is scrubbed. `paused` is reported only when a resume attempt did not complete; a run held at a human-in-the-loop pause point reads `pending` here, and `paused` on the workflow run resources. Use those when the pause state matters." }, "level": { "type": "string", @@ -1137,7 +1137,7 @@ { "type": "string", "title": "Folder path", - "description": "Canonical slash-prefixed folder path. `/` is the workspace root. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. Unicode is supported encoded. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", + "description": "Canonical slash-prefixed folder path. `/` is the workspace root. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", "maxLength": 4096 }, { @@ -1228,7 +1228,7 @@ "type": "null" } ], - "description": "Workflow graph snapshot captured for the run, with credential values redacted: `oauth-input`, `password: true`, and table sub-block values are null; sensitive nested tool parameters and every parameter without authoritative codec metadata are null; and `{{VAR}}` references in non-opaque fields are preserved. Null when no snapshot is retained." + "description": "Workflow graph snapshot captured for the run, or null when none is retained. Credential-bearing values are redacted to null: `oauth-input`, `password: true`, table sub-block values, sensitive nested tool parameters, and any parameter without authoritative codec metadata. `{{VAR}}` references in non-opaque fields are preserved." }, "traceSpans": { "type": "array", diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 7632f53222c..0fa48f2dfc2 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -210,7 +210,7 @@ "get": { "operationId": "listMcpServers", "summary": "List MCP Servers", - "description": "List MCP servers registered in a workspace. Request-header values and OAuth client secrets are never returned. Paginate with `limit` and `cursor`, stopping when `nextCursor` is null. The discovery fields stay at their registration defaults until `GET /api/v2/mcp-servers/{id}/tools` runs a discovery.", + "description": "List MCP servers registered in a workspace. Request-header values and OAuth client secrets are never returned. The discovery fields stay at their registration defaults until `GET /api/v2/mcp-servers/{id}/tools` runs a discovery.", "tags": ["MCP Servers"], "parameters": [ { @@ -333,7 +333,7 @@ "post": { "operationId": "createMcpServer", "summary": "Create MCP Server", - "description": "Register an MCP server in a workspace. The endpoint URL determines server identity, so a URL already registered in the workspace is a `409` — reconfigure that server with `PATCH /api/v2/mcp-servers/{id}` instead. Registration stores the configuration and never connects to the endpoint, so a 201 is not evidence the server is reachable: it comes back `disconnected` and the workspace tool registry treats it as unavailable until `GET /api/v2/mcp-servers/{id}/tools` succeeds.", + "description": "Register an MCP server in a workspace. The endpoint URL is the server identity, so a URL already registered here is a `409` — reconfigure that server with `PATCH /api/v2/mcp-servers/{id}` instead. Registration never connects to the endpoint: the server comes back `disconnected` and stays unavailable until `GET /api/v2/mcp-servers/{id}/tools` succeeds.", "tags": ["MCP Servers"], "requestBody": { "required": true, @@ -409,11 +409,11 @@ "name": "id", "in": "path", "required": true, - "description": "MCP server the operation acts on.", + "description": "Unique MCP server identifier.", "schema": { "type": "string", "minLength": 1, - "description": "MCP server the operation acts on." + "description": "Unique MCP server identifier." } }, { @@ -483,11 +483,11 @@ "name": "id", "in": "path", "required": true, - "description": "MCP server the operation acts on.", + "description": "Unique MCP server identifier.", "schema": { "type": "string", "minLength": 1, - "description": "MCP server the operation acts on." + "description": "Unique MCP server identifier." } } ], @@ -560,11 +560,11 @@ "name": "id", "in": "path", "required": true, - "description": "MCP server the operation acts on.", + "description": "Unique MCP server identifier.", "schema": { "type": "string", "minLength": 1, - "description": "MCP server the operation acts on." + "description": "Unique MCP server identifier." } }, { @@ -629,18 +629,18 @@ "get": { "operationId": "listMcpServerTools", "summary": "List MCP Server Tools", - "description": "Connect to a registered MCP server and return the tools it exposes, completing onboarding without opening the Sim UI. Unlike most reads this one has side effects: it opens a live connection to the third-party server and writes `connectionStatus`, `toolCount`, `lastError`, and `lastToolsRefresh` on the server resource. A `HEAD` skips the effect but is authorized exactly as the `GET` is, so it answers `400`, `401`, `403`, or `404` wherever the `GET` would and an empty `200` otherwise. Discovery itself bounds the set at 1,000 tools and 5 MB of tool payload per server. The bounded set is returned in one page with `nextCursor` always null; there is no second page to fetch. An unreachable, slow, or cooling-down server is a `503`; a stored OAuth grant that no longer works is a `409` with `error.details.code` `MCP_SERVER_REAUTHORIZATION_REQUIRED`, which only a human reauthorizing in Sim can clear. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.", + "description": "Connect to a registered MCP server and return the tools it exposes. This read has side effects: it opens a live connection to the third-party server and writes `connectionStatus`, `toolCount`, `lastError`, and `lastToolsRefresh`. A `HEAD` skips the effect but is authorized exactly as the `GET` is, so it answers `400`, `401`, `403`, or `404` wherever the `GET` would and an empty `200` otherwise. Discovery is bounded at 1,000 tools and 5 MB of tool payload per server. The bounded set is returned in one page; `nextCursor` is always null. An unreachable, slow, or cooling-down server is a `503`; a stored OAuth grant that no longer works is a `409` with `error.details.code` `MCP_SERVER_REAUTHORIZATION_REQUIRED`, which only a human reauthorizing in Sim can clear. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["MCP Servers"], "parameters": [ { "name": "id", "in": "path", "required": true, - "description": "MCP server the operation acts on.", + "description": "Unique MCP server identifier.", "schema": { "type": "string", "minLength": 1, - "description": "MCP server the operation acts on." + "description": "Unique MCP server identifier." } }, { @@ -718,7 +718,7 @@ "get": { "operationId": "listSkills", "summary": "List Skills", - "description": "List workspace and built-in skills. Built-ins are marked read-only. The list omits skill bodies; fetch one skill to read its content. Paginate with `limit` and `cursor`, stopping when `nextCursor` is null.", + "description": "List workspace and built-in skills with opaque cursor pagination. Built-ins are marked read-only. The list omits skill bodies; fetch one skill to read its content.", "tags": ["Skills"], "parameters": [ { @@ -841,7 +841,7 @@ "post": { "operationId": "createSkill", "summary": "Create Skill", - "description": "Create one skill in a workspace. Its kebab-case name must be unique and cannot be reserved by a built-in skill. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.", + "description": "Create one skill in a workspace. Its kebab-case name must be unique and cannot be reserved by a built-in skill. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Skills"], "requestBody": { "required": true, @@ -917,11 +917,11 @@ "name": "id", "in": "path", "required": true, - "description": "Skill to retrieve, update, or delete. A built-in skill is `builtin-` followed by its name, for example `builtin-research`.", + "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`.", "schema": { "type": "string", "minLength": 1, - "description": "Skill to retrieve, update, or delete. A built-in skill is `builtin-` followed by its name, for example `builtin-research`." + "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`." } }, { @@ -984,18 +984,18 @@ "patch": { "operationId": "updateSkill", "summary": "Update Skill", - "description": "Update the supplied fields on a workspace skill. Omitted fields retain their stored values. Built-in skills are read-only. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.", + "description": "Update the supplied fields on a workspace skill. Omitted fields retain their stored values. Built-in skills are read-only. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Skills"], "parameters": [ { "name": "id", "in": "path", "required": true, - "description": "Skill to retrieve, update, or delete. A built-in skill is `builtin-` followed by its name, for example `builtin-research`.", + "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`.", "schema": { "type": "string", "minLength": 1, - "description": "Skill to retrieve, update, or delete. A built-in skill is `builtin-` followed by its name, for example `builtin-research`." + "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`." } } ], @@ -1064,18 +1064,18 @@ "delete": { "operationId": "deleteSkill", "summary": "Delete Skill", - "description": "Delete a workspace skill. Built-in skills are read-only and cannot be deleted. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.", + "description": "Delete a workspace skill. Built-in skills are read-only and cannot be deleted. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Skills"], "parameters": [ { "name": "id", "in": "path", "required": true, - "description": "Skill to retrieve, update, or delete. A built-in skill is `builtin-` followed by its name, for example `builtin-research`.", + "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`.", "schema": { "type": "string", "minLength": 1, - "description": "Skill to retrieve, update, or delete. A built-in skill is `builtin-` followed by its name, for example `builtin-research`." + "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`." } }, { @@ -1140,7 +1140,7 @@ "get": { "operationId": "listCustomTools", "summary": "List Custom Tools", - "description": "List code-backed custom tools defined in a workspace. Legacy personal tools are excluded. Paginate with `limit` and `cursor`, stopping when `nextCursor` is null.", + "description": "List code-backed custom tools defined in a workspace, with opaque cursor pagination. Legacy personal tools are excluded.", "tags": ["Custom Tools"], "parameters": [ { @@ -1339,11 +1339,11 @@ "name": "id", "in": "path", "required": true, - "description": "Custom tool to retrieve, update, or delete.", + "description": "Unique custom tool identifier.", "schema": { "type": "string", "minLength": 1, - "description": "Custom tool to retrieve, update, or delete." + "description": "Unique custom tool identifier." } }, { @@ -1413,11 +1413,11 @@ "name": "id", "in": "path", "required": true, - "description": "Custom tool to retrieve, update, or delete.", + "description": "Unique custom tool identifier.", "schema": { "type": "string", "minLength": 1, - "description": "Custom tool to retrieve, update, or delete." + "description": "Unique custom tool identifier." } } ], @@ -1493,11 +1493,11 @@ "name": "id", "in": "path", "required": true, - "description": "Custom tool to retrieve, update, or delete.", + "description": "Unique custom tool identifier.", "schema": { "type": "string", "minLength": 1, - "description": "Custom tool to retrieve, update, or delete." + "description": "Unique custom tool identifier." } }, { @@ -1562,7 +1562,7 @@ "get": { "operationId": "listCredentials", "summary": "List Credentials", - "description": "List OAuth and service-account connections visible to the caller. Secret material is never returned. Credential mutations and single-resource reads are intentionally not exposed. Paginate with `limit` and `cursor`, stopping when `nextCursor` is null.", + "description": "List OAuth and service-account connections visible to the caller. Secret material is never returned. Credential mutations and single-resource reads are not exposed.", "tags": ["Credentials"], "parameters": [ { @@ -1709,7 +1709,7 @@ "get": { "operationId": "listSecrets", "summary": "List Secrets", - "description": "List workspace and caller-owned personal secret metadata. Only names, scope, role, and timestamps are returned; secret values are never read or returned. Paginate with `limit` and `cursor`, stopping when `nextCursor` is null. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.", + "description": "List workspace and caller-owned personal secret metadata with opaque cursor pagination. Only names, scope, role, and timestamps are returned; secret values are never returned. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Secrets"], "parameters": [ { @@ -1845,7 +1845,7 @@ "put": { "operationId": "setSecret", "summary": "Set Secret", - "description": "Create or replace a workspace or caller-owned personal secret. The value is encrypted at rest, is write-only, and is never included in the response. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.", + "description": "Create or replace a workspace or caller-owned personal secret. The value is encrypted at rest, is write-only, and is never included in the response. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Secrets"], "parameters": [ { @@ -1945,7 +1945,7 @@ "delete": { "operationId": "deleteSecret", "summary": "Delete Secret", - "description": "Delete a workspace or caller-owned personal secret without reading or returning its stored value. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.", + "description": "Delete a workspace or caller-owned personal secret without reading or returning its stored value. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Secrets"], "parameters": [ { @@ -2072,13 +2072,13 @@ } }, "Retry-After": { - "description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset.", + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset.", "schema": { "type": "integer", "minimum": 0, "maximum": 9007199254740991, "title": "Retry after", - "description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset." + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset." } }, "X-Run-Id": { @@ -2153,7 +2153,7 @@ } }, "RunIdConflict": { - "description": "The run cannot be started. Two causes share this status, distinguished by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already associated with a different request, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has already reached the maximum workflow-to-workflow call depth.", + "description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.", "headers": { "X-Run-Id": { "$ref": "#/components/headers/X-Run-Id" @@ -2168,7 +2168,7 @@ } }, "PayloadTooLarge": { - "description": "The request, or a resource collection it must materialize, exceeds the allowed size. Besides an oversized request body, this covers a generated artifact that renders past the download ceiling and a workspace folder tree too large to load in full.", + "description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.", "content": { "application/json": { "schema": { @@ -2213,7 +2213,7 @@ } }, "ClientClosedRequest": { - "description": "The client closed the connection before the response was produced. The response is written to a connection that is already gone, so the caller that caused it never reads it; it is documented only on operations where an abort can leave work running. There, `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", + "description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", "content": { "application/json": { "schema": { @@ -2233,7 +2233,7 @@ } }, "ServiceUnavailable": { - "description": "A required service is temporarily unavailable. The condition is transient, so the response normally carries `Retry-After` with the number of seconds to wait; treat that value as a floor and add jitter before retrying. One case deliberately omits the header: when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, the run may already have started, so retrying could start and bill a second run. Reconcile against the returned run id instead of retrying.", + "description": "A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.", "headers": { "Retry-After": { "$ref": "#/components/headers/Retry-After" @@ -2393,7 +2393,7 @@ }, "isExternal": { "type": "boolean", - "description": "Whether the member belongs to a different organization than the workspace. True for an explicitly granted member whose own organization differs from the workspace's; false for the workspace owner and for a member sharing the workspace organization. Inherited organization-administrator access is always reported as false, so this is not a signal that access came from outside the explicit member list." + "description": "Whether the member belongs to a different organization than the workspace. True only for an explicitly granted member whose own organization differs; inherited organization-administrator access is always reported as false, so this does not detect every outside caller." }, "joinedAt": { "type": "string", diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index c8b580630a2..684dc6dd738 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Sim Tables API v2", - "description": "Manage tables, typed columns, rows, saved views, workflow groups, folders, imports, and exports through the public v2 API. Row data is keyed by column name.", + "description": "Version 2 of the Sim REST API for tables, typed columns, rows, saved views, workflow groups, folders, imports, and exports. Row data is keyed by column name.", "version": "2.0.0", "contact": { "name": "Sim Support", @@ -36,7 +36,7 @@ "get": { "operationId": "listTables", "summary": "List Tables", - "description": "List tables in a workspace with optional folder filtering, search, sorting, and an opaque cursor envelope. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", + "description": "List tables in a workspace with optional folder filtering, search, sorting, and an opaque cursor envelope. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Tables"], "parameters": [ { @@ -64,9 +64,9 @@ "name": "search", "in": "query", "required": false, - "description": "Case-insensitive substring search on the resource name.", + "description": "Case-insensitive substring match against the resource name.", "schema": { - "description": "Case-insensitive substring search on the resource name.", + "description": "Case-insensitive substring match against the resource name.", "type": "string", "minLength": 1, "maxLength": 200 @@ -239,7 +239,7 @@ "get": { "operationId": "getTable", "summary": "Get Table", - "description": "Retrieve a table with its metadata, column schema, locks, and current job. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", + "description": "Retrieve a table with its metadata, column schema, locks, and current job. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Tables"], "parameters": [ { @@ -393,7 +393,7 @@ "patch": { "operationId": "updateTable", "summary": "Update Table", - "description": "Rename a table, edit its description, or move it to a canonical folder. At least one mutable field is required; lock flags remain read-only.\n\nThis operation is NOT atomic. Name, description, and folder are written independently in that order, so a 4xx does NOT mean nothing changed: the error body carries `details.applied` naming the fields that landed. Retry with only the fields missing from it.\n\nA workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", + "description": "Rename a table, edit its description, or move it to a canonical folder. At least one mutable field is required; lock flags remain read-only.\n\nNOT atomic: name, description, and folder are written independently, so a 4xx does not mean nothing changed. The error body carries `details.applied` naming the fields that landed — retry with only the ones missing from it.\n\nA workspace folder tree over 10,000 folders is a `413`.", "tags": ["Tables"], "parameters": [ { @@ -746,10 +746,10 @@ "name": "limit", "in": "query", "required": false, - "description": "Maximum rows to return in the current page.", + "description": "Maximum rows to return per page. Must be a whole number from 1 to 1000. Defaults to 100.", "schema": { "default": 100, - "description": "Maximum rows to return in the current page.", + "description": "Maximum rows to return per page. Must be a whole number from 1 to 1000. Defaults to 100.", "type": "integer", "minimum": 1, "maximum": 1000 @@ -759,9 +759,9 @@ "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "schema": { - "description": "Opaque cursor returned by the previous page.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "type": "string", "minLength": 1 } @@ -1323,7 +1323,7 @@ "post": { "operationId": "upsertTableRow", "summary": "Upsert Row", - "description": "Insert a row or update the existing row that conflicts on a selected unique column.\n\nWARNING — the update branch REPLACES the row, it does not merge. `data` is treated as the complete new row value, so every column you omit is cleared on the matched row. Upserting 2 of 10 columns blanks the other 8. This differs from `PATCH /api/v2/tables/{tableId}/rows/{rowId}`, which merges the patch into the existing row data. Send the full row here, or use PATCH when you only mean to change a subset.", + "description": "Insert a row or update the existing row that conflicts on a selected unique column.\n\nWARNING — the update branch REPLACES the row, it does not merge. `data` is the complete new row value, so every column you omit is cleared on the matched row. Send the full row here, or use `PATCH /api/v2/tables/{tableId}/rows/{rowId}` to change a subset.", "tags": ["Tables"], "parameters": [ { @@ -1484,7 +1484,7 @@ "post": { "operationId": "countTableRows", "summary": "Count Rows", - "description": "Count the rows matching a typed predicate across the entire table. The paged reads carry no total, and rowCount on the table resource counts every row rather than the predicate matches. Omit the predicate to count the whole table. A predicate larger than the request-body ceiling is a `413`.", + "description": "Count the rows matching a typed predicate across the entire table. The paged reads carry no total, and `rowCount` on the table resource counts every row rather than the matches. Omit the predicate to count the whole table. A predicate larger than the request-body ceiling is a `413`.", "tags": ["Tables"], "parameters": [ { @@ -1563,7 +1563,7 @@ "get": { "operationId": "listTableViews", "summary": "List Views", - "description": "List the bounded set of saved table views, with references to removed columns pruned on read. The bounded set is returned in one page with `nextCursor` always null; there is no second page to fetch.", + "description": "List the bounded set of saved table views, with references to removed columns pruned on read. The bounded set is returned in one page; `nextCursor` is always null.", "tags": ["Tables"], "parameters": [ { @@ -1976,7 +1976,7 @@ "get": { "operationId": "listTableWorkflowGroups", "summary": "List Workflow Groups", - "description": "List the workflow and enrichment groups that can be dispatched for a table. The bounded set is returned in one page with `nextCursor` always null; there is no second page to fetch.", + "description": "List the workflow and enrichment groups that can be dispatched for a table. The bounded set is returned in one page; `nextCursor` is always null.", "tags": ["Tables"], "parameters": [ { @@ -2130,7 +2130,7 @@ "patch": { "operationId": "updateTableWorkflowGroup", "summary": "Update Workflow Group", - "description": "Restructure a workflow group, its producer, outputs, or execution behavior.\n\nOutput leaf types are resolved against the group’s workflow outside the write lock. If the group is repointed at a different workflow concurrently, that snapshot is invalidated and the request returns `409` — retry the update.", + "description": "Restructure a workflow group, its producer, outputs, or execution behavior. Repointing the group at a different workflow concurrently invalidates the resolved output types and returns `409` — retry the update.", "tags": ["Tables"], "parameters": [ { @@ -2626,7 +2626,7 @@ "get": { "operationId": "getTableImport", "summary": "Get Table Import", - "description": "Read progress and terminal state for a durable table import.\n\nAn upload-backed import has no durable record until its upload completes, so send the signed upload control token to read it during the `uploading` phase; without the token that phase returns `404`.", + "description": "Read progress and terminal state for a durable table import.\n\nAn upload-backed import has no durable record until its upload completes, so send the signed upload control token to read it during the `uploading` phase; without the token that phase is a `404`.", "tags": ["Tables"], "parameters": [ { @@ -2711,7 +2711,7 @@ "delete": { "operationId": "cancelTableImport", "summary": "Cancel Table Import", - "description": "Cancel an upload or processing import without rolling back committed row batches.\n\nCanceling an import that is not in a cancelable state returns `409` naming the current status, and that includes an expired import — `expired` is a terminal import status, not a `410`. An import id that never existed, or one whose retention window already purged the record, returns `404`.", + "description": "Cancel an upload or processing import without rolling back committed row batches.\n\nAn import that is not in a cancelable state, including an `expired` one, is a `409` naming the current status. An unknown or already-purged import id is a `404`.", "tags": ["Tables"], "parameters": [ { @@ -2801,7 +2801,7 @@ "post": { "operationId": "createTableImportPartUrls", "summary": "Create Table Import Part URLs", - "description": "Issue short-lived signed PUT URLs for a bounded set of multipart part numbers.\n\nThe import must still be in the `uploading` state. An import that has moved on — including one that has `expired` — returns `409` naming the current status; a purged or unknown import id returns `404`.", + "description": "Issue short-lived signed PUT URLs for a bounded set of multipart part numbers.\n\nThe import must still be `uploading`; one that has moved on, including an `expired` one, is a `409` naming the current status. An unknown or already-purged import id is a `404`.", "tags": ["Tables"], "parameters": [ { @@ -2905,7 +2905,7 @@ "post": { "operationId": "completeTableImportUpload", "summary": "Complete Table Import Upload", - "description": "Verify or assemble the uploaded CSV and begin processing with the same import id.\n\nCompleting an import that is no longer awaiting an upload — including one that has `expired` — returns `409` naming the current status; a purged or unknown import id returns `404`.", + "description": "Verify or assemble the uploaded CSV and begin processing with the same import id.\n\nAn import no longer awaiting an upload, including an `expired` one, is a `409` naming the current status. An unknown or already-purged import id is a `404`.", "tags": ["Tables"], "parameters": [ { @@ -3233,7 +3233,7 @@ "get": { "operationId": "downloadTableExport", "summary": "Download Table Export", - "description": "Return a short-lived signed download URL for a completed table export.\n\nThe export must have reached the `completed` status. An export still processing, or one that failed or was canceled, returns `409` naming the current status. An export whose generated file is no longer available — the retention window elapsed, or the object was purged — returns `404` (`Export file is no longer available`), not `410`.", + "description": "Return a short-lived signed download URL for a completed table export.\n\nThe export must have reached `completed`; one still processing, failed, or canceled is a `409` naming the current status. An export whose file is no longer available is a `404`, not a `410`.", "tags": ["Tables"], "parameters": [ { @@ -3391,7 +3391,7 @@ "get": { "operationId": "listTablesFolders", "summary": "List Folders", - "description": "List table folders, optionally restricting the result to direct children of a canonical parent path. The bounded set is returned in one page with `nextCursor` always null; there is no second page to fetch.", + "description": "List table folders, optionally restricting the result to direct children of a canonical parent path. The bounded set is returned in one page; `nextCursor` is always null.", "tags": ["Tables"], "parameters": [ { @@ -3784,13 +3784,13 @@ } }, "Retry-After": { - "description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset.", + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset.", "schema": { "type": "integer", "minimum": 0, "maximum": 9007199254740991, "title": "Retry after", - "description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset." + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset." } }, "X-Run-Id": { @@ -3865,7 +3865,7 @@ } }, "RunIdConflict": { - "description": "The run cannot be started. Two causes share this status, distinguished by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already associated with a different request, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has already reached the maximum workflow-to-workflow call depth.", + "description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.", "headers": { "X-Run-Id": { "$ref": "#/components/headers/X-Run-Id" @@ -3880,7 +3880,7 @@ } }, "PayloadTooLarge": { - "description": "The request, or a resource collection it must materialize, exceeds the allowed size. Besides an oversized request body, this covers a generated artifact that renders past the download ceiling and a workspace folder tree too large to load in full.", + "description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.", "content": { "application/json": { "schema": { @@ -3925,7 +3925,7 @@ } }, "ClientClosedRequest": { - "description": "The client closed the connection before the response was produced. The response is written to a connection that is already gone, so the caller that caused it never reads it; it is documented only on operations where an abort can leave work running. There, `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", + "description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", "content": { "application/json": { "schema": { @@ -3945,7 +3945,7 @@ } }, "ServiceUnavailable": { - "description": "A required service is temporarily unavailable. The condition is transient, so the response normally carries `Retry-After` with the number of seconds to wait; treat that value as a floor and add jitter before retrying. One case deliberately omits the header: when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, the run may already have started, so retrying could start and bill a second run. Reconcile against the returned run id instead of retrying.", + "description": "A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.", "headers": { "Retry-After": { "$ref": "#/components/headers/Retry-After" @@ -3999,7 +3999,7 @@ }, "FolderPathInput": { "title": "Folder path input", - "description": "Folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. Unicode is supported encoded. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", + "description": "Folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", "maxLength": 4096, "type": "string" }, @@ -4125,7 +4125,7 @@ "folderPath": { "type": "string", "title": "Folder path", - "description": "Canonical slash-prefixed folder path. `/` is the workspace root. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. Unicode is supported encoded. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", + "description": "Canonical slash-prefixed folder path. `/` is the workspace root. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", "maxLength": 4096 }, "locks": { @@ -5379,7 +5379,7 @@ "description": "Unique workspace identifier." }, "data": { - "description": "Complete set of row cells keyed by column name. On the update branch this REPLACES the matched row: any column not present here is cleared, unlike the merging PATCH /rows/{rowId}.", + "description": "Complete set of row cells keyed by column name. On the update branch this REPLACES the matched row: any column not present here is cleared, unlike the merging `PATCH /api/v2/tables/{tableId}/rows/{rowId}`.", "$ref": "#/components/schemas/V2TableRowData" }, "conflictTarget": { @@ -7645,11 +7645,11 @@ }, "uploadToken": { "type": "null", - "description": "Always null for workspace-file imports." + "description": "Always null; a workspace-file import has no upload to authorize." }, "transfer": { "type": "null", - "description": "Always null for workspace-file imports." + "description": "Always null; a workspace-file import has no bytes to transfer." } }, "required": ["session", "uploadToken", "transfer"], @@ -8390,7 +8390,7 @@ }, "NonRootFolderPathInput": { "title": "Non-root folder path input", - "description": "Non-root folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. Unicode is supported encoded. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", + "description": "Non-root folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", "maxLength": 4096, "type": "string" }, diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 090325aa4c2..bb51e2a916a 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -40,7 +40,7 @@ "get": { "operationId": "listWorkflows", "summary": "List Workflows", - "description": "List workflows in a workspace with folder and deployment filters, search, sorting, and opaque cursor pagination. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", + "description": "List workflows in a workspace with folder and deployment filters, search, sorting, and opaque cursor pagination. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Workflows"], "parameters": [ { @@ -102,9 +102,9 @@ "name": "search", "in": "query", "required": false, - "description": "Case-insensitive substring search on the resource name.", + "description": "Case-insensitive substring match against the resource name.", "schema": { - "description": "Case-insensitive substring search on the resource name.", + "description": "Case-insensitive substring match against the resource name.", "type": "string", "minLength": 1, "maxLength": 200 @@ -186,7 +186,7 @@ "post": { "operationId": "createWorkflowV2", "summary": "Create Workflow", - "description": "Create a workflow in a workspace root or canonical workflow folder. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", + "description": "Create a workflow in a workspace root or canonical workflow folder. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Workflows"], "requestBody": { "required": true, @@ -258,7 +258,7 @@ "get": { "operationId": "getWorkflow", "summary": "Get Workflow", - "description": "Get a workflow with its variables and deployed API-trigger inputs. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", + "description": "Get a workflow with its variables and deployed API-trigger inputs. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Workflows"], "parameters": [ { @@ -325,7 +325,7 @@ "patch": { "operationId": "updateWorkflowV2", "summary": "Update Workflow", - "description": "Rename, describe, or move a workflow to a canonical folder path. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", + "description": "Rename, describe, or move a workflow to a canonical folder path. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Workflows"], "parameters": [ { @@ -646,7 +646,7 @@ "get": { "operationId": "getWorkflowDeployment", "summary": "Get Workflow Deployment", - "description": "Read the current deployment state of a workflow: whether a version is live, when it went live, the most recent deployment attempt with its readiness and failure payload, and whether the editable draft has since diverged from the live version. This is the only place `needsRedeployment` is published — the deploy, undeploy, and rollback responses cannot carry it, because they answer at the moment the draft and the live version are equal.", + "description": "Read the current deployment state of a workflow: whether a version is live, when it went live, the most recent deployment attempt with its readiness and failure payload, and whether the editable draft has since diverged from the live version. This is the only operation that publishes `needsRedeployment`.", "tags": ["Workflows"], "parameters": [ { @@ -712,7 +712,7 @@ "post": { "operationId": "deployWorkflow", "summary": "Deploy Workflow", - "description": "Create and asynchronously activate a deployment version. This request is not idempotent: every call mints a new deployment version, so retrying after a timeout creates a second version rather than returning the first. A deployment that would conflict with an existing webhook path is a 409. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.", + "description": "Create and asynchronously activate a deployment version. Not idempotent: every call mints a new version, so a retry after a timeout creates a second one. A deployment that would conflict with an existing webhook path is a `409`. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Workflows"], "parameters": [ { @@ -796,7 +796,7 @@ "delete": { "operationId": "undeployWorkflow", "summary": "Undeploy Workflow", - "description": "Deactivate the currently serving workflow version. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.", + "description": "Deactivate the currently serving workflow version. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Workflows"], "parameters": [ { @@ -865,7 +865,7 @@ "post": { "operationId": "rollbackWorkflow", "summary": "Rollback Workflow", - "description": "Asynchronously reactivate a previous deployment version, selecting the preceding active version when no version is supplied. A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.", + "description": "Asynchronously reactivate a previous deployment version, selecting the preceding active version when no version is supplied. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Workflows"], "parameters": [ { @@ -951,7 +951,7 @@ "get": { "operationId": "exportWorkflow", "summary": "Export Workflow", - "description": "Export a portable, secret-sanitized workflow. Workspace-scoped bindings must be selected again after import. Exporting records an audit event, so it is not a safe read. A `HEAD` skips the effect but is authorized exactly as the `GET` is, so it answers `400`, `401`, `403`, or `404` wherever the `GET` would and an empty `200` otherwise. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", + "description": "Export a portable, secret-sanitized workflow. Workspace-scoped bindings must be selected again after import. Exporting records an audit event, so it is not a safe read. A `HEAD` skips the effect but is authorized exactly as the `GET` is, so it answers `400`, `401`, `403`, or `404` wherever the `GET` would and an empty `200` otherwise. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Workflows"], "parameters": [ { @@ -1020,7 +1020,7 @@ "post": { "operationId": "importWorkflow", "summary": "Import Workflow", - "description": "Create a workflow from a portable export object, bare state, or JSON string. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", + "description": "Create a workflow from a portable export object, bare state, or JSON string. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Workflows"], "requestBody": { "required": true, @@ -1092,7 +1092,7 @@ "post": { "operationId": "executeWorkflowV2", "summary": "Execute Workflow", - "description": "Execute a deployed workflow synchronously, asynchronously, or as Server-Sent Events. Public workflows permit anonymous synchronous and streaming execution; asynchronous execution requires an API key. A synchronous run that exceeds its execution timeout returns HTTP 200 with `status: \"failed\"` and `error.code: \"TIMEOUT\"` rather than an HTTP error, so branch on `status`. The optional `X-Run-Id` header is a one-shot uniqueness claim, not an idempotency key: reusing a value returns 409 with `error.details.code: \"RUN_ID_CONFLICT\"` and never replays the earlier run. Each option carries the modes it requires and the modes that reject it; a violated combination is a 400.", + "description": "Execute a deployed workflow synchronously, asynchronously, or as Server-Sent Events. Public workflows permit anonymous synchronous and streaming execution; asynchronous execution requires an API key. A synchronous run that exceeds its execution timeout returns HTTP 200 with `status: \"failed\"` and `error.code: \"TIMEOUT\"` rather than an HTTP error, so branch on `status`. Each option carries the modes it requires and the modes that reject it; a violated combination is a 400.", "tags": ["Workflows"], "security": [ { @@ -1117,9 +1117,9 @@ "name": "x-run-id", "in": "header", "required": false, - "description": "Caller-supplied run identifier, available only to API-key callers. This is a one-shot uniqueness claim, NOT an idempotency key: the first request to use a value starts a run, and any later request reusing it fails with 409 and `error.details.code: \"RUN_ID_CONFLICT\"` instead of replaying the original result. To retry safely, generate a fresh value per attempt and reconcile duplicates yourself, or omit the header and let the server allocate the run identifier.", + "description": "Caller-supplied run identifier, available only to API-key callers. A one-shot uniqueness claim, NOT an idempotency key: reusing a value fails with `409` and `error.details.code: \"RUN_ID_CONFLICT\"` rather than replaying the original result. To retry safely, send a fresh value per attempt, or omit the header and let the server allocate one.", "schema": { - "description": "Caller-supplied run identifier, available only to API-key callers. This is a one-shot uniqueness claim, NOT an idempotency key: the first request to use a value starts a run, and any later request reusing it fails with 409 and `error.details.code: \"RUN_ID_CONFLICT\"` instead of replaying the original result. To retry safely, generate a fresh value per attempt and reconcile duplicates yourself, or omit the header and let the server allocate the run identifier.", + "description": "Caller-supplied run identifier, available only to API-key callers. A one-shot uniqueness claim, NOT an idempotency key: reusing a value fails with `409` and `error.details.code: \"RUN_ID_CONFLICT\"` rather than replaying the original result. To retry safely, send a fresh value per attempt, or omit the header and let the server allocate one.", "type": "string", "minLength": 1, "maxLength": 128, @@ -1131,9 +1131,9 @@ "name": "x-sim-via", "in": "header", "required": false, - "description": "Comma-separated workflow identifiers describing the workflow-to-workflow call chain that led to this request. Each hop appends its own workflow id, and Sim sets it automatically when one workflow calls another; supply it yourself only when relaying an existing chain. A chain already at the maximum depth is rejected with 409 and `error.details.code: \"CALL_CHAIN_DEPTH_EXCEEDED\"`, which is how runaway recursion between workflows is stopped.", + "description": "Comma-separated workflow identifiers naming the workflow-to-workflow call chain that led to this request. Each hop appends its own workflow id, and Sim sets it automatically; supply it yourself only when relaying an existing chain. A chain at the maximum depth is rejected with `409` and `error.details.code: \"CALL_CHAIN_DEPTH_EXCEEDED\"`.", "schema": { - "description": "Comma-separated workflow identifiers describing the workflow-to-workflow call chain that led to this request. Each hop appends its own workflow id, and Sim sets it automatically when one workflow calls another; supply it yourself only when relaying an existing chain. A chain already at the maximum depth is rejected with 409 and `error.details.code: \"CALL_CHAIN_DEPTH_EXCEEDED\"`, which is how runaway recursion between workflows is stopped.", + "description": "Comma-separated workflow identifiers naming the workflow-to-workflow call chain that led to this request. Each hop appends its own workflow id, and Sim sets it automatically; supply it yourself only when relaying an existing chain. A chain at the maximum depth is rejected with `409` and `error.details.code: \"CALL_CHAIN_DEPTH_EXCEEDED\"`.", "type": "string" } } @@ -1243,7 +1243,7 @@ "get": { "operationId": "listWorkflowRunsV2", "summary": "List Workflow Runs", - "description": "List recorded runs of a workflow with filtering and opaque cursor pagination. Ordering deviates from the v2 `sortBy` + `sortOrder` convention: runs are sortable only by start time, so direction is carried by the single `order` param. Runs are hard-deleted once they pass the payer's log retention window, so an older run is absent from this list rather than reported as removed. The window is 30 days from run start on the free plan; Pro and Team have none configured and keep runs indefinitely; Enterprise sets its own per organization, with an optional per-workspace override, and is also unbounded until configured. A workflow's `runCount` is never reduced by this deletion, so a workflow can report runs while this list is empty.", + "description": "List recorded runs of a workflow with filtering and opaque cursor pagination. Runs are hard-deleted once they pass the payer's log retention window, so an older run is simply absent rather than reported as removed. The window is 30 days from run start on the free plan, unbounded on Pro and Team, and set per organization on Enterprise with an optional per-workspace override.", "tags": ["Workflow Runs"], "parameters": [ { @@ -1332,10 +1332,10 @@ "name": "order", "in": "query", "required": false, - "description": "Sort direction by run start time. This operation deviates from the v2 `sortBy` + `sortOrder` convention: runs are sortable only by start time, so the direction is carried by this single `order` param and `sortBy`/`sortOrder` are not accepted.", + "description": "Sort direction by run start time. This list is sortable only by run start time, so it takes `order` in place of `sortBy`/`sortOrder`, which it rejects.", "schema": { "default": "desc", - "description": "Sort direction by run start time. This operation deviates from the v2 `sortBy` + `sortOrder` convention: runs are sortable only by start time, so the direction is carried by this single `order` param and `sortBy`/`sortOrder` are not accepted.", + "description": "Sort direction by run start time. This list is sortable only by run start time, so it takes `order` in place of `sortBy`/`sortOrder`, which it rejects.", "type": "string", "enum": ["asc", "desc"] } @@ -1619,7 +1619,7 @@ "post": { "operationId": "cancelRunV2", "summary": "Cancel Workflow Run", - "description": "Request cancellation of a running, queued, or paused workflow run. Cancelling a run that has already reached a terminal state succeeds with no effect rather than returning an error. A run produced by a table workflow group is a 409 when its cell can no longer accept the cancellation, because the run and its cell must reach the cancelled state together.", + "description": "Request cancellation of a running, queued, or paused workflow run. Cancelling a run already in a terminal state succeeds with no effect. A run produced by a table workflow group is a `409` when its cell can no longer accept the cancellation.", "tags": ["Workflow Runs"], "parameters": [ { @@ -1701,7 +1701,7 @@ "get": { "operationId": "listWorkflowsFolders", "summary": "List Workflow Folders", - "description": "List canonical workflow folders in a workspace. The bounded set is returned in one page with `nextCursor` always null; there is no second page to fetch. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", + "description": "List canonical workflow folders in a workspace. The bounded set is returned in one page; `nextCursor` is always null. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Workflows"], "parameters": [ { @@ -1813,7 +1813,7 @@ "post": { "operationId": "createWorkflowsFolder", "summary": "Create Workflow Folder", - "description": "Create a canonical workflow folder in a workspace. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", + "description": "Create a canonical workflow folder in a workspace. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Workflows"], "requestBody": { "required": true, @@ -1883,7 +1883,7 @@ "patch": { "operationId": "relocateWorkflowsFolder", "summary": "Rename or Move Workflow Folder", - "description": "Rename or move a workflow folder and its descendants to a canonical path. A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.", + "description": "Rename or move a workflow folder and its descendants to a canonical path. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Workflows"], "requestBody": { "required": true, @@ -2100,13 +2100,13 @@ } }, "Retry-After": { - "description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset.", + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset.", "schema": { "type": "integer", "minimum": 0, "maximum": 9007199254740991, "title": "Retry after", - "description": "Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset." + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset." } }, "X-Run-Id": { @@ -2181,7 +2181,7 @@ } }, "RunIdConflict": { - "description": "The run cannot be started. Two causes share this status, distinguished by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already associated with a different request, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has already reached the maximum workflow-to-workflow call depth.", + "description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.", "headers": { "X-Run-Id": { "$ref": "#/components/headers/X-Run-Id" @@ -2196,7 +2196,7 @@ } }, "PayloadTooLarge": { - "description": "The request, or a resource collection it must materialize, exceeds the allowed size. Besides an oversized request body, this covers a generated artifact that renders past the download ceiling and a workspace folder tree too large to load in full.", + "description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.", "content": { "application/json": { "schema": { @@ -2241,7 +2241,7 @@ } }, "ClientClosedRequest": { - "description": "The client closed the connection before the response was produced. The response is written to a connection that is already gone, so the caller that caused it never reads it; it is documented only on operations where an abort can leave work running. There, `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", + "description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", "content": { "application/json": { "schema": { @@ -2261,7 +2261,7 @@ } }, "ServiceUnavailable": { - "description": "A required service is temporarily unavailable. The condition is transient, so the response normally carries `Retry-After` with the number of seconds to wait; treat that value as a floor and add jitter before retrying. One case deliberately omits the header: when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, the run may already have started, so retrying could start and bill a second run. Reconcile against the returned run id instead of retrying.", + "description": "A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.", "headers": { "Retry-After": { "$ref": "#/components/headers/Retry-After" @@ -2315,7 +2315,7 @@ }, "FolderPathInput": { "title": "Folder path input", - "description": "Folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. Unicode is supported encoded. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", + "description": "Folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", "maxLength": 4096, "type": "string" }, @@ -2374,7 +2374,7 @@ "type": "integer", "minimum": 0, "maximum": 9007199254740991, - "description": "Runs that finished successfully. A run that failed, was cancelled, or is still paused is not counted, and the counter is never reduced when a run ages out of log retention — so this is not the number of runs `GET /api/v2/workflows/{id}/runs` returns, in either direction." + "description": "Runs that finished successfully. Failed, cancelled, and paused runs are not counted, and the counter is never reduced when a run ages out of log retention — so it does not match the size of `GET /api/v2/workflows/{id}/runs`, in either direction." }, "lastRunAt": { "anyOf": [ @@ -2604,7 +2604,7 @@ "type": "integer", "minimum": 0, "maximum": 9007199254740991, - "description": "Runs that finished successfully. A run that failed, was cancelled, or is still paused is not counted, and the counter is never reduced when a run ages out of log retention — so this is not the number of runs `GET /api/v2/workflows/{id}/runs` returns, in either direction." + "description": "Runs that finished successfully. Failed, cancelled, and paused runs are not counted, and the counter is never reduced when a run ages out of log retention — so it does not match the size of `GET /api/v2/workflows/{id}/runs`, in either direction." }, "lastRunAt": { "anyOf": [ @@ -2964,7 +2964,7 @@ "format": "date-time" }, "state": { - "description": "Deployed workflow graph snapshot pinned by this version. Credential-bearing values are redacted: `oauth-input`, `password: true`, and table sub-block values are null; sensitive nested tool parameters and every parameter without authoritative codec metadata are null.", + "description": "Deployed workflow graph snapshot pinned by this version, with credential-bearing values redacted to null: `oauth-input`, `password: true`, table sub-block values, sensitive nested tool parameters, and any parameter without authoritative codec metadata.", "$ref": "#/components/schemas/DeployedWorkflowState" } }, @@ -3337,7 +3337,7 @@ ], "additionalProperties": false, "title": "Deploy result", - "description": "Deployment attempt accepted for processing. Activation is asynchronous; `latestDeploymentAttempt` on this response is the attempt handle. The request is NOT idempotent — every POST mints a new deployment version, so a retry after a timeout creates a second version rather than returning the first. `latestDeploymentAttempt` is returned only here: `GET /workflows/{id}` does not carry it, so poll activation with `isDeployed` and `deployedAt` on the workflow, or with `isActive` on `GET /workflows/{id}/versions`." + "description": "Deployment attempt accepted for processing. Activation is asynchronous, and `latestDeploymentAttempt` is the attempt handle — returned only here. Poll activation with `isDeployed` and `deployedAt` on the workflow, or `isActive` on `GET /workflows/{id}/versions`." }, "DeployWorkflowResponse": { "type": "object", @@ -3958,7 +3958,7 @@ "required": ["runId", "workflowId", "status", "output", "error"], "additionalProperties": false, "title": "Workflow run result", - "description": "Synchronous workflow run output and in-band execution status. Run failures are reported in band, not as HTTP errors — a synchronous run that exceeds its execution timeout returns HTTP 200 with `status: \"failed\"` and `error.code: \"TIMEOUT\"`, so always branch on `status` rather than on the HTTP status alone." + "description": "Synchronous workflow run output and in-band execution status. Run failures are reported in band, not as HTTP errors — a run that exceeds its execution timeout returns HTTP 200 with `status: \"failed\"` and `error.code: \"TIMEOUT\"`, so branch on `status`." }, "ExecuteWorkflowSyncResponse": { "type": "object", @@ -4051,7 +4051,7 @@ "type": "boolean" }, "executionTimeoutSeconds": { - "description": "Requested server-side timeout for an asynchronous run, in seconds. This is an upper bound on the request, not the effective timeout: the run uses the smaller of this value and the account plan's execution timeout, so requesting more than the plan allows silently yields the plan timeout with no warning. Rejected with 400 unless `async` is true.", + "description": "Requested server-side timeout for an asynchronous run, in seconds. An upper bound, not the effective timeout: the run uses the smaller of this value and the plan's execution timeout, so requesting more than the plan allows silently yields the plan timeout. Rejected with `400` unless `async` is true.", "type": "integer", "minimum": 1, "maximum": 604800 @@ -4140,7 +4140,7 @@ "failed", "cancelled" ], - "description": "Current or terminal run status. `redacting` is transient, reported while the output of a finished run is being scrubbed. `paused` means the run is not executing and is waiting to be resumed: either held at a human-in-the-loop pause point, or left paused because a resume attempt did not run to completion. The status alone does not say which. On the single-run response `paused.automaticResumeWaitingReason` distinguishes them: it is recorded whenever a resume attempt fails and cleared once a resume succeeds, so a null value means the run is waiting on human input. When the failure is not retryable or the automatic retries are exhausted, the reason is prefixed `Automatic resume requires manual intervention: `. Run-list items carry no `paused` object, so the two cases are indistinguishable there." + "description": "Current or terminal run status. `redacting` is transient, reported while a finished run's output is being scrubbed. `paused` means the run is waiting to be resumed — either held at a human-in-the-loop pause point, or left paused by a resume attempt that did not complete. Only the single-run response distinguishes the two, through `paused.automaticResumeWaitingReason`." }, "trigger": { "type": "string", @@ -4281,7 +4281,7 @@ "cancelled", "queued" ], - "description": "Current or terminal run status. `redacting` is transient, reported while the output of a finished run is being scrubbed. `paused` means the run is not executing and is waiting to be resumed: either held at a human-in-the-loop pause point, or left paused because a resume attempt did not run to completion. The status alone does not say which. On the single-run response `paused.automaticResumeWaitingReason` distinguishes them: it is recorded whenever a resume attempt fails and cleared once a resume succeeds, so a null value means the run is waiting on human input. When the failure is not retryable or the automatic retries are exhausted, the reason is prefixed `Automatic resume requires manual intervention: `. Run-list items carry no `paused` object, so the two cases are indistinguishable there." + "description": "Current or terminal run status. `redacting` is transient, reported while a finished run's output is being scrubbed. `paused` means the run is waiting to be resumed — either held at a human-in-the-loop pause point, or left paused by a resume attempt that did not complete. Only the single-run response distinguishes the two, through `paused.automaticResumeWaitingReason`." }, "trigger": { "anyOf": [ @@ -4396,7 +4396,7 @@ "type": "null" } ], - "description": "Reason automatic resume is waiting, or null when it is not waiting." + "description": "Why automatic resume is waiting, or null when it is not — on a paused run, null means it is waiting on human input. Recorded whenever a resume attempt fails and cleared once one succeeds. A non-retryable or exhausted failure is prefixed `Automatic resume requires manual intervention: `." }, "pausePointCount": { "type": "number", @@ -4672,7 +4672,7 @@ "description": "Whether a paused execution was cancelled." }, "reason": { - "description": "Machine-readable cancellation outcome. Present on every cancellation, including full successes — it is not a partial-failure marker. `recorded` means cancellation was durably recorded (the normal success value). `redis_unavailable` and `redis_write_failed` mean the distributed cancellation signal could not be written, so an already-running execution may not observe the cancellation. `paused_event_publish_failed` and `paused_database_cancel_failed` name the failing step when cancelling a paused human-in-the-loop run.", + "description": "Machine-readable cancellation outcome, present on every cancellation including full successes. `recorded` is the success value. `redis_unavailable` and `redis_write_failed` mean the distributed cancellation signal was not written, so an already-running execution may not observe the cancellation. `paused_event_publish_failed` and `paused_database_cancel_failed` name the failing step for a paused run.", "type": "string", "enum": [ "recorded", @@ -4693,7 +4693,7 @@ ], "additionalProperties": false, "title": "Cancel workflow run result", - "description": "Outcome of a workflow run cancellation request. Cancelling a run that has already reached a terminal state (completed, failed, or cancelled) succeeds with no effect rather than returning an error — treat this endpoint as best-effort and poll the run to observe the final state." + "description": "Outcome of a workflow run cancellation request. Cancellation is best-effort: a run already in a terminal state succeeds with no effect, so poll the run to observe its final state." }, "CancelWorkflowRunResponse": { "type": "object", @@ -4829,7 +4829,7 @@ }, "NonRootFolderPathInput": { "title": "Non-root folder path input", - "description": "Non-root folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. Unicode is supported encoded. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", + "description": "Non-root folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", "maxLength": 4096, "type": "string" }, diff --git a/apps/sim/lib/api/contracts/v2/custom-tools.ts b/apps/sim/lib/api/contracts/v2/custom-tools.ts index 6311c3f9366..601b301c789 100644 --- a/apps/sim/lib/api/contracts/v2/custom-tools.ts +++ b/apps/sim/lib/api/contracts/v2/custom-tools.ts @@ -106,7 +106,7 @@ export const v2CustomToolDeleteDataSchema = z export type V2CustomToolDeleteData = z.output export const v2CustomToolParamsSchema = z.object({ - id: nonEmptyIdSchema.describe('Custom tool to retrieve, update, or delete.'), + id: nonEmptyIdSchema.describe('Unique custom tool identifier.'), }) export type V2CustomToolParams = z.output diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts index 45688358007..07f7dc1f72c 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -61,13 +61,13 @@ export const v2FileSchema = z .number() .nonnegative() .describe( - 'Size in bytes of the stored file. For a generated document (docx, pptx, pdf, xlsx) the stored file is the generation source rather than the rendered document, so this does not predict how many bytes `GET /files/{fileId}` returns — that endpoint serves the compiled artifact, which is typically much larger.' + 'Size in bytes of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source, not the rendered document, so it does not predict how many bytes `GET /files/{fileId}` returns.' ) .meta({ examples: [1024] }), type: z .string() .describe( - 'MIME type of the stored file. For a generated document (docx, pptx, pdf, xlsx) the stored file is the generation source, so this describes the source and not what `GET /files/{fileId}` serves — that endpoint returns the compiled artifact under the rendered document type.' + 'MIME type of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source type, not the rendered document type `GET /files/{fileId}` serves.' ) .meta({ examples: ['text/csv'] }), key: z @@ -257,7 +257,7 @@ export const v2CreateFileBodySchema = z .max(70_000_000, 'content is too large') .default('') .describe( - 'Initial file content. Omit or send an empty string for a zero-byte file. The 70,000,000-character bound is a JSON-envelope guard, not the file-size limit: the decoded bytes must be at most 50 MiB, so a longer base64 payload is admitted here and then rejected with 413. Use an upload session for anything larger.' + 'Initial file content. Omit or send an empty string for a zero-byte file. The 70,000,000-character bound guards the JSON envelope; the decoded bytes must be at most 50 MiB, and a longer base64 payload is rejected with `413`. Use an upload session for anything larger.' ), encoding: z .enum(['utf-8', 'base64']) @@ -310,7 +310,7 @@ export const v2ListFilesQuerySchema = z scope: v2FileScopeSchema .default('active') .describe( - 'Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted and that `POST /files/{fileId}/restore` can bring back. `folderPath` resolves against active folders only, so combining it with `scope=archived` returns an empty page when the containing folder was archived too.' + 'Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.' ), search: v2SearchSchema.describe('Case-insensitive substring match against the file name.'), ...v2SortFields(v2FileSortFields, { sortBy: 'uploadedAt', sortOrder: 'asc' }), @@ -518,7 +518,7 @@ export const v2UpdateFileContentBodySchema = z .string() .max(70_000_000, 'content is too large') .describe( - 'Complete replacement content for the file. The 70,000,000-character bound is a JSON-envelope guard, not the file-size limit: the decoded bytes must be at most 50 MiB, so a longer base64 payload is admitted here and then rejected with 413.' + 'Complete replacement content for the file. The 70,000,000-character bound guards the JSON envelope; the decoded bytes must be at most 50 MiB, and a longer base64 payload is rejected with `413`.' ), encoding: z .enum(['utf-8', 'base64']) diff --git a/apps/sim/lib/api/contracts/v2/knowledge.ts b/apps/sim/lib/api/contracts/v2/knowledge.ts index 15c76e15b0c..e6d5ac50aa9 100644 --- a/apps/sim/lib/api/contracts/v2/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/knowledge.ts @@ -17,6 +17,7 @@ import { v1SearchTagFilterSchema, } from '@/lib/api/contracts/v1/knowledge' import { + nameSortCollation, V2_FOLDER_FILTER_MISS, v2CreateFolderBodySchema, v2CursorListResponse, @@ -407,7 +408,7 @@ export const v2KnowledgeSearchDataSchema = z */ rerankerStatus: rerankerStatusSchema .describe( - 'What the reranker did on this search. `applied` means it ordered the results, which carry `rerankerScore`. `unavailable` means reranking was requested and attempted but could not complete, so results are in vector order and carry no `rerankerScore` — the search still succeeded, and the request is worth retrying. `skipped` means there was nothing to rank: a tag-only search, or no matching chunks. `not_requested` means `rerankerEnabled` was absent or false.' + 'What the reranker did on this search. `applied` means it ordered the results, which carry `rerankerScore`. `unavailable` means it was attempted but could not complete, so results are in vector order with no `rerankerScore` — the search still succeeded, and is worth retrying. `skipped` means there was nothing to rank. `not_requested` means `rerankerEnabled` was absent or false.' ) .meta({ examples: ['applied'] }), }) @@ -827,7 +828,7 @@ export const v2KnowledgeSearchBodySchema = v1KnowledgeSearchBodySchema .array(v2KnowledgeSearchTagFilterSchema) .optional() .describe( - 'Structured tag filters. Supported across multiple knowledge bases, but each filtered tag must resolve to the same slot and field type in every knowledge base selected; a tag missing from one of them, or defined inconsistently across them, is rejected and those knowledge bases must be searched separately. A tag name defined in none of the selected knowledge bases is rejected, never ignored; list the available names with GET /api/v2/knowledge/{id}/tags.' + 'Structured tag filters. Each filtered tag must resolve to the same slot and field type in every knowledge base selected; one missing from any of them, or defined inconsistently across them, is rejected rather than ignored, and those knowledge bases must be searched separately. List the available names with `GET /api/v2/knowledge/{id}/tags`.' ), searchMode: v1KnowledgeSearchBodySchema.shape.searchMode.describe( 'Retrieval strategy: vector is semantic-only, while hybrid also runs full-text search.' @@ -836,7 +837,7 @@ export const v2KnowledgeSearchBodySchema = v1KnowledgeSearchBodySchema .boolean() .optional() .describe( - 'Re-order retrieved chunks with a reranking model before truncating to `topK`. Ignored for a tag-only search, which has no query to rank against. Reranking is billed as an additional search unit. Whether it actually ran is reported by `rerankerStatus` on the response: reranking is best-effort, and a provider failure falls back to vector ordering rather than failing the search.' + 'Re-order retrieved chunks with a reranking model before truncating to `topK`. Ignored for a tag-only search, and billed as an additional search unit. Reranking is best-effort — a provider failure falls back to vector ordering, so check `rerankerStatus` on the response.' ), /** * Defaulted, matching the internal search contract this one otherwise @@ -955,7 +956,7 @@ export const v2ListKnowledgeDocumentsQuerySchema = v1ListKnowledgeDocumentsQuery 'Filter by whether documents are enabled for search.' ), sortBy: v1ListKnowledgeDocumentsQuerySchema.shape.sortBy.describe( - 'Document field used to sort results.' + `Field used to sort the result. ${nameSortCollation('filename')}` ), sortOrder: v1ListKnowledgeDocumentsQuerySchema.shape.sortOrder.describe('Sort direction.'), tagFilters: z diff --git a/apps/sim/lib/api/contracts/v2/logs.ts b/apps/sim/lib/api/contracts/v2/logs.ts index 1e0540648ea..858392464c2 100644 --- a/apps/sim/lib/api/contracts/v2/logs.ts +++ b/apps/sim/lib/api/contracts/v2/logs.ts @@ -52,7 +52,7 @@ const v2LogCostSchema = z export const v2LogStatusSchema = z .enum(PERSISTED_WORKFLOW_EXECUTION_STATUSES) .describe( - 'Current execution status, reported as persisted. `redacting` is transient while run output is scrubbed. `paused` is reported only when a resume attempt did not run to completion and the run is waiting to be resumed again. **This differs from the run resources for the same run:** `GET /api/v2/workflows/{id}/runs` and `GET /api/v2/workflows/{id}/runs/{runId}` additionally report `paused` for a run held at a human-in-the-loop pause point, which this field reports as `pending`. Use the run resources when the pause state matters.' + 'Current execution status, reported as persisted. `redacting` is transient while run output is scrubbed. `paused` is reported only when a resume attempt did not complete; a run held at a human-in-the-loop pause point reads `pending` here, and `paused` on the workflow run resources. Use those when the pause state matters.' ) /** Execution `files` is a per-run jsonb array of attachment metadata. */ @@ -82,7 +82,7 @@ const v2LogWorkflowStateSchema = z ) .nullable() .describe( - 'Workflow graph snapshot captured for the run, with credential values redacted: `oauth-input`, `password: true`, and table sub-block values are null; sensitive nested tool parameters and every parameter without authoritative codec metadata are null; and `{{VAR}}` references in non-opaque fields are preserved. Null when no snapshot is retained.' + 'Workflow graph snapshot captured for the run, or null when none is retained. Credential-bearing values are redacted to null: `oauth-input`, `password: true`, table sub-block values, sensitive nested tool parameters, and any parameter without authoritative codec metadata. `{{VAR}}` references in non-opaque fields are preserved.' ) const v2LogWorkflowSummarySchema = z.object({ @@ -196,9 +196,7 @@ export const v2LogDetailSchema = z export type V2LogDetail = z.output export const v2LogParamsSchema = z.object({ - runId: runIdSchema.describe( - 'The unique run identifier shared by lifecycle and diagnostic resources.' - ), + runId: runIdSchema.describe('Unique workflow run identifier.'), }) export const v2ListLogsQuerySchema = v1ListLogsQuerySchema @@ -229,7 +227,7 @@ export const v2ListLogsQuerySchema = v1ListLogsQuerySchema .default('basic'), includeTraceSpans: booleanQueryFlagSchema .describe( - 'Whether to include block-level trace spans. Spans are stored apart from the log row and pruned on their own retention schedule, so a run whose spans have aged out returns `traceSpans: []` rather than an error — an empty array does not mean the run recorded none.' + 'Whether to include block-level trace spans. Spans are pruned on their own retention schedule, so a run whose spans have aged out returns `traceSpans: []` rather than an error.' ) .optional() .default(false), diff --git a/apps/sim/lib/api/contracts/v2/mcp-servers.ts b/apps/sim/lib/api/contracts/v2/mcp-servers.ts index 60111ac2442..0fa7d6c8481 100644 --- a/apps/sim/lib/api/contracts/v2/mcp-servers.ts +++ b/apps/sim/lib/api/contracts/v2/mcp-servers.ts @@ -178,7 +178,7 @@ export const v2McpServerDeleteDataSchema = z export type V2McpServerDeleteData = z.output export const v2McpServerParamsSchema = z.object({ - id: nonEmptyIdSchema.describe('MCP server the operation acts on.'), + id: nonEmptyIdSchema.describe('Unique MCP server identifier.'), }) export type V2McpServerParams = z.output diff --git a/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts b/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts index aba11d0c5cb..c6d30e09d7b 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts @@ -123,7 +123,7 @@ const routes = [ filesOperation({ operationId: 'listFiles', summary: 'List Files', - description: `List workspace files with search, sorting, folder filtering, and opaque cursor pagination. Defaults to active files; pass \`scope=archived\` to page over soft-deleted files, whose \`deletedAt\` is non-null and which \`POST /files/{fileId}/restore\` can bring back. ${FOLDER_TREE_TOO_LARGE}`, + description: `List workspace files with search, sorting, folder filtering, and opaque cursor pagination. Defaults to active files; pass \`scope=archived\` to page over soft-deleted ones. ${FOLDER_TREE_TOO_LARGE}`, errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], success: { description: 'A page of workspace files.' }, }), @@ -361,7 +361,7 @@ const routes = [ operationId: 'deleteFile', summary: 'Delete File', description: - 'Archive a workspace file. This is a soft delete: the row is retained with a deletion timestamp, the file stops appearing in the default listing and is no longer readable through the API, and its stored bytes are never removed. Archiving an already-archived file is a `404`, not a no-op. List archived files with `GET /files?scope=archived` and reverse the delete with `POST /files/{fileId}/restore`.', + 'Archive a workspace file. This is a soft delete: the file stops appearing in the default listing and is no longer readable through the API, but its stored bytes are never removed. Archiving an already-archived file is a `404`, not a no-op. List archived files with `GET /files?scope=archived`, and reverse the delete with `POST /files/{fileId}/restore`.', errors: RESOURCE_ERRORS, success: { description: 'Deletion confirmation.' }, }), @@ -430,7 +430,7 @@ const routes = [ operationId: 'restoreFile', summary: 'Restore File', description: - 'Reverse a soft delete and return the file to the workspace. Restore is not a pure undo — the file comes back at the workspace root, and gains a `_restored` suffix when another file there already holds its name — so read `folderPath` and `name` off the response. Restoring an already-active file is a no-op that returns it, so a retry is safe. An archived workspace is a 400, and a name the restore could not free is a 409.', + 'Reverse a soft delete and return the file to the workspace. Not a pure undo: the file comes back at the workspace root, and gains a `_restored` suffix when another file there already holds its name, so read `folderPath` and `name` off the response. Restoring an already-active file returns it unchanged, so a retry is safe. An archived workspace is a `400`, and a name the restore could not free is a `409`.', errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], success: { description: 'The file as it exists after the restore.' }, }), @@ -618,7 +618,7 @@ const routes = [ filesOperation({ operationId: 'upsertFileShare', summary: 'Enable or Disable File Share', - description: `Create or partially update a server-tokenized public share. Only \`isActive\` is required; each other field states what enabling a mode does to it. A file that has never been shared has nothing stored to fall back on, so enabling any mode other than \`public\` must carry its credential in the same request. ${WORKSPACE_API_KEY_DENIED}`, + description: `Create or partially update a server-tokenized public share. Only \`isActive\` is required; each other field states what enabling a mode does to it. Enabling any mode other than \`public\` on a file that has never been shared must carry its credential in the same request. ${WORKSPACE_API_KEY_DENIED}`, errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], success: { description: 'The updated file share.' }, }), @@ -844,7 +844,7 @@ export const filesAuditOpenApiDocument = defineOpenApiDocument({ info: { title: 'Sim API v2 — Files & Audit Logs', description: - 'Version 2 of the Sim REST API for workspace files and organization audit logs. Lists use opaque cursors, and rate-limit state is returned in response headers. Download File streams raw bytes as `application/octet-stream`; every other response uses the canonical v2 data, cursor-list, or error envelope.', + 'Version 2 of the Sim REST API for workspace files, resumable uploads, public shares, and organization audit logs.', version: '2.0.0', contact: { name: 'Sim Support', diff --git a/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts b/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts index 93b75850306..e40d0570a20 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts @@ -70,7 +70,7 @@ const routes = [ knowledgeOperation({ operationId: 'listKnowledgeBases', summary: 'List Knowledge Bases', - description: `List knowledge bases in a workspace with folder filtering, search, and sorting. Paginate with \`limit\` and \`cursor\`, stopping when \`nextCursor\` is null. An unknown \`folderPath\` returns an empty page. ${FOLDER_TREE_TOO_LARGE}`, + description: `List knowledge bases in a workspace with folder filtering, search, sorting, and opaque cursor pagination. ${FOLDER_TREE_TOO_LARGE}`, errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], success: { description: 'A page of knowledge bases.' }, }), @@ -94,7 +94,7 @@ const routes = [ knowledgeOperation({ operationId: 'createKnowledgeBase', summary: 'Create Knowledge Base', - description: `Create a knowledge base in a workspace with optional folder placement and chunking configuration. An unknown \`folderPath\` is a 404. ${FOLDER_TREE_TOO_LARGE}`, + description: `Create a knowledge base in a workspace with optional folder placement and chunking configuration. An unknown \`folderPath\` is a \`404\`. ${FOLDER_TREE_TOO_LARGE}`, errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], success: { description: 'The created knowledge base.' }, }), @@ -213,7 +213,7 @@ const routes = [ operationId: 'searchKnowledge', summary: 'Search Knowledge', description: - 'Search one or more knowledge bases with semantic vector retrieval, optional hybrid full-text retrieval, and structured tag filters. Every result names the `knowledgeBaseId` it came from. The request body is capped at 2 MiB; a larger body is a 413.', + 'Search one or more knowledge bases with semantic vector retrieval, optional hybrid full-text retrieval, and structured tag filters. Every result names the `knowledgeBaseId` it came from. A request body over 2 MiB is a `413`.', errors: [...WORKSPACE_ERRORS, 'UsageLimitExceeded', 'NotFound', 'PayloadTooLarge'], success: { description: 'Matching document chunks ordered by relevance.' }, }), @@ -246,7 +246,7 @@ const routes = [ knowledgeOperation({ operationId: 'listKnowledgeTags', summary: 'List Tags', - description: `List the knowledge base's tag vocabulary: each tag's display name, the slot it is stored in, and its field type. Filters and document reads use display names; document writes address slots, each in its declared type. The vocabulary is bounded by the fixed slot table. ${FULL_SET_LIST}`, + description: `List the knowledge base's tag vocabulary: each tag's display name, the slot it is stored in, and its field type. Filters and document reads use display names; document writes address slots. ${FULL_SET_LIST}`, errors: RESOURCE_ERRORS, success: { description: 'The knowledge base tag vocabulary.' }, }), @@ -277,7 +277,7 @@ const routes = [ operationId: 'listKnowledgeDocuments', summary: 'List Documents', description: - 'List documents in a knowledge base with filename search, state filtering, tag filtering, sorting, and opaque cursor pagination. Each document carries its tag values keyed by tag display name; resolve those names to write slots with `GET /api/v2/knowledge/{id}/tags`.', + 'List documents in a knowledge base with filename search, state filtering, tag filtering, sorting, and opaque cursor pagination. Tag values are keyed by display name; resolve those to write slots with `GET /api/v2/knowledge/{id}/tags`.', errors: RESOURCE_ERRORS, success: { description: 'A page of knowledge documents.' }, }), @@ -307,7 +307,7 @@ const routes = [ knowledgeOperation({ operationId: 'bulkUpdateKnowledgeDocuments', summary: 'Bulk Enable or Disable Documents', - description: `Enable or disable many documents in one request, either by identifier or, with \`selectAll\`, every document in the knowledge base. Bulk delete is deliberately not offered: the bulk path records no audit entries, so deletions go through \`DELETE /api/v2/knowledge/{id}/documents/{documentId}\`, which audits each one. ${WORKSPACE_API_KEY_DENIED}`, + description: `Enable or disable many documents in one request, either by identifier or, with \`selectAll\`, every document in the knowledge base. Bulk delete is not offered; delete documents one at a time with \`DELETE /api/v2/knowledge/{id}/documents/{documentId}\`. ${WORKSPACE_API_KEY_DENIED}`, errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], success: { description: 'The number and identifiers of the documents that changed.' }, }), @@ -583,7 +583,7 @@ const routes = [ knowledgeOperation({ operationId: 'updateKnowledgeDocument', summary: 'Update Document', - description: `Rename a document, enable or disable it for search, set any of its 17 tag slots, or requeue it for processing. Absent fields are unchanged, and derived indexing state — \`chunkCount\`, \`tokenCount\`, \`characterCount\`, \`processingStatus\`, \`processingError\` — is written by the processing pipeline and cannot be asserted here. Resolve a tag display name to its slot with \`GET /api/v2/knowledge/{id}/tags\`. The returned document omits the connector provenance the detail read carries. ${WORKSPACE_API_KEY_DENIED}`, + description: `Rename a document, enable or disable it for search, set any of its 17 tag slots, or requeue it for processing. Absent fields are unchanged, and derived indexing state is read-only. Resolve a tag display name to its slot with \`GET /api/v2/knowledge/{id}/tags\`. The returned document omits the connector provenance the detail read carries. ${WORKSPACE_API_KEY_DENIED}`, errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], success: { description: 'The updated document, or the requeue acknowledgement.' }, }), @@ -616,7 +616,7 @@ const routes = [ operationId: 'deleteKnowledgeDocument', summary: 'Delete Document', description: - 'Remove one document from a knowledge base. What that means depends on the document. A directly uploaded document is deleted outright along with its indexed chunks. A connector-backed document is instead excluded: its row survives, marked excluded and disabled so it stops being searchable and a later connector sync does not re-add it, and its embeddings are not deleted. Either way the document no longer appears in listings or search results.', + 'Remove one document from a knowledge base. An uploaded document is deleted outright with its indexed chunks. A connector-backed document is instead excluded — its row and embeddings survive, but it stops being searchable and a later sync does not re-add it. Either way it no longer appears in listings or search results.', errors: RESOURCE_ERRORS, success: { description: 'Knowledge document deletion acknowledgement.' }, }), diff --git a/apps/sim/lib/api/contracts/v2/openapi/logs.ts b/apps/sim/lib/api/contracts/v2/openapi/logs.ts index c9813605a20..ee9eeb4053b 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/logs.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/logs.ts @@ -95,7 +95,7 @@ const routes = [ logsOperation({ operationId: 'listLogs', summary: 'List Logs', - description: `List workflow execution logs for a workspace with filters, selectable detail, and opaque cursor pagination. This list predates the shared sort convention: it has no \`sortBy\` (the sort column is fixed to execution start time) and spells the direction \`order\` rather than \`sortOrder\`. ${RUN_RETENTION}`, + description: `List workflow execution logs for a workspace with filters, selectable detail, and opaque cursor pagination. ${RUN_RETENTION}`, errors: RESOURCE_ERRORS, success: { description: 'A page of execution logs matching the filters.' }, }), @@ -121,7 +121,7 @@ const routes = [ operationId: 'getLog', summary: 'Get Log', description: - 'Retrieve the diagnostic representation of a run, including its workflow snapshot, trace spans, final output, and cost. Trace spans are stored apart from the log row and pruned on their own retention schedule, so an empty `traceSpans` array does not mean the run recorded none.', + 'Retrieve the diagnostic representation of a run, including its workflow snapshot, trace spans, final output, and cost. Trace spans are pruned on their own retention schedule, so an empty `traceSpans` array does not mean the run recorded none.', errors: RESOURCE_ERRORS, success: { description: 'The requested diagnostic log representation.' }, }), diff --git a/apps/sim/lib/api/contracts/v2/openapi/resources.ts b/apps/sim/lib/api/contracts/v2/openapi/resources.ts index 3ff095f788f..04fdbf1df06 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/resources.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/resources.ts @@ -280,7 +280,7 @@ const routes = [ operationId: 'listMcpServers', summary: 'List MCP Servers', description: - 'List MCP servers registered in a workspace. Request-header values and OAuth client secrets are never returned. Paginate with `limit` and `cursor`, stopping when `nextCursor` is null. The discovery fields stay at their registration defaults until `GET /api/v2/mcp-servers/{id}/tools` runs a discovery.', + 'List MCP servers registered in a workspace. Request-header values and OAuth client secrets are never returned. The discovery fields stay at their registration defaults until `GET /api/v2/mcp-servers/{id}/tools` runs a discovery.', errors: RESOURCE_ERRORS, success: { description: 'MCP servers registered in the workspace.' }, }), @@ -306,7 +306,7 @@ const routes = [ operationId: 'createMcpServer', summary: 'Create MCP Server', description: - 'Register an MCP server in a workspace. The endpoint URL determines server identity, so a URL already registered in the workspace is a `409` — reconfigure that server with `PATCH /api/v2/mcp-servers/{id}` instead. Registration stores the configuration and never connects to the endpoint, so a 201 is not evidence the server is reachable: it comes back `disconnected` and the workspace tool registry treats it as unavailable until `GET /api/v2/mcp-servers/{id}/tools` succeeds.', + 'Register an MCP server in a workspace. The endpoint URL is the server identity, so a URL already registered here is a `409` — reconfigure that server with `PATCH /api/v2/mcp-servers/{id}` instead. Registration never connects to the endpoint: the server comes back `disconnected` and stays unavailable until `GET /api/v2/mcp-servers/{id}/tools` succeeds.', errors: RESOURCE_CONFLICT_BODY_ERRORS, success: { description: 'The MCP server was registered.' }, }), @@ -439,7 +439,7 @@ const routes = [ resourceOperation('MCP Servers', { operationId: 'listMcpServerTools', summary: 'List MCP Server Tools', - description: `Connect to a registered MCP server and return the tools it exposes, completing onboarding without opening the Sim UI. Unlike most reads this one has side effects: it opens a live connection to the third-party server and writes \`connectionStatus\`, \`toolCount\`, \`lastError\`, and \`lastToolsRefresh\` on the server resource. ${HEAD_MIRRORS_GET} Discovery itself bounds the set at 1,000 tools and 5 MB of tool payload per server. ${FULL_SET_LIST} An unreachable, slow, or cooling-down server is a \`503\`; a stored OAuth grant that no longer works is a \`409\` with \`error.details.code\` \`MCP_SERVER_REAUTHORIZATION_REQUIRED\`, which only a human reauthorizing in Sim can clear. ${WORKSPACE_API_KEY_DENIED}`, + description: `Connect to a registered MCP server and return the tools it exposes. This read has side effects: it opens a live connection to the third-party server and writes \`connectionStatus\`, \`toolCount\`, \`lastError\`, and \`lastToolsRefresh\`. ${HEAD_MIRRORS_GET} Discovery is bounded at 1,000 tools and 5 MB of tool payload per server. ${FULL_SET_LIST} An unreachable, slow, or cooling-down server is a \`503\`; a stored OAuth grant that no longer works is a \`409\` with \`error.details.code\` \`MCP_SERVER_REAUTHORIZATION_REQUIRED\`, which only a human reauthorizing in Sim can clear. ${WORKSPACE_API_KEY_DENIED}`, errors: RESOURCE_CONFLICT_ERRORS, success: { description: 'Tools exposed by the MCP server.' }, }), @@ -471,7 +471,7 @@ const routes = [ operationId: 'listSkills', summary: 'List Skills', description: - 'List workspace and built-in skills. Built-ins are marked read-only. The list omits skill bodies; fetch one skill to read its content. Paginate with `limit` and `cursor`, stopping when `nextCursor` is null.', + 'List workspace and built-in skills with opaque cursor pagination. Built-ins are marked read-only. The list omits skill bodies; fetch one skill to read its content.', errors: RESOURCE_ERRORS, success: { description: 'Skills available in the workspace.' }, }), @@ -627,7 +627,7 @@ const routes = [ operationId: 'listCustomTools', summary: 'List Custom Tools', description: - 'List code-backed custom tools defined in a workspace. Legacy personal tools are excluded. Paginate with `limit` and `cursor`, stopping when `nextCursor` is null.', + 'List code-backed custom tools defined in a workspace, with opaque cursor pagination. Legacy personal tools are excluded.', errors: RESOURCE_ERRORS, success: { description: 'Custom tools defined in the workspace.' }, }), @@ -785,7 +785,7 @@ const routes = [ operationId: 'listCredentials', summary: 'List Credentials', description: - 'List OAuth and service-account connections visible to the caller. Secret material is never returned. Credential mutations and single-resource reads are intentionally not exposed. Paginate with `limit` and `cursor`, stopping when `nextCursor` is null.', + 'List OAuth and service-account connections visible to the caller. Secret material is never returned. Credential mutations and single-resource reads are not exposed.', errors: RESOURCE_ERRORS, success: { description: 'Credentials visible to the caller.' }, }), @@ -810,7 +810,7 @@ const routes = [ resourceOperation('Secrets', { operationId: 'listSecrets', summary: 'List Secrets', - description: `List workspace and caller-owned personal secret metadata. Only names, scope, role, and timestamps are returned; secret values are never read or returned. Paginate with \`limit\` and \`cursor\`, stopping when \`nextCursor\` is null. ${WORKSPACE_API_KEY_DENIED}`, + description: `List workspace and caller-owned personal secret metadata with opaque cursor pagination. Only names, scope, role, and timestamps are returned; secret values are never returned. ${WORKSPACE_API_KEY_DENIED}`, errors: RESOURCE_ERRORS, success: { description: 'Secret metadata visible to the caller.' }, }), diff --git a/apps/sim/lib/api/contracts/v2/openapi/shared.ts b/apps/sim/lib/api/contracts/v2/openapi/shared.ts index 2103e05847d..9e911eaaf1c 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/shared.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/shared.ts @@ -61,13 +61,13 @@ export const ERROR_RESPONSES = { RunIdConflict: { status: 409, description: - 'The run cannot be started. Two causes share this status, distinguished by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already associated with a different request, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has already reached the maximum workflow-to-workflow call depth.', + 'The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.', headers: ['X-Run-Id'], }, PayloadTooLarge: { status: 413, description: - 'The request, or a resource collection it must materialize, exceeds the allowed size. Besides an oversized request body, this covers a generated artifact that renders past the download ceiling and a workspace folder tree too large to load in full.', + 'The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.', }, UnsupportedMediaType: { status: 415, @@ -100,13 +100,13 @@ export const ERROR_RESPONSES = { ClientClosedRequest: { status: 499, description: - 'The client closed the connection before the response was produced. The response is written to a connection that is already gone, so the caller that caused it never reads it; it is documented only on operations where an abort can leave work running. There, `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.', + 'The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.', }, InternalError: { status: 500, description: 'An unexpected server error occurred.' }, ServiceUnavailable: { status: 503, description: - 'A required service is temporarily unavailable. The condition is transient, so the response normally carries `Retry-After` with the number of seconds to wait; treat that value as a floor and add jitter before retrying. One case deliberately omits the header: when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, the run may already have started, so retrying could start and bill a second run. Reconcile against the returned run id instead of retrying.', + 'A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.', headers: ['Retry-After'], }, } as const satisfies Readonly> @@ -202,8 +202,7 @@ export const V2_API_KEY_SECURITY_SCHEMES = { * rendering one back do not need this sentence: the shared `413` response * description already covers them. */ -export const FOLDER_TREE_TOO_LARGE = - 'A workspace whose folder tree exceeds 10,000 folders is a 413, because the response needs the whole tree to render folder paths.' +export const FOLDER_TREE_TOO_LARGE = 'A workspace folder tree over 10,000 folders is a `413`.' /** * Appended to a list whose result set is bounded by construction, so it answers @@ -215,8 +214,7 @@ export const FOLDER_TREE_TOO_LARGE = * promise. The authoritative membership is pinned in * `contracts/v2/__tests__/list-pagination.test.ts` as `FULL_SET_LISTS`. */ -export const FULL_SET_LIST = - 'The bounded set is returned in one page with `nextCursor` always null; there is no second page to fetch.' +export const FULL_SET_LIST = 'The bounded set is returned in one page; `nextCursor` is always null.' /** * Appended to a `GET` whose route declares `headSafe: false` because the read @@ -239,7 +237,7 @@ export const HEAD_MIRRORS_GET = * so it is not something a workspace owner can grant around. */ export const WORKSPACE_API_KEY_DENIED = - 'A workspace API key cannot call this operation and is rejected with `403`; use a personal API key.' + 'A workspace API key is rejected with `403`; use a personal API key.' /** * {@link WORKSPACE_API_KEY_DENIED} for an operation behind the resource-concealment @@ -247,7 +245,7 @@ export const WORKSPACE_API_KEY_DENIED = * the caller learns nothing about the resource. */ export const WORKSPACE_API_KEY_DENIED_AS_NOT_FOUND = - 'A workspace API key cannot call this operation. Because unauthorized resources are concealed, the rejection is reported as `404` rather than `403`; use a personal API key.' + 'A workspace API key is rejected as `404` rather than `403`, because unauthorized resources are concealed; use a personal API key.' /** * Appended to the two reads over `workflow_execution_logs`, which is the only @@ -264,14 +262,12 @@ export const WORKSPACE_API_KEY_DENIED_AS_NOT_FOUND = * enabled. * * Stated because deletion is otherwise invisible: an aged-out run is not a - * tombstone or a 404, it is simply absent, and `runCount` on the workflow is - * never decremented to match — so a free-plan workflow can report dozens of - * runs beside an empty list and nothing in either response explains the gap. - * Kept as one constant so the two sibling reads cannot drift into two - * paraphrases of one window. + * tombstone or a 404, it is simply absent. The matching `runCount` caveat lives + * on that field rather than here. Kept as one constant so the two sibling reads + * cannot drift into two paraphrases of one window. */ export const RUN_RETENTION = - "Runs are hard-deleted once they pass the payer's log retention window, so an older run is absent from this list rather than reported as removed. The window is 30 days from run start on the free plan; Pro and Team have none configured and keep runs indefinitely; Enterprise sets its own per organization, with an optional per-workspace override, and is also unbounded until configured. A workflow's `runCount` is never reduced by this deletion, so a workflow can report runs while this list is empty." + "Runs are hard-deleted once they pass the payer's log retention window, so an older run is simply absent rather than reported as removed. The window is 30 days from run start on the free plan, unbounded on Pro and Team, and set per organization on Enterprise with an optional per-workspace override." export const V2_COMMON_HEADERS = { 'X-RateLimit-Limit': { @@ -300,7 +296,7 @@ export const V2_COMMON_HEADERS = { id: 'RetryAfterHeader', title: 'Retry after', description: - 'Seconds to wait before retrying. Sent on `429` (derived from the caller rate-limit window) and on `503` (a fixed transient-failure floor). Add jitter rather than retrying at exactly this offset.', + 'Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset.', }), }, 'X-Run-Id': { diff --git a/apps/sim/lib/api/contracts/v2/openapi/tables.ts b/apps/sim/lib/api/contracts/v2/openapi/tables.ts index 5d98322d4d6..c67ee2676c3 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/tables.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/tables.ts @@ -249,7 +249,7 @@ const declaredRoutes = [ tableOperation({ operationId: 'updateTable', summary: 'Update Table', - description: `Rename a table, edit its description, or move it to a canonical folder. At least one mutable field is required; lock flags remain read-only.\n\nThis operation is NOT atomic. Name, description, and folder are written independently in that order, so a 4xx does NOT mean nothing changed: the error body carries \`details.applied\` naming the fields that landed. Retry with only the fields missing from it.\n\n${FOLDER_TREE_TOO_LARGE}`, + description: `Rename a table, edit its description, or move it to a canonical folder. At least one mutable field is required; lock flags remain read-only.\n\nNOT atomic: name, description, and folder are written independently, so a 4xx does not mean nothing changed. The error body carries \`details.applied\` naming the fields that landed — retry with only the ones missing from it.\n\n${FOLDER_TREE_TOO_LARGE}`, errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], success: { description: 'The updated table.' }, }), @@ -605,7 +605,7 @@ const declaredRoutes = [ operationId: 'upsertTableRow', summary: 'Upsert Row', description: - 'Insert a row or update the existing row that conflicts on a selected unique column.\n\nWARNING — the update branch REPLACES the row, it does not merge. `data` is treated as the complete new row value, so every column you omit is cleared on the matched row. Upserting 2 of 10 columns blanks the other 8. This differs from `PATCH /api/v2/tables/{tableId}/rows/{rowId}`, which merges the patch into the existing row data. Send the full row here, or use PATCH when you only mean to change a subset.', + 'Insert a row or update the existing row that conflicts on a selected unique column.\n\nWARNING — the update branch REPLACES the row, it does not merge. `data` is the complete new row value, so every column you omit is cleared on the matched row. Send the full row here, or use `PATCH /api/v2/tables/{tableId}/rows/{rowId}` to change a subset.', errors: TABLE_MUTATION_ERRORS, success: { description: 'The upserted row and operation performed.' }, }), @@ -684,7 +684,7 @@ const declaredRoutes = [ operationId: 'countTableRows', summary: 'Count Rows', description: - 'Count the rows matching a typed predicate across the entire table. The paged reads carry no total, and rowCount on the table resource counts every row rather than the predicate matches. Omit the predicate to count the whole table. A predicate larger than the request-body ceiling is a `413`.', + 'Count the rows matching a typed predicate across the entire table. The paged reads carry no total, and `rowCount` on the table resource counts every row rather than the matches. Omit the predicate to count the whole table. A predicate larger than the request-body ceiling is a `413`.', errors: TABLE_QUERY_ERRORS, success: { description: 'The number of matching table rows.' }, }), @@ -960,7 +960,7 @@ const declaredRoutes = [ operationId: 'updateTableWorkflowGroup', summary: 'Update Workflow Group', description: - 'Restructure a workflow group, its producer, outputs, or execution behavior.\n\nOutput leaf types are resolved against the group\u2019s workflow outside the write lock. If the group is repointed at a different workflow concurrently, that snapshot is invalidated and the request returns `409` — retry the update.', + 'Restructure a workflow group, its producer, outputs, or execution behavior. Repointing the group at a different workflow concurrently invalidates the resolved output types and returns `409` — retry the update.', errors: RESOURCE_MUTATION_ERRORS, success: { description: 'The updated workflow group and resulting columns.' }, }), @@ -1163,7 +1163,7 @@ const declaredRoutes = [ operationId: 'getTableImport', summary: 'Get Table Import', description: - 'Read progress and terminal state for a durable table import.\n\nAn upload-backed import has no durable record until its upload completes, so send the signed upload control token to read it during the `uploading` phase; without the token that phase returns `404`.', + 'Read progress and terminal state for a durable table import.\n\nAn upload-backed import has no durable record until its upload completes, so send the signed upload control token to read it during the `uploading` phase; without the token that phase is a `404`.', errors: RESOURCE_ERRORS, success: { description: 'The requested table import.' }, }), @@ -1201,7 +1201,7 @@ const declaredRoutes = [ operationId: 'cancelTableImport', summary: 'Cancel Table Import', description: - 'Cancel an upload or processing import without rolling back committed row batches.\n\nCanceling an import that is not in a cancelable state returns `409` naming the current status, and that includes an expired import — `expired` is a terminal import status, not a `410`. An import id that never existed, or one whose retention window already purged the record, returns `404`.', + 'Cancel an upload or processing import without rolling back committed row batches.\n\nAn import that is not in a cancelable state, including an `expired` one, is a `409` naming the current status. An unknown or already-purged import id is a `404`.', errors: RESOURCE_CONFLICT_ERRORS, success: { description: 'The canceled table import.' }, }), @@ -1238,7 +1238,7 @@ const declaredRoutes = [ operationId: 'createTableImportPartUrls', summary: 'Create Table Import Part URLs', description: - 'Issue short-lived signed PUT URLs for a bounded set of multipart part numbers.\n\nThe import must still be in the `uploading` state. An import that has moved on — including one that has `expired` — returns `409` naming the current status; a purged or unknown import id returns `404`.', + 'Issue short-lived signed PUT URLs for a bounded set of multipart part numbers.\n\nThe import must still be `uploading`; one that has moved on, including an `expired` one, is a `409` naming the current status. An unknown or already-purged import id is a `404`.', errors: RESOURCE_CONFLICT_ERRORS, success: { description: 'The signed multipart upload URLs.' }, }), @@ -1282,7 +1282,7 @@ const declaredRoutes = [ operationId: 'completeTableImportUpload', summary: 'Complete Table Import Upload', description: - 'Verify or assemble the uploaded CSV and begin processing with the same import id.\n\nCompleting an import that is no longer awaiting an upload — including one that has `expired` — returns `409` naming the current status; a purged or unknown import id returns `404`.', + 'Verify or assemble the uploaded CSV and begin processing with the same import id.\n\nAn import no longer awaiting an upload, including an `expired` one, is a `409` naming the current status. An unknown or already-purged import id is a `404`.', errors: [...RESOURCE_CONFLICT_ERRORS, 'Locked'], success: { description: 'The table import after upload completion.' }, }), @@ -1413,7 +1413,7 @@ const declaredRoutes = [ operationId: 'downloadTableExport', summary: 'Download Table Export', description: - 'Return a short-lived signed download URL for a completed table export.\n\nThe export must have reached the `completed` status. An export still processing, or one that failed or was canceled, returns `409` naming the current status. An export whose generated file is no longer available — the retention window elapsed, or the object was purged — returns `404` (`Export file is no longer available`), not `410`.', + 'Return a short-lived signed download URL for a completed table export.\n\nThe export must have reached `completed`; one still processing, failed, or canceled is a `409` naming the current status. An export whose file is no longer available is a `404`, not a `410`.', errors: RESOURCE_CONFLICT_ERRORS, success: { description: 'Signed table-export download information.' }, }), @@ -1609,7 +1609,7 @@ export const tablesOpenApiDocument = defineOpenApiDocument({ info: { title: 'Sim Tables API v2', description: - 'Manage tables, typed columns, rows, saved views, workflow groups, folders, imports, and exports through the public v2 API. Row data is keyed by column name.', + 'Version 2 of the Sim REST API for tables, typed columns, rows, saved views, workflow groups, folders, imports, and exports. Row data is keyed by column name.', version: '2.0.0', contact: { name: 'Sim Support', email: 'help@sim.ai', url: 'https://www.sim.ai' }, license: { name: 'Apache 2.0', url: 'https://www.apache.org/licenses/LICENSE-2.0.html' }, diff --git a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts index 0afaed27e78..14636710ccd 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts @@ -324,7 +324,7 @@ const routes = [ operationId: 'getWorkflowDeployment', summary: 'Get Workflow Deployment', description: - 'Read the current deployment state of a workflow: whether a version is live, when it went live, the most recent deployment attempt with its readiness and failure payload, and whether the editable draft has since diverged from the live version. This is the only place `needsRedeployment` is published — the deploy, undeploy, and rollback responses cannot carry it, because they answer at the moment the draft and the live version are equal.', + 'Read the current deployment state of a workflow: whether a version is live, when it went live, the most recent deployment attempt with its readiness and failure payload, and whether the editable draft has since diverged from the live version. This is the only operation that publishes `needsRedeployment`.', errors: RESOURCE_ERRORS, success: jsonSuccess('The current deployment state.'), }), @@ -372,7 +372,7 @@ const routes = [ workflowOperation({ operationId: 'deployWorkflow', summary: 'Deploy Workflow', - description: `Create and asynchronously activate a deployment version. This request is not idempotent: every call mints a new deployment version, so retrying after a timeout creates a second version rather than returning the first. A deployment that would conflict with an existing webhook path is a 409. ${WORKSPACE_API_KEY_DENIED}`, + description: `Create and asynchronously activate a deployment version. Not idempotent: every call mints a new version, so a retry after a timeout creates a second one. A deployment that would conflict with an existing webhook path is a \`409\`. ${WORKSPACE_API_KEY_DENIED}`, errors: [...RESOURCE_ERRORS, 'Conflict', 'PayloadTooLarge', 'Locked'], success: jsonSuccess('The accepted deployment attempt.'), }), @@ -563,7 +563,7 @@ const routes = [ workflowOperation({ operationId: 'executeWorkflowV2', summary: 'Execute Workflow', - description: `Execute a deployed workflow synchronously, asynchronously, or as Server-Sent Events. Public workflows permit anonymous synchronous and streaming execution; asynchronous execution requires an API key. A synchronous run that exceeds its execution timeout returns HTTP 200 with \`status: "failed"\` and \`error.code: "TIMEOUT"\` rather than an HTTP error, so branch on \`status\`. The optional \`X-Run-Id\` header is a one-shot uniqueness claim, not an idempotency key: reusing a value returns 409 with \`error.details.code: "RUN_ID_CONFLICT"\` and never replays the earlier run. ${EXECUTE_OPTION_CONSTRAINTS}`, + description: `Execute a deployed workflow synchronously, asynchronously, or as Server-Sent Events. Public workflows permit anonymous synchronous and streaming execution; asynchronous execution requires an API key. A synchronous run that exceeds its execution timeout returns HTTP 200 with \`status: "failed"\` and \`error.code: "TIMEOUT"\` rather than an HTTP error, so branch on \`status\`. ${EXECUTE_OPTION_CONSTRAINTS}`, errors: [ 'BadRequest', 'Unauthorized', @@ -606,7 +606,7 @@ const routes = [ workflowRunOperation({ operationId: 'listWorkflowRunsV2', summary: 'List Workflow Runs', - description: `List recorded runs of a workflow with filtering and opaque cursor pagination. Ordering deviates from the v2 \`sortBy\` + \`sortOrder\` convention: runs are sortable only by start time, so direction is carried by the single \`order\` param. ${RUN_RETENTION}`, + description: `List recorded runs of a workflow with filtering and opaque cursor pagination. ${RUN_RETENTION}`, errors: RESOURCE_ERRORS, success: jsonSuccess('A page of workflow runs.'), }), @@ -711,7 +711,7 @@ const routes = [ operationId: 'cancelRunV2', summary: 'Cancel Workflow Run', description: - 'Request cancellation of a running, queued, or paused workflow run. Cancelling a run that has already reached a terminal state succeeds with no effect rather than returning an error. A run produced by a table workflow group is a 409 when its cell can no longer accept the cancellation, because the run and its cell must reach the cancelled state together.', + 'Request cancellation of a running, queued, or paused workflow run. Cancelling a run already in a terminal state succeeds with no effect. A run produced by a table workflow group is a `409` when its cell can no longer accept the cancellation.', errors: RESOURCE_CONFLICT_ERRORS, success: jsonSuccess('The cancellation outcome.'), }), diff --git a/apps/sim/lib/api/contracts/v2/shared.ts b/apps/sim/lib/api/contracts/v2/shared.ts index 6b3f4826bcd..7a424a7cb44 100644 --- a/apps/sim/lib/api/contracts/v2/shared.ts +++ b/apps/sim/lib/api/contracts/v2/shared.ts @@ -402,13 +402,12 @@ export function v2RunWindowBoundSchema(field: 'startDate' | 'endDate') { * {@link LIST_SORT_ORDERS}, the same one `sortOrder` publishes everywhere else. */ export function v2RunOrderSchema(subject: 'execution' | 'run') { - const noun = subject === 'execution' ? 'logs' : 'runs' return z .enum(LIST_SORT_ORDERS) .optional() .default('desc') .describe( - `Sort direction by ${subject} start time. This operation deviates from the v2 \`sortBy\` + \`sortOrder\` convention: ${noun} are sortable only by start time, so the direction is carried by this single \`order\` param and \`sortBy\`/\`sortOrder\` are not accepted.` + `Sort direction by ${subject} start time. This list is sortable only by ${subject} start time, so it takes \`order\` in place of \`sortBy\`/\`sortOrder\`, which it rejects.` ) } @@ -420,7 +419,7 @@ export function v2RunOrderSchema(subject: 'execution' | 'run') { export const V2_SEARCH_MAX_LENGTH = 200 /** - * Added to `sortBy` wherever `name` is sortable. + * Added to `sortBy` wherever a text name column is sortable. * * Name ordering is `ORDER BY` on the stored text with no `COLLATE` and no * `lower()`, so it is whatever the server database's collation does — under a @@ -429,8 +428,9 @@ export const V2_SEARCH_MAX_LENGTH = 200 * spec must not promise one; what it can promise is that Sim does not case-fold, * which is the part a caller gets wrong. */ -const NAME_SORT_COLLATION = - 'Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.' +export function nameSortCollation(field = 'name') { + return `Sorting by \`${field}\` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.` +} export const v2SearchSchema = z .string() @@ -438,7 +438,7 @@ export const v2SearchSchema = z .min(1, 'search cannot be empty') .max(V2_SEARCH_MAX_LENGTH, 'search is too long') .optional() - .describe('Case-insensitive substring search on the resource name.') + .describe('Case-insensitive substring match against the resource name.') /** * Appended to every list folder-filter description. @@ -494,7 +494,7 @@ function canonicalFolderPathSchema(parser: (path: string) => string[]) { * character count — a name outside the unreserved set spends up to twelve * bytes per source character. */ -const FOLDER_PATH_FORMAT = `Segments are percent-encoded, so a folder shown as "New folder" is \`/New%20folder\`: everything outside \`A-Z a-z 0-9 - _ . ~\` is escaped as uppercase hex, and only that exact encoding is accepted. Unicode is supported encoded. A trailing slash, an empty segment, and a literal \`.\` or \`..\` segment are rejected. At most ${MAX_FOLDER_PATH_SEGMENTS} segments and ${MAX_FOLDER_PATH_BYTES} encoded bytes.` +const FOLDER_PATH_FORMAT = `Segments are percent-encoded, so a folder shown as "New folder" is \`/New%20folder\`: everything outside \`A-Z a-z 0-9 - _ . ~\` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal \`.\` or \`..\` segment are rejected. At most ${MAX_FOLDER_PATH_SEGMENTS} segments and ${MAX_FOLDER_PATH_BYTES} encoded bytes.` /** Canonical slash-prefixed folder path. `/` is the workspace root. */ export const v2FolderPathSchema = canonicalFolderPathSchema(parseFolderPath).meta({ @@ -632,7 +632,7 @@ export function v2SortFields( defaults: { sortBy: F[number]; sortOrder: V2SortOrder } ) { const sortByDescription = fields.includes('name') - ? `Field used to sort the result. ${NAME_SORT_COLLATION}` + ? `Field used to sort the result. ${nameSortCollation()}` : 'Field used to sort the result.' return { sortBy: z.enum(fields).default(defaults.sortBy).describe(sortByDescription), diff --git a/apps/sim/lib/api/contracts/v2/skills.ts b/apps/sim/lib/api/contracts/v2/skills.ts index 79ddce2784a..fe2a8cd026d 100644 --- a/apps/sim/lib/api/contracts/v2/skills.ts +++ b/apps/sim/lib/api/contracts/v2/skills.ts @@ -87,7 +87,7 @@ export type V2SkillDeleteData = z.output export const v2SkillParamsSchema = z.object({ id: nonEmptyIdSchema.describe( - 'Skill to retrieve, update, or delete. A built-in skill is `builtin-` followed by its name, for example `builtin-research`.' + 'Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`.' ), }) export type V2SkillParams = z.output diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index 3add6bfefca..30676dce740 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -693,13 +693,17 @@ export const v2TableRowsQuerySchema = tableRowsQueryBaseSchema * `DEFAULT_QUERY_LIMIT` through the inner default. */ limit: tableRowsQueryBaseSchema.shape.limit - .describe('Maximum rows to return in the current page.') + .describe( + `Maximum rows to return per page. Must be a whole number from 1 to ${V2_MAX_ROW_LIMIT}. Defaults to ${V2_DEFAULT_ROW_LIMIT}.` + ) .prefault(TABLE_LIMITS.DEFAULT_QUERY_LIMIT), cursor: z .string() .min(1, 'cursor must be a non-empty token') .optional() - .describe('Opaque cursor returned by the previous page.'), + .describe( + 'Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.' + ), }) .strict() export type V2TableRowsQuery = z.output @@ -972,7 +976,7 @@ export const v2UpsertTableRowBodySchema = upsertTableRowBodySchema .omit(OMIT_PRIVATE_PROVENANCE) .extend({ data: v2RowDataSchema.describe( - 'Complete set of row cells keyed by column name. On the update branch this REPLACES the matched row: any column not present here is cleared, unlike the merging PATCH /rows/{rowId}.' + 'Complete set of row cells keyed by column name. On the update branch this REPLACES the matched row: any column not present here is cleared, unlike the merging `PATCH /api/v2/tables/{tableId}/rows/{rowId}`.' ), }) .strict() @@ -1819,8 +1823,12 @@ export const v2CreateTableImportDataSchema = z session: v2WorkspaceFileTableImportSchema.describe( 'Created workspace-file import session.' ), - uploadToken: z.null().describe('Always null for workspace-file imports.'), - transfer: z.null().describe('Always null for workspace-file imports.'), + uploadToken: z + .null() + .describe('Always null; a workspace-file import has no upload to authorize.'), + transfer: z + .null() + .describe('Always null; a workspace-file import has no bytes to transfer.'), }) .strict(), ]) diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 5f53a288f67..afa12772f6a 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -63,10 +63,10 @@ export const v2WorkflowRunIdSchema = runIdSchema * double-executes (fresh id per attempt) or hard-fails (same id per attempt). */ const X_RUN_ID_DESCRIPTION = - 'Caller-supplied run identifier, available only to API-key callers. This is a one-shot uniqueness claim, NOT an idempotency key: the first request to use a value starts a run, and any later request reusing it fails with 409 and `error.details.code: "RUN_ID_CONFLICT"` instead of replaying the original result. To retry safely, generate a fresh value per attempt and reconcile duplicates yourself, or omit the header and let the server allocate the run identifier.' + 'Caller-supplied run identifier, available only to API-key callers. A one-shot uniqueness claim, NOT an idempotency key: reusing a value fails with `409` and `error.details.code: "RUN_ID_CONFLICT"` rather than replaying the original result. To retry safely, send a fresh value per attempt, or omit the header and let the server allocate one.' const X_SIM_VIA_DESCRIPTION = - 'Comma-separated workflow identifiers describing the workflow-to-workflow call chain that led to this request. Each hop appends its own workflow id, and Sim sets it automatically when one workflow calls another; supply it yourself only when relaying an existing chain. A chain already at the maximum depth is rejected with 409 and `error.details.code: "CALL_CHAIN_DEPTH_EXCEEDED"`, which is how runaway recursion between workflows is stopped.' + 'Comma-separated workflow identifiers naming the workflow-to-workflow call chain that led to this request. Each hop appends its own workflow id, and Sim sets it automatically; supply it yourself only when relaying an existing chain. A chain at the maximum depth is rejected with `409` and `error.details.code: "CALL_CHAIN_DEPTH_EXCEEDED"`.' export const v2ExecuteWorkflowHeadersSchema = z .object({ @@ -77,7 +77,7 @@ export const v2ExecuteWorkflowHeadersSchema = z id: 'ExecuteWorkflowHeaders', title: 'Execute workflow headers', description: - 'Optional one-shot run-identifier claim and workflow call-chain marker for a workflow execution. Reusing an `X-Run-Id` returns 409 and `error.details.code: "RUN_ID_CONFLICT"`; it does not replay the earlier run. An `X-Sim-Via` chain at maximum depth returns 409 and `error.details.code: "CALL_CHAIN_DEPTH_EXCEEDED"`.', + 'Optional one-shot run-identifier claim and workflow call-chain marker for a workflow execution.', }) export type V2ExecuteWorkflowHeaders = z.input @@ -189,7 +189,7 @@ export const v2WorkflowListItemSchema = z .int() .nonnegative() .describe( - 'Runs that finished successfully. A run that failed, was cancelled, or is still paused is not counted, and the counter is never reduced when a run ages out of log retention — so this is not the number of runs `GET /api/v2/workflows/{id}/runs` returns, in either direction.' + 'Runs that finished successfully. Failed, cancelled, and paused runs are not counted, and the counter is never reduced when a run ages out of log retention — so it does not match the size of `GET /api/v2/workflows/{id}/runs`, in either direction.' ), lastRunAt: z .string() @@ -343,7 +343,7 @@ export const v2DeployWorkflowDataSchema = v2DeploymentStateSchema id: 'DeployResult', title: 'Deploy result', description: - 'Deployment attempt accepted for processing. Activation is asynchronous; `latestDeploymentAttempt` on this response is the attempt handle. The request is NOT idempotent — every POST mints a new deployment version, so a retry after a timeout creates a second version rather than returning the first. `latestDeploymentAttempt` is returned only here: `GET /workflows/{id}` does not carry it, so poll activation with `isDeployed` and `deployedAt` on the workflow, or with `isActive` on `GET /workflows/{id}/versions`.', + 'Deployment attempt accepted for processing. Activation is asynchronous, and `latestDeploymentAttempt` is the attempt handle — returned only here. Poll activation with `isDeployed` and `deployedAt` on the workflow, or `isActive` on `GET /workflows/{id}/versions`.', }) export type V2DeployWorkflowData = z.output @@ -660,7 +660,7 @@ export const v2WorkflowVersionDetailSchema = z .describe('ISO 8601 timestamp when this version was created.') .meta({ format: 'date-time' }), state: deployedWorkflowStateSchema.describe( - 'Deployed workflow graph snapshot pinned by this version. Credential-bearing values are redacted: `oauth-input`, `password: true`, and table sub-block values are null; sensitive nested tool parameters and every parameter without authoritative codec metadata are null.' + 'Deployed workflow graph snapshot pinned by this version, with credential-bearing values redacted to null: `oauth-input`, `password: true`, table sub-block values, sensitive nested tool parameters, and any parameter without authoritative codec metadata.' ), }) .meta({ @@ -864,7 +864,7 @@ export const v2ExecuteWorkflowBodySchema = z .max(MAX_WORKFLOW_EXECUTION_TIMEOUT_SECONDS) .optional() .describe( - "Requested server-side timeout for an asynchronous run, in seconds. This is an upper bound on the request, not the effective timeout: the run uses the smaller of this value and the account plan's execution timeout, so requesting more than the plan allows silently yields the plan timeout with no warning. Rejected with 400 unless `async` is true." + "Requested server-side timeout for an asynchronous run, in seconds. An upper bound, not the effective timeout: the run uses the smaller of this value and the plan's execution timeout, so requesting more than the plan allows silently yields the plan timeout. Rejected with `400` unless `async` is true." ), stream: z .boolean() @@ -955,7 +955,7 @@ export const v2ExecuteWorkflowDataSchema = z id: 'WorkflowRunResult', title: 'Workflow run result', description: - 'Synchronous workflow run output and in-band execution status. Run failures are reported in band, not as HTTP errors — a synchronous run that exceeds its execution timeout returns HTTP 200 with `status: "failed"` and `error.code: "TIMEOUT"`, so always branch on `status` rather than on the HTTP status alone.', + 'Synchronous workflow run output and in-band execution status. Run failures are reported in band, not as HTTP errors — a run that exceeds its execution timeout returns HTTP 200 with `status: "failed"` and `error.code: "TIMEOUT"`, so branch on `status`.', }) export type V2ExecuteWorkflowData = z.output @@ -1065,7 +1065,7 @@ export const v2ResumeWorkflowContract = defineRouteContract({ }) const RUN_STATUS_DESCRIPTION = - 'Current or terminal run status. `redacting` is transient, reported while the output of a finished run is being scrubbed. `paused` means the run is not executing and is waiting to be resumed: either held at a human-in-the-loop pause point, or left paused because a resume attempt did not run to completion. The status alone does not say which. On the single-run response `paused.automaticResumeWaitingReason` distinguishes them: it is recorded whenever a resume attempt fails and cleared once a resume succeeds, so a null value means the run is waiting on human input. When the failure is not retryable or the automatic retries are exhausted, the reason is prefixed `Automatic resume requires manual intervention: `. Run-list items carry no `paused` object, so the two cases are indistinguishable there.' + "Current or terminal run status. `redacting` is transient, reported while a finished run's output is being scrubbed. `paused` means the run is waiting to be resumed — either held at a human-in-the-loop pause point, or left paused by a resume attempt that did not complete. Only the single-run response distinguishes the two, through `paused.automaticResumeWaitingReason`." /** * The list projection passes `workflow_execution_logs.status` through except where it @@ -1295,14 +1295,14 @@ export const v2CancelWorkflowRunDataSchema = z reason: cancelWorkflowExecutionReasonSchema .optional() .describe( - 'Machine-readable cancellation outcome. Present on every cancellation, including full successes — it is not a partial-failure marker. `recorded` means cancellation was durably recorded (the normal success value). `redis_unavailable` and `redis_write_failed` mean the distributed cancellation signal could not be written, so an already-running execution may not observe the cancellation. `paused_event_publish_failed` and `paused_database_cancel_failed` name the failing step when cancelling a paused human-in-the-loop run.' + 'Machine-readable cancellation outcome, present on every cancellation including full successes. `recorded` is the success value. `redis_unavailable` and `redis_write_failed` mean the distributed cancellation signal was not written, so an already-running execution may not observe the cancellation. `paused_event_publish_failed` and `paused_database_cancel_failed` name the failing step for a paused run.' ), }) .meta({ id: 'CancelWorkflowRunResult', title: 'Cancel workflow run result', description: - 'Outcome of a workflow run cancellation request. Cancelling a run that has already reached a terminal state (completed, failed, or cancelled) succeeds with no effect rather than returning an error — treat this endpoint as best-effort and poll the run to observe the final state.', + 'Outcome of a workflow run cancellation request. Cancellation is best-effort: a run already in a terminal state succeeds with no effect, so poll the run to observe its final state.', }) export type V2CancelWorkflowRunData = z.output diff --git a/apps/sim/lib/api/contracts/v2/workspaces.ts b/apps/sim/lib/api/contracts/v2/workspaces.ts index a89dbb3d563..c85fbcdd001 100644 --- a/apps/sim/lib/api/contracts/v2/workspaces.ts +++ b/apps/sim/lib/api/contracts/v2/workspaces.ts @@ -46,7 +46,7 @@ export const v2WorkspaceMemberSchema = z isExternal: z .boolean() .describe( - "Whether the member belongs to a different organization than the workspace. True for an explicitly granted member whose own organization differs from the workspace's; false for the workspace owner and for a member sharing the workspace organization. Inherited organization-administrator access is always reported as false, so this is not a signal that access came from outside the explicit member list." + 'Whether the member belongs to a different organization than the workspace. True only for an explicitly granted member whose own organization differs; inherited organization-administrator access is always reported as false, so this does not detect every outside caller.' ), joinedAt: v2TimestampSchema.describe('ISO 8601 timestamp when access was granted.'), }) diff --git a/apps/sim/lib/api/contracts/workflows.ts b/apps/sim/lib/api/contracts/workflows.ts index acaa7cc344c..45a29933d25 100644 --- a/apps/sim/lib/api/contracts/workflows.ts +++ b/apps/sim/lib/api/contracts/workflows.ts @@ -618,7 +618,9 @@ export const workflowExecutionPausedDetailSchema = z.object({ automaticResumeWaitingReason: z .string() .nullable() - .describe('Reason automatic resume is waiting, or null when it is not waiting.'), + .describe( + 'Why automatic resume is waiting, or null when it is not — on a paused run, null means it is waiting on human input. Recorded whenever a resume attempt fails and cleared once one succeeds. A non-retryable or exhausted failure is prefixed `Automatic resume requires manual intervention: `.' + ), pausedExecutionId: z.string().describe('Persistent paused-execution record identifier.'), pausePointCount: z.number().describe('Number of pause points tracked for the execution.'), resumedCount: z.number().describe('Number of pause points that have resumed.'), diff --git a/scripts/openapi/documents.test.ts b/scripts/openapi/documents.test.ts index 058c1911986..f66495223ec 100644 --- a/scripts/openapi/documents.test.ts +++ b/scripts/openapi/documents.test.ts @@ -8,6 +8,7 @@ import { logsOpenApiDocument } from '../../apps/sim/lib/api/contracts/v2/openapi import { resourcesOpenApiDocument } from '../../apps/sim/lib/api/contracts/v2/openapi/resources' import { FOLDER_TREE_TOO_LARGE, + RUN_RETENTION, WORKSPACE_API_KEY_DENIED, WORKSPACE_API_KEY_DENIED_AS_NOT_FOUND, } from '../../apps/sim/lib/api/contracts/v2/openapi/shared' @@ -405,3 +406,58 @@ describe('knowledge and files documented error sets', () => { expect(description).not.toContain(WORKSPACE_API_KEY_DENIED_AS_NOT_FOUND) }) }) + +/** + * Shared parameter vocabulary. + * + * `cursor` and `sortOrder` appear on dozens of operations across the seven + * documents, and each is sourced from one schema in `contracts/v2/shared.ts`. A + * caller reading two families back to back cannot tell a reworded copy from a + * different contract, so a divergence is a defect rather than a style choice. + * This pins each to one string; a list that hand-rolls its own `cursor` fails + * here. + * + * `startDate`/`endDate` are deliberately excluded: the run-window pair and the + * billing usage window share a name but filter different sequences. + */ +describe('shared parameter descriptions do not fork', () => { + const SINGLE_VOICE_PARAMETERS = ['cursor', 'sortOrder'] as const + + const descriptionsByParameter = new Map>() + for (const document of DOCUMENTS) { + const spec = generateOpenApiDocument(document) + for (const operation of operations(spec)) { + for (const parameter of (operation.parameters ?? []) as JsonObject[]) { + const name = parameter.name as string + if (!SINGLE_VOICE_PARAMETERS.includes(name as (typeof SINGLE_VOICE_PARAMETERS)[number])) { + continue + } + const seen = descriptionsByParameter.get(name) ?? new Set() + seen.add(parameter.description as string) + descriptionsByParameter.set(name, seen) + } + } + } + + it.each(SINGLE_VOICE_PARAMETERS)('publishes one description for %s', (name) => { + expect([...(descriptionsByParameter.get(name) ?? [])]).toHaveLength(1) + }) +}) + +/** + * The run-retention window is the one fact that explains an empty run list on a + * workflow reporting a non-zero `runCount`, and it is published on both reads + * over `workflow_execution_logs` from one constant. Pinning both keeps a future + * trim from silently dropping it off one of them. + */ +describe('run retention is published on both run reads', () => { + it.each([ + [logsOpenApiDocument, 'listLogs'], + [workflowsOpenApiDocument, 'listWorkflowRunsV2'], + ] as const)('%#: names the retention window', (document, operationId) => { + const description = document.routes.find((route) => route.operation.operationId === operationId) + ?.operation.description + + expect(description).toContain(RUN_RETENTION) + }) +}) From 165db833769453ac8a56f504f38dc7a8c2499eb7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 20:14:43 -0700 Subject: [PATCH 27/56] fix(v2): keep one unreadable-cursor message Two branches each added the constant, in cursor-binding and list-query. It belongs beside its sibling REFILTERED_CURSOR_MESSAGE, so the list-query copy and its importers move there. --- apps/sim/app/api/v2/lib/response.ts | 6 +----- apps/sim/app/api/v2/logs/route.ts | 2 +- apps/sim/lib/api/list-query.ts | 3 --- 3 files changed, 2 insertions(+), 9 deletions(-) diff --git a/apps/sim/app/api/v2/lib/response.ts b/apps/sim/app/api/v2/lib/response.ts index c6a6e2cae12..9bb1ff83b56 100644 --- a/apps/sim/app/api/v2/lib/response.ts +++ b/apps/sim/app/api/v2/lib/response.ts @@ -6,11 +6,7 @@ import { REFILTERED_CURSOR_MESSAGE, UNREADABLE_CURSOR_MESSAGE, } from '@/lib/api/cursor-binding' -import { - type CursorKey, - INVALID_CURSOR_MESSAGE, - UNREADABLE_CURSOR_MESSAGE, -} from '@/lib/api/list-query' +import { type CursorKey, INVALID_CURSOR_MESSAGE } from '@/lib/api/list-query' import { getValidationErrorMessage, serializeZodIssues } from '@/lib/api/server' import { ADMISSION_RETRY_AFTER_SECONDS } from '@/lib/core/admission/transient-failure' import { forbiddenErrorDetails } from '@/lib/core/application' diff --git a/apps/sim/app/api/v2/logs/route.ts b/apps/sim/app/api/v2/logs/route.ts index 55ccdfa0c2c..8a72aa58e42 100644 --- a/apps/sim/app/api/v2/logs/route.ts +++ b/apps/sim/app/api/v2/logs/route.ts @@ -4,7 +4,7 @@ import { v2ListLogsContract, v2LogStatusSchema, } from '@/lib/api/contracts/v2/logs' -import { UNREADABLE_CURSOR_MESSAGE } from '@/lib/api/list-query' +import { UNREADABLE_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { OrchestrationError } from '@/lib/core/orchestration/types' import { v2LogErrorPolicies } from '@/lib/logs/api/route-policies' diff --git a/apps/sim/lib/api/list-query.ts b/apps/sim/lib/api/list-query.ts index 8de86cd6575..ba9978107b5 100644 --- a/apps/sim/lib/api/list-query.ts +++ b/apps/sim/lib/api/list-query.ts @@ -68,9 +68,6 @@ export const INVALID_CURSOR_MESSAGE = * problem `UNKNOWN_CURSOR_MESSAGE` was written to avoid on the ledger. The * actionable half — restart without a cursor — is identical. */ -export const UNREADABLE_CURSOR_MESSAGE = - 'cursor is not a readable pagination token. Restart pagination without a cursor; a cursor is only valid for the request that issued it.' - /** * One column of a keyset ordering, with the codec that moves its value through * the opaque cursor. From 1ae4a72be8991b5072344856a616e5089526b570 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 20:36:50 -0700 Subject: [PATCH 28/56] fix(v2): bind a cursor to what a set filter means, not how it was spelled workflowIds, triggers and folderPaths are comma lists the query treats as unordered sets, and tagFilters is an object whose key order carries no meaning. Fingerprinting the raw spelling bound the cursor to the spelling, so a caller who reordered an equivalent filter mid-walk got a 400 for a page that was genuinely the next one. --- .../api/v2/knowledge/[id]/documents/route.ts | 18 ++++++++++++++- apps/sim/app/api/v2/logs/route.ts | 8 +++---- apps/sim/lib/api/cursor-binding.test.ts | 19 ++++++++++++++- apps/sim/lib/api/cursor-binding.ts | 23 +++++++++++++++++++ 4 files changed, 62 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts index b7610646ef8..0ecc8462481 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts @@ -4,6 +4,7 @@ import { v2ListKnowledgeDocumentsContract, v2UploadKnowledgeDocumentContract, } from '@/lib/api/contracts/v2/knowledge' +import { canonicalJson } from '@/lib/api/cursor-binding' import { defineV2BodyLifecycleRoute, defineV2JsonRoute, @@ -45,6 +46,21 @@ export const revalidate = 0 const MAX_FILE_SIZE = MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE /** Every param that changes which documents, in which order, this list returns. */ +/** + * Canonical form of `tagFilters` so two equivalent filters differing only in + * key order fingerprint the same. {@link canonicalJson} sorts object keys. An + * unparseable value binds by its raw spelling — the request carrying it is + * about to fail validation anyway. + */ +function canonicalTagFilters(raw: string | undefined): string | undefined { + if (raw === undefined) return undefined + try { + return canonicalJson(JSON.parse(raw)) + } catch { + return raw + } +} + function documentCursorFilters( knowledgeBaseId: string, query: { workspaceId: string; enabledFilter?: string; search?: string; tagFilters?: string } @@ -54,7 +70,7 @@ function documentCursorFilters( workspaceId: query.workspaceId, enabledFilter: query.enabledFilter, search: query.search, - tagFilters: query.tagFilters, + tagFilters: canonicalTagFilters(query.tagFilters), }) } diff --git a/apps/sim/app/api/v2/logs/route.ts b/apps/sim/app/api/v2/logs/route.ts index 8a72aa58e42..0f0420d4b22 100644 --- a/apps/sim/app/api/v2/logs/route.ts +++ b/apps/sim/app/api/v2/logs/route.ts @@ -4,7 +4,7 @@ import { v2ListLogsContract, v2LogStatusSchema, } from '@/lib/api/contracts/v2/logs' -import { UNREADABLE_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' +import { UNREADABLE_CURSOR_MESSAGE, unorderedScopePart } from '@/lib/api/cursor-binding' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { OrchestrationError } from '@/lib/core/orchestration/types' import { v2LogErrorPolicies } from '@/lib/logs/api/route-policies' @@ -41,8 +41,8 @@ function logCursorFilters(query: { }) { return cursorFilterScope({ workspaceId: query.workspaceId, - workflowIds: query.workflowIds, - triggers: query.triggers, + workflowIds: unorderedScopePart(query.workflowIds), + triggers: unorderedScopePart(query.triggers), level: query.level, startDate: query.startDate, endDate: query.endDate, @@ -52,7 +52,7 @@ function logCursorFilters(query: { minCost: query.minCost, maxCost: query.maxCost, model: query.model, - folderPaths: query.folderPaths, + folderPaths: unorderedScopePart(query.folderPaths), order: query.order, }) } diff --git a/apps/sim/lib/api/cursor-binding.test.ts b/apps/sim/lib/api/cursor-binding.test.ts index e0a8dc82e78..6b94b284897 100644 --- a/apps/sim/lib/api/cursor-binding.test.ts +++ b/apps/sim/lib/api/cursor-binding.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { cursorScopeKey } from '@/lib/api/cursor-binding' +import { cursorScopeKey, unorderedScopePart } from '@/lib/api/cursor-binding' import { cursorFilterScope, cursorSortKey, @@ -192,3 +192,20 @@ describe('v2 cursor binding', () => { }) }) }) + +describe('unordered filter scope parts', () => { + it('fingerprints a reordered set identically', () => { + const a = cursorScopeKey({ workflowIds: unorderedScopePart('A,B') }) + const b = cursorScopeKey({ workflowIds: unorderedScopePart('B,A') }) + expect(a).toBe(b) + }) + it('still separates genuinely different sets', () => { + expect(cursorScopeKey({ workflowIds: unorderedScopePart('A,B') })).not.toBe( + cursorScopeKey({ workflowIds: unorderedScopePart('A,C') }) + ) + }) + it('treats an all-empty list as absent, matching the parsers', () => { + expect(unorderedScopePart(',,')).toBeUndefined() + expect(unorderedScopePart('A,,B')).toBe('A,B') + }) +}) diff --git a/apps/sim/lib/api/cursor-binding.ts b/apps/sim/lib/api/cursor-binding.ts index 62e286966d9..24ff3797930 100644 --- a/apps/sim/lib/api/cursor-binding.ts +++ b/apps/sim/lib/api/cursor-binding.ts @@ -58,6 +58,29 @@ export type CursorScopePart = * Array order is preserved — reordering an `in` list is treated as a different * filter, which only ever costs a restart. */ +/** + * Canonical form of a filter the query treats as an unordered SET. + * + * A comma-separated list and a JSON object both have a spelling the caller + * chose and a meaning the query acts on: `workflowIds=A,B` and `B,A` select the + * same runs, and two `tagFilters` objects differing only in key order match the + * same documents. Fingerprinting the raw spelling binds the cursor to the + * spelling, so a caller who reorders an equivalent filter mid-walk gets a 400 + * for a page that is genuinely the next one. + * + * {@link canonicalJson} already sorts object keys, so this only has to sort the + * list members. Empty members are dropped because the parsers drop them too. + */ +export function unorderedScopePart(raw: string | undefined): string | undefined { + if (raw === undefined) return undefined + const members = raw + .split(',') + .map((member) => member.trim()) + .filter((member) => member.length > 0) + .sort() + return members.length > 0 ? members.join(',') : undefined +} + export function canonicalJson(value: unknown): string { if (value instanceof Date) return JSON.stringify(value.toISOString()) if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null' From f8f568e54a9c437a7c9447a663132a4bc2efb117 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 20:55:18 -0700 Subject: [PATCH 29/56] fix(v2, db): make two unfalsifiable tests observable and document strict query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-ups on the w5 policy work: one decision recorded, two tests that could not fail. The `query: noInputSchema` sweep is kept. It is a real tightening — 69 v2 operations that ignored an unknown query param now answer 400 — so it was weighed rather than assumed. The v2 body slice on those same endpoints was already `.strict()`, and every v2 list already rejected `?bogus=1`, so the split was arbitrary rather than a promise: the same typo was a 400 on `GET /workflows` and a silent 200 on `GET /workflows/{id}`. A parameter the server drops without saying so is the bug class the lists' rule already exists to prevent. No first-party caller is affected — the two SDKs send only `includeOutput`/`selectedOutputs`, both declared; the UI and the desktop app make no v2 calls at all; `requestJson` appends nothing implicitly and no v2 cache buster exists; every docs example uses a declared param. A third-party caller appending a tracking tag does break, which is why the behavior is now documented in the API reference with the exact 400 body rather than left to be discovered, and why the reasoning sits in the v2 conventions skill next to the rule instead of only in a commit message. `packages/db/timestamps.test.ts` asserted that `withUtcTimestamps` registers a UTC parser on oid 1114 by reading it off a bare postgres.js client. Every real client is then handed to `drizzle()`, which overwrites that entry with a transparent parser, so the assertion held whether or not the parser had any effect. The mechanism is fine and stays: drizzle's own `PgTimestamp` mapper appends `+0000`, so the read is UTC-correct either way and the session `TimeZone` pin — the write-side fix — is untouched by `drizzle()`. The test now resolves the parser both before and after `drizzle()`, pins the clobbering it depends on, and asserts the instant recovered through the full composition, so a regression in either layer is red. `timestamps.ts` records why the inert entry is kept. `nul-byte-boundary.test.ts` embedded a raw U+0000, so git classified it binary and rendered it as `Bin 0 -> 4102 bytes` — the test proving the NUL hardening works was the one file a reviewer could not read. The escape is byte-for-byte equivalent at runtime. Two older files had the same defect and are fixed the same way. `check:source-text` now fails the build on a raw NUL in any tracked source file, and `.gitattributes` forces source files to diff as text so the next one is visible in review rather than hidden by it. --- .agents/skills/v2-api-conventions/SKILL.md | 5 ++ .claude/commands/v2-api-conventions.md | 5 ++ .cursor/commands/v2-api-conventions.md | 5 ++ .gitattributes | 9 +++ apps/desktop/src/main/downloads.test.ts | Bin 1938 -> 1948 bytes .../docs/de/api-reference/getting-started.mdx | 18 +++++ .../docs/en/api-reference/getting-started.mdx | 18 +++++ .../docs/es/api-reference/getting-started.mdx | 18 +++++ .../docs/fr/api-reference/getting-started.mdx | 18 +++++ .../docs/ja/api-reference/getting-started.mdx | 18 +++++ .../docs/zh/api-reference/getting-started.mdx | 18 +++++ .../file-viewer/mermaid-diagram.tsx | Bin 7062 -> 7067 bytes .../lib/api/server/nul-byte-boundary.test.ts | Bin 4102 -> 4577 bytes package.json | 1 + packages/db/timestamps.test.ts | 76 ++++++++++++++---- packages/db/timestamps.ts | 10 +++ scripts/check-source-text.ts | 73 +++++++++++++++++ 17 files changed, 277 insertions(+), 15 deletions(-) create mode 100644 scripts/check-source-text.ts diff --git a/.agents/skills/v2-api-conventions/SKILL.md b/.agents/skills/v2-api-conventions/SKILL.md index 3b54cf478d8..55da39cd239 100644 --- a/.agents/skills/v2-api-conventions/SKILL.md +++ b/.agents/skills/v2-api-conventions/SKILL.md @@ -119,6 +119,10 @@ Return `nextCursor: null` on the last page and only then. Never construct a curs ## Rule 4 — reject what you do not implement +**Every contract declares a `query`, even when the endpoint takes none** — `query: noInputSchema` (`z.object({}).strict()`), never omission. `parseRequest` validates the query slice only when the contract declares one, so an omitted `query` means "never look at the query string", not "takes no query params". The two were indistinguishable, which is how 69 contracts ended up accepting anything without anyone deciding they should: `GET /workflows/{id}?bogus=1` answered 200 while every list answered 400 for the same shape. `query-declaration.test.ts` sweeps the tree so contract 70 fails at authoring time rather than shipping unvalidated. + +Declaring them is a **deliberate tightening** of endpoints that previously ignored an unknown param. It was weighed and kept: the v2 body slice on those same endpoints was already strict, so the split was arbitrary rather than a promise to callers; a mistyped param that is silently dropped is the bug class this rule exists to prevent; and no first-party caller sends an undeclared v2 query param (the two SDKs send only `includeOutput`/`selectedOutputs`, both declared; the UI makes no v2 calls at all; `requestJson` appends nothing implicitly and no v2 cache buster exists). Third-party callers appending a tracking tag or cache buster do break, which is why `api-reference/getting-started.mdx` documents the behavior rather than leaving it to be discovered from a 400. + Query and body schemas are **`.strict()`** — and `.strict()` binds the **top level only**. A strict body containing a non-strict nested object still drops unknown keys one level down, which is the headline `filter` bug at a smaller scale: `sort: [{ field, direction, nulls: 'last' }]` answered 200 and ordered by the default. Strictness belongs on the shared nested schema (`sortSpecSchema`'s element, `tableViewConfigSchema`), not restated per body. Before tightening a schema that is **also** a response or a stored blob, make the read canonical first. `table_views.config` is schemaless JSONB, so a legacy row carrying a retired key would fail a newly strict response parse and become a 500; `normalizeStoredViewConfig` projects the stored blob onto the declared keys so the tightening is safe in both directions. Zod strips unknown keys by default, so a non-strict schema answers `?limit=1` with 200 and the whole set — the caller believes it bounded the response and it did not. That is a contract lie, and on an uncapped list it is also an unbounded-response risk. @@ -207,6 +211,7 @@ Run this against any new or changed v2 endpoint. - [ ] Success body is exactly `{data}` or `{data, nextCursor}`; failures are exactly `{error:{code,message,details?}}`. - [ ] Route uses a shared builder; no hand-built `NextResponse.json`. - [ ] Query and body schemas are `.strict()`. +- [ ] The contract declares a `query` — `noInputSchema` when the endpoint takes no query params, never omission. - [ ] No caller-supplied value can produce a 500 — check every numeric param reaches SQL as a validated integer, and that any bound value passed as an argument to a SQL function carries an explicit type. - [ ] `limit` comes from `v2PaginationFields`, not a hand-written `z.coerce.number()`. - [ ] If the response carries `nextCursor`, the query accepts `limit` + `cursor` and the query actually applies them. diff --git a/.claude/commands/v2-api-conventions.md b/.claude/commands/v2-api-conventions.md index b7c30389a50..c4d86a251dc 100644 --- a/.claude/commands/v2-api-conventions.md +++ b/.claude/commands/v2-api-conventions.md @@ -118,6 +118,10 @@ Return `nextCursor: null` on the last page and only then. Never construct a curs ## Rule 4 — reject what you do not implement +**Every contract declares a `query`, even when the endpoint takes none** — `query: noInputSchema` (`z.object({}).strict()`), never omission. `parseRequest` validates the query slice only when the contract declares one, so an omitted `query` means "never look at the query string", not "takes no query params". The two were indistinguishable, which is how 69 contracts ended up accepting anything without anyone deciding they should: `GET /workflows/{id}?bogus=1` answered 200 while every list answered 400 for the same shape. `query-declaration.test.ts` sweeps the tree so contract 70 fails at authoring time rather than shipping unvalidated. + +Declaring them is a **deliberate tightening** of endpoints that previously ignored an unknown param. It was weighed and kept: the v2 body slice on those same endpoints was already strict, so the split was arbitrary rather than a promise to callers; a mistyped param that is silently dropped is the bug class this rule exists to prevent; and no first-party caller sends an undeclared v2 query param (the two SDKs send only `includeOutput`/`selectedOutputs`, both declared; the UI makes no v2 calls at all; `requestJson` appends nothing implicitly and no v2 cache buster exists). Third-party callers appending a tracking tag or cache buster do break, which is why `api-reference/getting-started.mdx` documents the behavior rather than leaving it to be discovered from a 400. + Query and body schemas are **`.strict()`** — and `.strict()` binds the **top level only**. A strict body containing a non-strict nested object still drops unknown keys one level down, which is the headline `filter` bug at a smaller scale: `sort: [{ field, direction, nulls: 'last' }]` answered 200 and ordered by the default. Strictness belongs on the shared nested schema (`sortSpecSchema`'s element, `tableViewConfigSchema`), not restated per body. Before tightening a schema that is **also** a response or a stored blob, make the read canonical first. `table_views.config` is schemaless JSONB, so a legacy row carrying a retired key would fail a newly strict response parse and become a 500; `normalizeStoredViewConfig` projects the stored blob onto the declared keys so the tightening is safe in both directions. Zod strips unknown keys by default, so a non-strict schema answers `?limit=1` with 200 and the whole set — the caller believes it bounded the response and it did not. That is a contract lie, and on an uncapped list it is also an unbounded-response risk. @@ -206,6 +210,7 @@ Run this against any new or changed v2 endpoint. - [ ] Success body is exactly `{data}` or `{data, nextCursor}`; failures are exactly `{error:{code,message,details?}}`. - [ ] Route uses a shared builder; no hand-built `NextResponse.json`. - [ ] Query and body schemas are `.strict()`. +- [ ] The contract declares a `query` — `noInputSchema` when the endpoint takes no query params, never omission. - [ ] No caller-supplied value can produce a 500 — check every numeric param reaches SQL as a validated integer, and that any bound value passed as an argument to a SQL function carries an explicit type. - [ ] `limit` comes from `v2PaginationFields`, not a hand-written `z.coerce.number()`. - [ ] If the response carries `nextCursor`, the query accepts `limit` + `cursor` and the query actually applies them. diff --git a/.cursor/commands/v2-api-conventions.md b/.cursor/commands/v2-api-conventions.md index a658349e2fb..7456c295e20 100644 --- a/.cursor/commands/v2-api-conventions.md +++ b/.cursor/commands/v2-api-conventions.md @@ -113,6 +113,10 @@ Return `nextCursor: null` on the last page and only then. Never construct a curs ## Rule 4 — reject what you do not implement +**Every contract declares a `query`, even when the endpoint takes none** — `query: noInputSchema` (`z.object({}).strict()`), never omission. `parseRequest` validates the query slice only when the contract declares one, so an omitted `query` means "never look at the query string", not "takes no query params". The two were indistinguishable, which is how 69 contracts ended up accepting anything without anyone deciding they should: `GET /workflows/{id}?bogus=1` answered 200 while every list answered 400 for the same shape. `query-declaration.test.ts` sweeps the tree so contract 70 fails at authoring time rather than shipping unvalidated. + +Declaring them is a **deliberate tightening** of endpoints that previously ignored an unknown param. It was weighed and kept: the v2 body slice on those same endpoints was already strict, so the split was arbitrary rather than a promise to callers; a mistyped param that is silently dropped is the bug class this rule exists to prevent; and no first-party caller sends an undeclared v2 query param (the two SDKs send only `includeOutput`/`selectedOutputs`, both declared; the UI makes no v2 calls at all; `requestJson` appends nothing implicitly and no v2 cache buster exists). Third-party callers appending a tracking tag or cache buster do break, which is why `api-reference/getting-started.mdx` documents the behavior rather than leaving it to be discovered from a 400. + Query and body schemas are **`.strict()`** — and `.strict()` binds the **top level only**. A strict body containing a non-strict nested object still drops unknown keys one level down, which is the headline `filter` bug at a smaller scale: `sort: [{ field, direction, nulls: 'last' }]` answered 200 and ordered by the default. Strictness belongs on the shared nested schema (`sortSpecSchema`'s element, `tableViewConfigSchema`), not restated per body. Before tightening a schema that is **also** a response or a stored blob, make the read canonical first. `table_views.config` is schemaless JSONB, so a legacy row carrying a retired key would fail a newly strict response parse and become a 500; `normalizeStoredViewConfig` projects the stored blob onto the declared keys so the tightening is safe in both directions. Zod strips unknown keys by default, so a non-strict schema answers `?limit=1` with 200 and the whole set — the caller believes it bounded the response and it did not. That is a contract lie, and on an uncapped list it is also an unbounded-response risk. @@ -201,6 +205,7 @@ Run this against any new or changed v2 endpoint. - [ ] Success body is exactly `{data}` or `{data, nextCursor}`; failures are exactly `{error:{code,message,details?}}`. - [ ] Route uses a shared builder; no hand-built `NextResponse.json`. - [ ] Query and body schemas are `.strict()`. +- [ ] The contract declares a `query` — `noInputSchema` when the endpoint takes no query params, never omission. - [ ] No caller-supplied value can produce a 500 — check every numeric param reaches SQL as a validated integer, and that any bound value passed as an argument to a SQL function carries an explicit type. - [ ] `limit` comes from `v2PaginationFields`, not a hand-written `z.coerce.number()`. - [ ] If the response carries `nextCursor`, the query accepts `limit` + `cursor` and the query actually applies them. diff --git a/.gitattributes b/.gitattributes index 8347b118c47..ffd7862b0be 100644 --- a/.gitattributes +++ b/.gitattributes @@ -21,6 +21,15 @@ Dockerfile* text eol=lf .gitignore text eol=lf .gitattributes text eol=lf +# Source files always diff as text. Git otherwise classifies a whole file as +# binary the moment it contains a NUL byte, hiding every line of it from review. +*.ts diff +*.tsx diff +*.js diff +*.jsx diff +*.json diff +*.md diff + # Denote all files that are truly binary and should not be modified *.png binary *.jpg binary diff --git a/apps/desktop/src/main/downloads.test.ts b/apps/desktop/src/main/downloads.test.ts index e8308633b6049dd4fb7564ee5dc02f6d2c2b39a3..92721bfa706f0cbb0be602cd682ce94dbe5ee627 100644 GIT binary patch delta 25 ccmbQlKZk#V0uxV6sR0mxNW-+vN=yZ;09@_{&;S4c delta 15 WcmbQkKZ$>X0uvL1{AL}d0#*PZ=>wJk diff --git a/apps/docs/content/docs/de/api-reference/getting-started.mdx b/apps/docs/content/docs/de/api-reference/getting-started.mdx index 55c4503f38b..4f5ae08008f 100644 --- a/apps/docs/content/docs/de/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/de/api-reference/getting-started.mdx @@ -171,6 +171,24 @@ The API uses standard HTTP status codes. v2 errors include a stable code and hum | `404` | Resource not found | Verify the ID exists and belongs to your workspace | | `429` | Rate limit exceeded | Wait for the duration in the `Retry-After` header | +### Unrecognized fields are rejected + +Every v2 endpoint validates the request against its published schema — path parameters, query string, and body — and answers `400` for any field it does not declare. A misspelled parameter is an error rather than a silent no-op, so `?limt=20` fails instead of quietly returning an unbounded list. + +This holds for endpoints that declare no query parameters at all. Do not append tracking tags, cache busters, or other extra parameters to a v2 URL; send only what the endpoint documents. + +```json +{ + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request", + "details": [ + { "code": "unrecognized_keys", "keys": ["limt"], "path": [], "message": "Unrecognized key: \"limt\"" } + ] + } +} +``` + Use [Get Billing Status](/api-reference/billing/getBillingStatus) to inspect current credit and storage usage. diff --git a/apps/docs/content/docs/en/api-reference/getting-started.mdx b/apps/docs/content/docs/en/api-reference/getting-started.mdx index 8136cec9df3..73b72490885 100644 --- a/apps/docs/content/docs/en/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/en/api-reference/getting-started.mdx @@ -171,6 +171,24 @@ The API uses standard HTTP status codes. v2 errors include a stable code and hum | `404` | Resource not found | Verify the ID exists and belongs to your workspace | | `429` | Rate limit exceeded | Wait for the duration in the `Retry-After` header | +### Unrecognized fields are rejected + +Every v2 endpoint validates the request against its published schema — path parameters, query string, and body — and answers `400` for any field it does not declare. A misspelled parameter is an error rather than a silent no-op, so `?limt=20` fails instead of quietly returning an unbounded list. + +This holds for endpoints that declare no query parameters at all. Do not append tracking tags, cache busters, or other extra parameters to a v2 URL; send only what the endpoint documents. + +```json +{ + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request", + "details": [ + { "code": "unrecognized_keys", "keys": ["limt"], "path": [], "message": "Unrecognized key: \"limt\"" } + ] + } +} +``` + Use [Get Billing Status](/api-reference/billing/getBillingStatus) to inspect current credit and storage usage. diff --git a/apps/docs/content/docs/es/api-reference/getting-started.mdx b/apps/docs/content/docs/es/api-reference/getting-started.mdx index 8136cec9df3..73b72490885 100644 --- a/apps/docs/content/docs/es/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/es/api-reference/getting-started.mdx @@ -171,6 +171,24 @@ The API uses standard HTTP status codes. v2 errors include a stable code and hum | `404` | Resource not found | Verify the ID exists and belongs to your workspace | | `429` | Rate limit exceeded | Wait for the duration in the `Retry-After` header | +### Unrecognized fields are rejected + +Every v2 endpoint validates the request against its published schema — path parameters, query string, and body — and answers `400` for any field it does not declare. A misspelled parameter is an error rather than a silent no-op, so `?limt=20` fails instead of quietly returning an unbounded list. + +This holds for endpoints that declare no query parameters at all. Do not append tracking tags, cache busters, or other extra parameters to a v2 URL; send only what the endpoint documents. + +```json +{ + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request", + "details": [ + { "code": "unrecognized_keys", "keys": ["limt"], "path": [], "message": "Unrecognized key: \"limt\"" } + ] + } +} +``` + Use [Get Billing Status](/api-reference/billing/getBillingStatus) to inspect current credit and storage usage. diff --git a/apps/docs/content/docs/fr/api-reference/getting-started.mdx b/apps/docs/content/docs/fr/api-reference/getting-started.mdx index 8136cec9df3..73b72490885 100644 --- a/apps/docs/content/docs/fr/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/fr/api-reference/getting-started.mdx @@ -171,6 +171,24 @@ The API uses standard HTTP status codes. v2 errors include a stable code and hum | `404` | Resource not found | Verify the ID exists and belongs to your workspace | | `429` | Rate limit exceeded | Wait for the duration in the `Retry-After` header | +### Unrecognized fields are rejected + +Every v2 endpoint validates the request against its published schema — path parameters, query string, and body — and answers `400` for any field it does not declare. A misspelled parameter is an error rather than a silent no-op, so `?limt=20` fails instead of quietly returning an unbounded list. + +This holds for endpoints that declare no query parameters at all. Do not append tracking tags, cache busters, or other extra parameters to a v2 URL; send only what the endpoint documents. + +```json +{ + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request", + "details": [ + { "code": "unrecognized_keys", "keys": ["limt"], "path": [], "message": "Unrecognized key: \"limt\"" } + ] + } +} +``` + Use [Get Billing Status](/api-reference/billing/getBillingStatus) to inspect current credit and storage usage. diff --git a/apps/docs/content/docs/ja/api-reference/getting-started.mdx b/apps/docs/content/docs/ja/api-reference/getting-started.mdx index 8136cec9df3..73b72490885 100644 --- a/apps/docs/content/docs/ja/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/ja/api-reference/getting-started.mdx @@ -171,6 +171,24 @@ The API uses standard HTTP status codes. v2 errors include a stable code and hum | `404` | Resource not found | Verify the ID exists and belongs to your workspace | | `429` | Rate limit exceeded | Wait for the duration in the `Retry-After` header | +### Unrecognized fields are rejected + +Every v2 endpoint validates the request against its published schema — path parameters, query string, and body — and answers `400` for any field it does not declare. A misspelled parameter is an error rather than a silent no-op, so `?limt=20` fails instead of quietly returning an unbounded list. + +This holds for endpoints that declare no query parameters at all. Do not append tracking tags, cache busters, or other extra parameters to a v2 URL; send only what the endpoint documents. + +```json +{ + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request", + "details": [ + { "code": "unrecognized_keys", "keys": ["limt"], "path": [], "message": "Unrecognized key: \"limt\"" } + ] + } +} +``` + Use [Get Billing Status](/api-reference/billing/getBillingStatus) to inspect current credit and storage usage. diff --git a/apps/docs/content/docs/zh/api-reference/getting-started.mdx b/apps/docs/content/docs/zh/api-reference/getting-started.mdx index 8136cec9df3..73b72490885 100644 --- a/apps/docs/content/docs/zh/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/zh/api-reference/getting-started.mdx @@ -171,6 +171,24 @@ The API uses standard HTTP status codes. v2 errors include a stable code and hum | `404` | Resource not found | Verify the ID exists and belongs to your workspace | | `429` | Rate limit exceeded | Wait for the duration in the `Retry-After` header | +### Unrecognized fields are rejected + +Every v2 endpoint validates the request against its published schema — path parameters, query string, and body — and answers `400` for any field it does not declare. A misspelled parameter is an error rather than a silent no-op, so `?limt=20` fails instead of quietly returning an unbounded list. + +This holds for endpoints that declare no query parameters at all. Do not append tracking tags, cache busters, or other extra parameters to a v2 URL; send only what the endpoint documents. + +```json +{ + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request", + "details": [ + { "code": "unrecognized_keys", "keys": ["limt"], "path": [], "message": "Unrecognized key: \"limt\"" } + ] + } +} +``` + Use [Get Billing Status](/api-reference/billing/getBillingStatus) to inspect current credit and storage usage. diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-diagram.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-diagram.tsx index 2085b046840038034a2aeee43f54a8d72575a948..1eb77d5bafe97d6d030a5c971405f94020039ea0 100644 GIT binary patch delta 19 ZcmbPcKHGeQIUie0sR0mdw&0871pq#l1&{y$ delta 14 VcmbPjKFxfCIUgg#W?Q~EUH~Ix1OEU3 diff --git a/apps/sim/lib/api/server/nul-byte-boundary.test.ts b/apps/sim/lib/api/server/nul-byte-boundary.test.ts index f6967f9bf583e531eadfa7c218e57f640b32f7cc..bd1ec14832e305e312f78641f0ba87d582efa9c6 100644 GIT binary patch delta 524 zcmXw$J#G|15QXJ*15(l_5m;KhKOh1UkT8UZ$j%&WPfv}fw7uP{?iz-1vP3?C4`PXX za0fUEuLpaA)=X8`_g=lZ$Ddb^FV9Y!<#K@~u6lv*U3T6iV~8zJlVTSbvzkeSD{v7~ zygLfAv%cQwS#k)jii9+tW|cZ9ty-#dzIQ1f9Jl6Yp{HnJLb0qzq)Z~4LGR?5S=HBbf=}rj zb7}`qai*g!$JM(q1L7^5ArCAaIP)c)h%<&1ls46BgxD2NDJvV_i{G#QN4GNhVQb@- zozqa})S5v<3++&M^yxsw#`%4PY!;q78ophB#Rr_<4E1N6FP^GRYNyXBT6VwQpZ$LE E4}vwb!vFvP delta 26 hcmaE;+@`QWoRKd%Kd-n%!7tQD!B# unknown + /** - * The parser postgres.js would actually apply to oid 1114 for a client built - * with `options`. Constructing a client does not open a connection, so this - * reads the real resolved configuration without touching a database. + * Builds a postgres.js client the way `db.ts` does and returns the parser it + * resolves for oid 1114. Constructing a client does not open a connection, so + * this reads the real resolved configuration without touching a database. + * + * `wrapInDrizzle` selects whether that is the parser the driver starts with or + * the one it ends up with after `drizzle()` has registered its own. Production + * is always the latter: every client in this repo is handed straight to + * `drizzle()`. */ -function resolveTimestampParser(options: Parameters[1]) { - const client = postgres('postgres://user@localhost:5432/db', options) - return (client.options as { parsers: Record unknown> }).parsers[ - TIMESTAMP_OID - ] +function resolveTimestampParser(wrapInDrizzle: boolean): TimestampParser { + const client = postgres( + 'postgres://user@localhost:5432/db', + withUtcTimestamps({ connection: { application_name: 'test' } }) + ) + if (wrapInDrizzle) drizzle(client, {}) + return (client.options as { parsers: Record }).parsers[TIMESTAMP_OID] } describe('naive timestamp UTC pinning', () => { @@ -52,6 +66,11 @@ describe('naive timestamp UTC pinning', () => { expect(UTC_CONNECTION_PARAMETERS.TimeZone).toBe('UTC') }) + it('keeps the session TimeZone when a caller sets its own connection params', () => { + const merged = withUtcTimestamps({ connection: { application_name: 'sub-pool' } }) + expect(merged.connection).toEqual({ application_name: 'sub-pool', TimeZone: 'UTC' }) + }) + it('reads a naive timestamp as UTC rather than the process zone', () => { const parsed = UTC_TIMESTAMP_TYPES.utcTimestamp.parse(NAIVE_WIRE_VALUE) expect(parsed.toISOString()).toBe(NAIVE_WIRE_INSTANT) @@ -67,15 +86,42 @@ describe('naive timestamp UTC pinning', () => { ) }) - it('is what a client built through withUtcTimestamps actually applies', () => { - const parse = resolveTimestampParser( - withUtcTimestamps({ connection: { application_name: 'test' } }) - ) + it('registers the UTC parser on a bare postgres.js client', () => { + const parse = resolveTimestampParser(false) expect((parse(NAIVE_WIRE_VALUE) as Date).toISOString()).toBe(NAIVE_WIRE_INSTANT) }) - it('keeps the session TimeZone when a caller sets its own connection params', () => { - const merged = withUtcTimestamps({ connection: { application_name: 'sub-pool' } }) - expect(merged.connection).toEqual({ application_name: 'sub-pool', TimeZone: 'UTC' }) + /** + * `drizzle()` installs its own transparent parser over the oids it maps, + * including 1114, so the entry `withUtcTimestamps` registered is replaced the + * moment a client is wrapped. Every client in this repo is wrapped, which + * makes the registration above true but not load-bearing — asserting only the + * registration passes whether or not the parser has any effect. This pins the + * fact the next case depends on, so a drizzle version that stops clobbering + * turns the file red instead of silently changing which layer decides the + * instant. + */ + it('has that parser overwritten by drizzle, so registration alone proves nothing', () => { + const parse = resolveTimestampParser(true) + expect(parse(NAIVE_WIRE_VALUE)).toBe(NAIVE_WIRE_VALUE) + }) + + /** + * What actually carries the read-side guarantee for a drizzle client: + * `PgTimestamp.mapFromDriverValue` appends `+0000` to a naive string, so the + * recovered instant is UTC regardless of which parser won the oid. Both + * branches are asserted together because the composition is the contract — + * the read must not depend on which of the two layers got there first. + */ + it('recovers the same UTC instant through either parser once drizzle maps it', () => { + const throughDrizzleParser = naiveColumn.mapFromDriverValue( + resolveTimestampParser(true)(NAIVE_WIRE_VALUE) + ) + const throughUtcParser = naiveColumn.mapFromDriverValue( + resolveTimestampParser(false)(NAIVE_WIRE_VALUE) + ) + + expect(throughDrizzleParser.toISOString()).toBe(NAIVE_WIRE_INSTANT) + expect(throughUtcParser.toISOString()).toBe(NAIVE_WIRE_INSTANT) }) }) diff --git a/packages/db/timestamps.ts b/packages/db/timestamps.ts index 93b895fdde2..d62ba7de2fb 100644 --- a/packages/db/timestamps.ts +++ b/packages/db/timestamps.ts @@ -67,6 +67,16 @@ export const UTC_CONNECTION_PARAMETERS = { TimeZone: 'UTC' } as const * never selected by postgres.js's type inference (a `Date` infers as 1184), so * the serializer exists only to keep the entry self-consistent for an explicit * `sql.typed` bind. + * + * It does not decide the instant for a drizzle read. `drizzle()` registers its + * own transparent parser over oid 1114 when it wraps a client, replacing this + * entry, and every client in this repo is wrapped — so on those paths a naive + * value arrives at drizzle as the raw wire string and `PgTimestamp`'s mapper + * (`new Date(value + '+0000')`) supplies the UTC reading instead. Both routes + * yield the same instant, which is why the clobbering is harmless rather than a + * defect. The entry is kept because it is the only thing pinning the read for a + * client used as raw postgres.js, and `timestamps.test.ts` asserts the + * composition of both layers rather than the registration alone. */ export const UTC_TIMESTAMP_TYPES = { utcTimestamp: { diff --git a/scripts/check-source-text.ts b/scripts/check-source-text.ts new file mode 100644 index 00000000000..1052604eb32 --- /dev/null +++ b/scripts/check-source-text.ts @@ -0,0 +1,73 @@ +#!/usr/bin/env bun +/** + * Asserts that no tracked source file contains a raw `U+0000`. + * + * Git classifies a file as binary the moment its contents hold a NUL byte, so a + * single stray `U+0000` written as a literal turns the whole file into + * `Bin 0 -> 4102 bytes` in every diff — a reviewer sees not one line of it, and + * `git grep`, formatters, and editors treat it as opaque or silently normalize + * the byte away. `apps/sim/lib/api/server/nul-byte-boundary.test.ts` shipped + * exactly that way, and two older files had done the same unnoticed. + * + * The escape `'\u0000'` produces an identical string at runtime, so this costs + * nothing to satisfy. `.gitattributes` forces source files to diff as text as a + * second layer, which makes a violation visible; this audit is what keeps one + * from landing in the first place. + */ +import { spawnSync } from 'node:child_process' +import path from 'node:path' + +const ROOT = path.resolve(import.meta.dir, '..') + +/** Extensions whose contents are source text a human reads in review. */ +const SOURCE_EXTENSIONS = [ + '*.ts', + '*.tsx', + '*.js', + '*.jsx', + '*.mjs', + '*.cjs', + '*.json', + '*.md', + '*.mdx', + '*.css', + '*.yml', + '*.yaml', + '*.toml', + '*.sql', + '*.sh', +] + +const listed = spawnSync('git', ['ls-files', '-z', '--', ...SOURCE_EXTENSIONS], { + cwd: ROOT, + encoding: 'buffer', + maxBuffer: 256 * 1024 * 1024, +}) + +if (listed.status !== 0) { + console.error(`Source-text audit failed: \`git ls-files\` exited ${listed.status}.`) + process.exit(1) +} + +const files = listed.stdout + .toString('utf8') + .split('\0') + .filter((entry) => entry.length > 0) + +const offenders: string[] = [] +for (const file of files) { + const bytes = await Bun.file(path.join(ROOT, file)).bytes() + if (bytes.includes(0)) offenders.push(file) +} + +if (offenders.length > 0) { + console.error( + `Source-text audit failed: ${offenders.length} tracked source file(s) contain a raw NUL byte,\n` + + 'which makes git treat them as binary and hides their contents from review.\n\n' + + offenders.map((file) => ` ${file}`).join('\n') + + "\n\n Write the character as the escape '\\u0000' instead — the runtime string is identical." + ) + process.exit(1) +} + +console.log(`Source-text audit passed (${files.length} files, no raw NUL bytes).`) From f9c63301d78c62b6804905782b7bff429689513e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 20:55:56 -0700 Subject: [PATCH 30/56] fix(w5): narrow three fixes that reached past the harm they were fixing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workflow-create `23505` handler answered for the whole transaction, which also runs `saveWorkflowToNormalizedTables`. `workflow_blocks.id` is a global primary key, so a block-id collision — an integrity fault already seen in production — surfaced as `A workflow named "X" already exists in this folder`. Match on the constraint name; any other unique violation propagates unchanged. Moving the knowledge dispatch out of the completion transaction was right, but a dispatch failure then committed the session as `completed` and left the document at `pending`, which nothing sweeps and `retryProcessing` refuses. Record the failure on the document instead, so it lands on the existing failed-document path, and describe what the code does rather than a recovery branch that cannot fire for this state. The MCP re-registration reset stopped a registration claiming a connection it never made, but reset for any re-registration. `isServerEligibleForDiscovery` skips an OAuth row that is not `connected`, so a rename removed every tool the server published with no path back. Scope the reset to url, transport, headers, auth type, OAuth credentials, and revival. --- .../application/upload-sessions.test.ts | 86 +++++++++++++++++ .../knowledge/application/upload-sessions.ts | 37 +++++-- .../knowledge/documents/processing-claim.ts | 48 ++++++++++ .../orchestration/server-lifecycle.test.ts | 96 +++++++++++++++++++ .../lib/mcp/orchestration/server-lifecycle.ts | 43 ++++++--- .../orchestration/workflow-lifecycle.test.ts | 90 +++++++++++++++++ .../orchestration/workflow-lifecycle.ts | 22 ++++- 7 files changed, 399 insertions(+), 23 deletions(-) create mode 100644 apps/sim/lib/workflows/orchestration/workflow-lifecycle.test.ts diff --git a/apps/sim/lib/knowledge/application/upload-sessions.test.ts b/apps/sim/lib/knowledge/application/upload-sessions.test.ts index 9479e1919bb..6f870c76254 100644 --- a/apps/sim/lib/knowledge/application/upload-sessions.test.ts +++ b/apps/sim/lib/knowledge/application/upload-sessions.test.ts @@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({ createDocument: vi.fn(), createPartUrls: vi.fn(), createUpload: vi.fn(), + failUndispatched: vi.fn(), findBound: vi.fn(), getUpload: vi.fn(), processQueue: vi.fn(), @@ -48,6 +49,10 @@ vi.mock('@/lib/knowledge/application/contexts', () => ({ resolveActiveKnowledgeBaseContext: mocks.resolveContext, })) +vi.mock('@/lib/knowledge/documents/processing-claim', () => ({ + failUndispatchedDocumentProcessing: mocks.failUndispatched, +})) + vi.mock('@/lib/knowledge/documents/service', () => ({ createSingleDocument: mocks.createDocument, processDocumentsWithQueue: mocks.processQueue, @@ -195,6 +200,7 @@ describe('knowledge-document upload application lifecycle', () => { mocks.findBound.mockResolvedValue({ status: 'absent' }) mocks.createDocument.mockResolvedValue(DOCUMENT) mocks.processQueue.mockResolvedValue(undefined) + mocks.failUndispatched.mockResolvedValue(true) }) it('admits, binds, and records ownership before returning upload credentials', async () => { @@ -438,6 +444,86 @@ describe('knowledge-document upload application lifecycle', () => { expect(mocks.recordAudit).toHaveBeenCalledTimes(1) }) + /** + * A completed session with a `completedFileId` replays into `loadCompleted`, + * which never dispatches, and nothing sweeps `pending`. Leaving the document + * there strands it with no retry and no signal, so the failure is recorded on + * the row — the state `retryProcessing` accepts. + */ + it('marks the document failed when its processing dispatch never got off the ground', async () => { + mocks.processQueue.mockRejectedValue(new Error('queue unavailable')) + mocks.completeUpload.mockImplementation( + async (params: { + session: UploadSessionRecord + finalize: (session: UploadSessionRecord) => Promise<{ + value: { document: typeof DOCUMENT; created: boolean; knowledgeBaseName: string | null } + completedFileId?: string + }> + }) => { + const finalized = await params.finalize(params.session) + return { + session: { ...params.session, status: 'completed' as const }, + value: finalized.value, + alreadyCompleted: false, + } + } + ) + + await completeKnowledgeDocumentUpload.execute({ + principal: PRINCIPAL, + input: { + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-1', + uploadId: 'upload-1', + uploadToken: 'token', + source: 'api', + }, + request: REQUEST, + }) + + expect(mocks.failUndispatched).toHaveBeenCalledWith({ + documentId: DOCUMENT.id, + knowledgeBaseId: 'knowledge-1', + error: 'queue unavailable', + }) + }) + + /** Recording the failure is itself best-effort; it must not resurface as a 500. */ + it('still completes when the dispatch failure cannot be recorded', async () => { + mocks.processQueue.mockRejectedValue(new Error('queue unavailable')) + mocks.failUndispatched.mockRejectedValue(new Error('database unavailable')) + mocks.completeUpload.mockImplementation( + async (params: { + session: UploadSessionRecord + finalize: (session: UploadSessionRecord) => Promise<{ + value: { document: typeof DOCUMENT; created: boolean; knowledgeBaseName: string | null } + completedFileId?: string + }> + }) => { + const finalized = await params.finalize(params.session) + return { + session: { ...params.session, status: 'completed' as const }, + value: finalized.value, + alreadyCompleted: false, + } + } + ) + + const result = await completeKnowledgeDocumentUpload.execute({ + principal: PRINCIPAL, + input: { + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-1', + uploadId: 'upload-1', + uploadToken: 'token', + source: 'api', + }, + request: REQUEST, + }) + + expect(result.value.document).toEqual(DOCUMENT) + }) + /** * The dispatch is a follow-on to the completion, not a step inside it: a * completion that cannot write its durable marker must not have queued diff --git a/apps/sim/lib/knowledge/application/upload-sessions.ts b/apps/sim/lib/knowledge/application/upload-sessions.ts index 29226a442f8..5db83c64df7 100644 --- a/apps/sim/lib/knowledge/application/upload-sessions.ts +++ b/apps/sim/lib/knowledge/application/upload-sessions.ts @@ -2,6 +2,7 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import type { Principal } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { truncate } from '@sim/utils/string' import { checkAttributedUsageLimits } from '@/lib/billing/core/billing-attribution' import { authorizeWorkspaceOperation, type WorkspaceOperation } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -18,6 +19,7 @@ import { resolveActiveKnowledgeBaseContext, } from '@/lib/knowledge/application/contexts' import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { failUndispatchedDocumentProcessing } from '@/lib/knowledge/documents/processing-claim' import { createSingleDocument, type DocumentData, @@ -46,6 +48,9 @@ const logger = createLogger('KnowledgeUploadSessions') const PROCESSING_DISPATCH_FAILURE_MESSAGE = 'Knowledge document processing dispatch failed' +/** Keeps a driver or provider message from filling the document row's error column. */ +const DISPATCH_FAILURE_MESSAGE_MAX_LENGTH = 500 + export class KnowledgeDocumentUnsupportedMediaTypeError extends Error { constructor(message: string) { super(message) @@ -390,7 +395,7 @@ interface PendingProcessingDispatch { /** * Queues indexing for a document the completion has already made durable. * - * It runs after `completeUploadSession` resolves, and a failure is logged + * It runs after `completeUploadSession` resolves, and a failure is recorded * rather than raised, because by that point the caller's request has already * succeeded: the object is stored, the document row exists, and the session is * marked completed. Raising here used to fail the completion `POST` with a 500 @@ -398,11 +403,14 @@ interface PendingProcessingDispatch { * the same request — answered `200 completed`, so the 500 described nothing the * caller could act on. * - * The dispatch outcome is not lost by being swallowed. `processDocumentsWithQueue` - * marks the document `failed` with its error when processing itself breaks, and - * a document that was never picked up stays `pending`; both are visible on the - * document the completion returns and on every subsequent read of it. A - * `pending` document is re-queued by the finalization-recovery path above. + * The dispatch outcome is not lost by going unraised. `processDocumentsWithQueue` + * marks the document `failed` with its error when processing itself breaks. When + * the dispatch never got off the ground the document would instead be left at + * `pending`, which nothing sweeps and which `retryProcessing` refuses, so + * {@link failUndispatchedDocumentProcessing} records the failure on the row. + * Either way the error is visible on every subsequent read of the document, and + * the document can be re-queued through + * `PATCH /api/knowledge/{id}/documents/{documentId}` with `retryProcessing`. */ async function queueKnowledgeDocumentProcessing( dispatch: PendingProcessingDispatch, @@ -424,12 +432,27 @@ async function queueKnowledgeDocumentProcessing( dispatch.billingAttribution ) } catch (error) { + const failureMessage = getErrorMessage(error, 'Document processing dispatch failed') logger.error(PROCESSING_DISPATCH_FAILURE_MESSAGE, { requestId, documentId: dispatch.document.id, knowledgeBaseId: dispatch.knowledgeBaseId, - error: getErrorMessage(error), + error: failureMessage, }) + try { + await failUndispatchedDocumentProcessing({ + documentId: dispatch.document.id, + knowledgeBaseId: dispatch.knowledgeBaseId, + error: truncate(failureMessage, DISPATCH_FAILURE_MESSAGE_MAX_LENGTH), + }) + } catch (markError) { + logger.error('Failed to record a knowledge document dispatch failure', { + requestId, + documentId: dispatch.document.id, + knowledgeBaseId: dispatch.knowledgeBaseId, + error: getErrorMessage(markError), + }) + } } } diff --git a/apps/sim/lib/knowledge/documents/processing-claim.ts b/apps/sim/lib/knowledge/documents/processing-claim.ts index bfb4a2208c4..3a720bb9328 100644 --- a/apps/sim/lib/knowledge/documents/processing-claim.ts +++ b/apps/sim/lib/knowledge/documents/processing-claim.ts @@ -94,3 +94,51 @@ export async function failStaleDocumentProcessingClaim({ return { success: Boolean(failed), processingDuration } } + +interface FailUndispatchedDocumentProcessingParams { + documentId: string + knowledgeBaseId: string + error: string + now?: Date +} + +/** + * Marks a document whose indexing dispatch never got off the ground as `failed`. + * + * A document registered by a completed upload sits at `pending` until a worker + * claims it. Nothing sweeps `pending`, and `retryProcessing` only accepts a + * `failed` document, so a document left there after a failed dispatch is + * invisible and unrecoverable. Recording the failure puts it on the same path + * as any other processing failure: visible in the document list with its error, + * and re-queueable. + * + * Guarded on `pending` so it cannot overwrite a document a worker has already + * claimed — the dispatch may have been accepted and only its acknowledgement + * lost. A worker that starts late still moves the row to `processing` + * unconditionally, so this write never strands a job that does run. + */ +export async function failUndispatchedDocumentProcessing({ + documentId, + knowledgeBaseId, + error, + now = new Date(), +}: FailUndispatchedDocumentProcessingParams): Promise { + const [failed] = await db + .update(document) + .set({ + processingStatus: 'failed', + processingError: error, + processingCompletedAt: now, + }) + .where( + and( + eq(document.id, documentId), + eq(document.knowledgeBaseId, knowledgeBaseId), + eq(document.processingStatus, 'pending'), + isNull(document.deletedAt) + ) + ) + .returning({ id: document.id }) + + return Boolean(failed) +} diff --git a/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts b/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts index e83df3dd14d..f89751d23db 100644 --- a/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts +++ b/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts @@ -285,6 +285,8 @@ describe('MCP server lifecycle orchestration', () => { id: 'server-1', deletedAt: null, url: 'https://example.com/mcp', + transport: 'streamable-http', + headers: { authorization: 'Bearer original' }, authType: 'headers', oauthClientId: null, oauthClientSecret: null, @@ -319,6 +321,100 @@ describe('MCP server lifecycle orchestration', () => { ) }) + /** + * `isServerEligibleForDiscovery` skips an OAuth row that is not `connected`, + * and only a real discovery can set `connected`. Clearing the status for an + * edit that changes nothing a connection is made from therefore removes every + * tool the server publishes, with no path back. + */ + it('keeps an OAuth server connected through a re-registration that only renames it', async () => { + mockGenerateMcpServerId.mockReturnValue('server-1') + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'server-1', + deletedAt: null, + url: 'https://example.com/mcp', + transport: 'streamable-http', + headers: {}, + authType: 'oauth', + oauthClientId: 'client-1', + oauthClientSecret: 'secret-1', + }, + ]) + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'server-1', + workspaceId: 'workspace-1', + name: 'Renamed', + transport: 'streamable-http', + url: 'https://example.com/mcp', + authType: 'oauth', + }, + ]) + + const result = await performCreateMcpServer({ + workspaceId: 'workspace-1', + userId: 'user-1', + name: 'Renamed', + description: 'Now with a description', + url: 'https://example.com/mcp', + }) + + expect(result.success).toBe(true) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Renamed', description: 'Now with a description' }) + ) + expect(dbChainMockFns.set).not.toHaveBeenCalledWith( + expect.objectContaining({ connectionStatus: 'disconnected' }) + ) + expect(result.updatedFields).not.toContain('connectionStatus') + // A rename invalidates nothing, so the stored OAuth grant must survive it too. + expect(mockRevokeOauthTokens).not.toHaveBeenCalled() + }) + + it('resets a re-registered server whose transport changes', async () => { + mockGenerateMcpServerId.mockReturnValue('server-1') + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'server-1', + deletedAt: null, + url: 'https://example.com/mcp', + transport: 'streamable-http', + headers: {}, + authType: 'oauth', + oauthClientId: 'client-1', + oauthClientSecret: 'secret-1', + }, + ]) + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'server-1', + workspaceId: 'workspace-1', + name: 'Example', + transport: 'sse', + url: 'https://example.com/mcp', + authType: 'oauth', + }, + ]) + + const result = await performCreateMcpServer({ + workspaceId: 'workspace-1', + userId: 'user-1', + name: 'Example', + url: 'https://example.com/mcp', + transport: 'sse', + }) + + expect(result.success).toBe(true) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + connectionStatus: 'disconnected', + lastConnected: null, + lastError: null, + }) + ) + }) + it('audits a re-registration that rewrites a live server as an update', async () => { mockGenerateMcpServerId.mockReturnValue('server-1') dbChainMockFns.limit.mockResolvedValueOnce([ diff --git a/apps/sim/lib/mcp/orchestration/server-lifecycle.ts b/apps/sim/lib/mcp/orchestration/server-lifecycle.ts index 3330b6efc72..ca9ecde3730 100644 --- a/apps/sim/lib/mcp/orchestration/server-lifecycle.ts +++ b/apps/sim/lib/mcp/orchestration/server-lifecycle.ts @@ -4,6 +4,7 @@ import { mcpServerOauth } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' +import { isEqual } from 'es-toolkit' import type { NextRequest } from 'next/server' import { encryptSecret } from '@/lib/core/security/encryption' import { sanitizeUrlForLog } from '@/lib/core/utils/logging' @@ -157,6 +158,8 @@ export async function createMcpServer( id: mcpServers.id, deletedAt: mcpServers.deletedAt, url: mcpServers.url, + transport: mcpServers.transport, + headers: mcpServers.headers, authType: mcpServers.authType, oauthClientId: mcpServers.oauthClientId, oauthClientSecret: mcpServers.oauthClientSecret, @@ -207,6 +210,19 @@ export async function createMcpServer( // Turning OAuth off orphans its tokens; revoke and delete them, mirroring the update path. const oauthDisabled = existingServer.authType === 'oauth' && resolvedAuthType !== 'oauth' const shouldClearOauth = urlChanged || credsChanged || isRevival || oauthDisabled + /** + * Everything a connection is established from. `name`, `description`, + * `timeout`, `retries`, and `enabled` are deliberately absent: none of + * them changes what the server answers to a discovery, so rewriting one + * must not invalidate a status a real discovery earned. + */ + const connectionInputsChanged = + isRevival || + urlChanged || + credsChanged || + existingServer.transport !== transport || + (existingServer.authType ?? 'headers') !== resolvedAuthType || + !isEqual(existingServer.headers ?? {}, params.headers || {}) if (shouldClearOauth) await revokeMcpOauthTokens(serverId, params.workspaceId) @@ -229,20 +245,25 @@ export async function createMcpServer( deletedAt: null, } /** - * A re-registration rewrites the URL, headers, transport, and timeouts — - * i.e. every input to a connection — so whatever the previous discovery - * established no longer describes this configuration. It resets rather - * than branching on auth type: the former `else` branch stamped - * `connected` plus a fresh `lastConnected` for any non-OAuth - * re-registration without contacting the endpoint, which published a - * successful connection that never happened and, because it left - * `lastError` alone, could publish `connected` beside a stale error. + * A re-registration must never stamp `connected` itself: the former + * `else` branch published a fresh `lastConnected` for any non-OAuth + * re-registration without contacting the endpoint, and left `lastError` + * alone, so `connected` could sit beside a stale error. * `mcpService.updateServerStatus` is the only writer entitled to claim a * connection, and it does so after a real discovery. + * + * Resetting is scoped to the inputs a connection is actually made from. + * A re-registration also rewrites `name` and `description`, and clearing + * the status for those strands an OAuth server: `isServerEligibleForDiscovery` + * skips an OAuth row that is not `connected`, so the only writer that can + * restore the status is gated on the status just cleared, and a rename + * silently removes every tool the server publishes. */ - updateValues.connectionStatus = 'disconnected' - updateValues.lastConnected = null - updateValues.lastError = null + if (connectionInputsChanged) { + updateValues.connectionStatus = 'disconnected' + updateValues.lastConnected = null + updateValues.lastError = null + } if (params.oauthClientIdProvided) updateValues.oauthClientId = oauthClientId if (params.oauthClientSecretProvided) { updateValues.oauthClientSecret = oauthClientSecretEncrypted diff --git a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.test.ts b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.test.ts new file mode 100644 index 00000000000..e4acadb6f3e --- /dev/null +++ b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.test.ts @@ -0,0 +1,90 @@ +/** + * @vitest-environment node + */ +import { + auditMock, + dbChainMockFns, + posthogServerMock, + resetDbChainMock, + workflowAuthzMockFns, + workflowsPersistenceUtilsMock, + workflowsPersistenceUtilsMockFns, + workflowsUtilsMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@sim/audit', () => auditMock) +vi.mock('@/lib/posthog/server', () => posthogServerMock) +vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock) +vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) +vi.mock('@/lib/workflows/lifecycle', () => ({ + archiveWorkflow: vi.fn(), + restoreWorkflow: vi.fn(), +})) +vi.mock('@/lib/workflows/defaults', () => ({ + buildDefaultWorkflowArtifacts: () => ({ + workflowState: { blocks: {}, edges: [], loops: {}, parallels: {} }, + subBlockValues: {}, + startBlockId: 'start-1', + }), +})) + +import { performCreateWorkflowTransition } from '@/lib/workflows/orchestration/workflow-lifecycle' + +/** Shape the `postgres` driver throws for a unique violation. */ +const uniqueViolation = (constraintName: string) => + Object.assign(new Error('duplicate key value violates unique constraint'), { + code: '23505', + constraint_name: constraintName, + }) + +const createParams = { + userId: 'user-1', + workspaceId: 'workspace-1', + name: 'My Workflow', +} + +describe('performCreateWorkflowTransition unique-violation handling', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + workflowAuthzMockFns.mockIsFolderInWorkspace.mockResolvedValue(true) + workflowsPersistenceUtilsMockFns.mockSaveWorkflowToNormalizedTables.mockResolvedValue({ + success: true, + }) + }) + + it('reports a lost name race as a conflict', async () => { + dbChainMockFns.transaction.mockRejectedValueOnce( + uniqueViolation('workflow_workspace_folder_name_active_unique') + ) + + const result = await performCreateWorkflowTransition(createParams) + + expect(result).toEqual({ + success: false, + error: 'A workflow named "My Workflow" already exists in this folder', + errorCode: 'conflict', + }) + }) + + it('does not report a block-id collision as a name conflict', async () => { + /** + * `workflow_blocks.id` is a global primary key and the same transaction runs + * `saveWorkflowToNormalizedTables`, so a colliding block id raises `23505` + * from a constraint that has nothing to do with the workflow name. Relabelling + * it hides an integrity fault behind a message about a duplicate name. + */ + const collision = uniqueViolation('workflow_blocks_pkey') + dbChainMockFns.transaction.mockRejectedValueOnce(collision) + + await expect(performCreateWorkflowTransition(createParams)).rejects.toBe(collision) + }) + + it('propagates a unique violation that carries no constraint name', async () => { + const opaque = Object.assign(new Error('duplicate key value'), { code: '23505' }) + dbChainMockFns.transaction.mockRejectedValueOnce(opaque) + + await expect(performCreateWorkflowTransition(createParams)).rejects.toBe(opaque) + }) +}) diff --git a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts index 14829656be1..0bb8ab307f1 100644 --- a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts @@ -3,7 +3,7 @@ import { db } from '@sim/db' import { folder as folderTable, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { isFolderInWorkspace } from '@sim/platform-authz/workflow' -import { getPostgresErrorCode, toError } from '@sim/utils/errors' +import { getPostgresConstraintName, getPostgresErrorCode, toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, isNull, min, ne } from 'drizzle-orm' import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' @@ -17,6 +17,9 @@ import { deduplicateWorkflowName } from '@/lib/workflows/utils' const logger = createLogger('WorkflowLifecycle') +/** Partial unique index on `(workspace_id, coalesce(folder_id, ''), name) WHERE archived_at IS NULL`. */ +const WORKFLOW_NAME_UNIQUE_INDEX = 'workflow_workspace_folder_name_active_unique' + export interface PerformCreateWorkflowParams { userId: string workspaceId: string @@ -282,11 +285,20 @@ export async function performCreateWorkflowTransition( /** * The name pre-check above is a `SELECT`, so two concurrent creates of the same * name both pass it and the loser is rejected by - * `workflow_workspace_folder_name_active_unique` as a raw Postgres `23505`. - * Reported as the conflict the pre-check already raises, so a caller sees one - * answer whether it lost the race or simply arrived second. + * {@link WORKFLOW_NAME_UNIQUE_INDEX} as a raw Postgres `23505`. Reported as the + * conflict the pre-check already raises, so a caller sees one answer whether it + * lost the race or simply arrived second. + * + * Matched on the constraint name, not on the code alone. This transaction also + * runs `saveWorkflowToNormalizedTables`, whose inserts can raise `23505` from + * `workflow_blocks_pkey` — a globally unique block id colliding across + * workflows, an integrity fault this repository has already hit in production. + * A code-only match reported that as a name conflict and hid it. */ - if (getPostgresErrorCode(error) === '23505') { + if ( + getPostgresErrorCode(error) === '23505' && + getPostgresConstraintName(error) === WORKFLOW_NAME_UNIQUE_INDEX + ) { return { success: false, error: `A workflow named "${name}" already exists in this folder`, From acc720777931c8c18e0887b9a9ceea350566e86a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 21:01:34 -0700 Subject: [PATCH 31/56] fix(tables): confine the write-policy tightening to what the caller sent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The null-policy work made `reject` the default for caller-supplied writes, which is right, but it landed on the wrong values. - A partial update coerces the MERGED row, so an untouched legacy cell failed an unrelated column's update — and failed a paged bulk job after its earlier pages had committed. The merged-row callers now name the patch's keys; every other key follows the `null` policy, in the in-memory copy only (the write sends the patched keys alone). - A multiselect whose members do not all resolve returned `{ok:false}`, which on the machine paths that pass `'null'` — CSV import, computed writes, the cell-write snapshot — erased the whole cell. Those paths now consult a new `salvage` hook and keep the members that do resolve; a caller-supplied write still 400s on an unknown option. - Refusing a bare number in `date.coerce` reached the executor, v1, copilot and the grid. The refusal stays where there is a caller to tell, and `salvage` restores the milliseconds reading where the only other answer is a blank cell. Also: the cursor docblocks claimed pure-keyset cursors were left unbound while the code and its tests bind them; a saved-view create took the table's SCHEMA advisory lock, so it queued behind column rewrites whose statement timeouts run past its 3s lock_timeout, and now takes a views-scoped lock instead; and a view whose column was deleted could not be saved at all, because the Save chip always resends the filter — references the stored config already carries are now exempt while a newly introduced one is still refused. The cursor version is deliberately not bumped: the stamp is additive, unfiltered in-flight tokens keep working, and a filtered one fails with the accurate "restart paging without the cursor" rather than a generic unreadable-cursor 400. --- .../__tests__/column-type-registry.test.ts | 50 +++++++ .../lib/table/__tests__/update-row.test.ts | 18 +++ apps/sim/lib/table/column-types/date.ts | 19 ++- apps/sim/lib/table/column-types/select.ts | 12 ++ apps/sim/lib/table/column-types/types.ts | 15 ++ apps/sim/lib/table/rows/cursor.test.ts | 28 ++++ apps/sim/lib/table/rows/cursor.ts | 39 +++-- apps/sim/lib/table/rows/service.ts | 12 +- apps/sim/lib/table/update-runner.ts | 7 +- apps/sim/lib/table/validation.test.ts | 56 +++++++- apps/sim/lib/table/validation.ts | 60 ++++++-- apps/sim/lib/table/views/service.test.ts | 57 ++++++++ apps/sim/lib/table/views/service.ts | 133 ++++++++++++++---- 13 files changed, 448 insertions(+), 58 deletions(-) diff --git a/apps/sim/lib/table/__tests__/column-type-registry.test.ts b/apps/sim/lib/table/__tests__/column-type-registry.test.ts index fb72c9748f2..70e5e206a01 100644 --- a/apps/sim/lib/table/__tests__/column-type-registry.test.ts +++ b/apps/sim/lib/table/__tests__/column-type-registry.test.ts @@ -210,3 +210,53 @@ describe('metadata ownership', () => { } ) }) + +/** + * `salvage` is the escape hatch for the write paths that have no caller to + * answer: a computed cell, a CSV import row, the cell-write snapshot. There the + * alternative to a lossy reading is a blanked cell, so the registry may read + * looser than `coerce` — but only there, and only in that direction. + */ +describe('salvage — the machine-path reading', () => { + it('reads a bare epoch number as milliseconds for a date column', () => { + const column: ColumnDefinition = { name: 'd', type: 'date' } + expect(COLUMN_TYPE_REGISTRY.date.coerce(1700000000000 as never, column).ok).toBe(false) + expect(COLUMN_TYPE_REGISTRY.date.salvage?.(1700000000000 as never, column)).toEqual({ + ok: true, + value: '2023-11-14T22:13:20.000Z', + }) + }) + + it('refuses an out-of-range epoch rather than throwing on toISOString', () => { + const column: ColumnDefinition = { name: 'd', type: 'date' } + expect(COLUMN_TYPE_REGISTRY.date.salvage?.(1e20 as never, column)).toEqual({ ok: false }) + }) + + it('keeps the resolvable members of a multiselect and drops the rest', () => { + const column: ColumnDefinition = { + id: 'col_tags', + name: 'tags', + type: 'select', + multiple: true, + options: [ + { id: 'opt_a', name: 'Alpha' }, + { id: 'opt_b', name: 'Beta' }, + ], + } + expect(COLUMN_TYPE_REGISTRY.select.coerce(['Alpha', 'ghost'], column).ok).toBe(false) + expect(COLUMN_TYPE_REGISTRY.select.salvage?.(['Alpha', 'ghost'], column)).toEqual({ + ok: true, + value: ['opt_a'], + }) + }) + + it('has nothing partial to keep for a single select', () => { + const column: ColumnDefinition = { + id: 'col_status', + name: 'status', + type: 'select', + options: [{ id: 'opt_open', name: 'Open' }], + } + expect(COLUMN_TYPE_REGISTRY.select.salvage?.('ghost', column)).toEqual({ ok: false }) + }) +}) diff --git a/apps/sim/lib/table/__tests__/update-row.test.ts b/apps/sim/lib/table/__tests__/update-row.test.ts index c9cf83348b8..57a820f122f 100644 --- a/apps/sim/lib/table/__tests__/update-row.test.ts +++ b/apps/sim/lib/table/__tests__/update-row.test.ts @@ -138,6 +138,24 @@ describe('updateRow — partial merge', () => { expect(data?.values).not.toContain(JSON.stringify({ name: 'Alice', age: 31 })) }) + it('holds only the patched keys to the strict policy when validating the merge', async () => { + // The merged row carries cells this request never sent. A legacy value in one + // of them belongs to an earlier write and must not decide this one. + const { coerceRowToSchema } = await import('@/lib/table/validation') + await updateRow( + { tableId: 'tbl-1', rowId: 'row-1', data: { age: 31 }, workspaceId: 'ws-1' }, + TABLE, + 'req-1' + ) + + expect(coerceRowToSchema).toHaveBeenCalledWith( + { name: 'Alice', age: 31 }, + TABLE.schema, + 'reject', + ['age'] + ) + }) + it('allows updating a single column without affecting others', async () => { const result = await updateRow( { tableId: 'tbl-1', rowId: 'row-1', data: { name: 'Bob' }, workspaceId: 'ws-1' }, diff --git a/apps/sim/lib/table/column-types/date.ts b/apps/sim/lib/table/column-types/date.ts index c78768f4f38..60f3c0d5a2c 100644 --- a/apps/sim/lib/table/column-types/date.ts +++ b/apps/sim/lib/table/column-types/date.ts @@ -26,15 +26,17 @@ export const dateColumnType: ColumnTypeDefinition = { const normalized = normalizeDateCellValue(value) return normalized === null ? { ok: false } : { ok: true, value: normalized } } - // A bare number is refused, in every direction. It is the one input whose - // meaning cannot be recovered from the value itself: `1600000000` is + // A bare number is refused wherever there is a caller to tell. It is the + // one input whose meaning cannot be recovered from the value itself: `1600000000` is // September 2020 read as Unix seconds and 19 January 1970 read as // milliseconds, both readings are in range, and nothing on the wire says // which was meant. Milliseconds used to win, so a seconds-based epoch — // the far more common shape — stored a timestamp 50 years early under a // 200. An ISO-8601 string carries its own unit; that is what a date cell // takes. This also removes the reason the bulk retype gate had to be - // stricter than the write path, so it no longer overrides. + // stricter than the write path, so it no longer overrides. `salvage` keeps + // the old milliseconds reading for the machine paths, where the only other + // answer is a blank cell. // // A Date instance may still be out of the representable range (>±8.64e15ms), // so `toISOString()` is guarded — it throws RangeError on an Invalid Date — @@ -46,6 +48,17 @@ export const dateColumnType: ColumnTypeDefinition = { return { ok: false } }, + salvage(value) { + // Milliseconds — the reading every caller got before — restored only where + // refusing would blank the cell rather than answer anyone. A machine + // emitting an epoch for a date column is overwhelmingly producing + // `Date.now()` or another JS timestamp, both milliseconds; guessing wrong + // there costs a wrong year, guessing not at all costs the value. + if (typeof value !== 'number') return { ok: false } + const date = new Date(value) + return Number.isNaN(date.getTime()) ? { ok: false } : { ok: true, value: date.toISOString() } + }, + validateCell(value, column) { const valid = value instanceof Date || (typeof value === 'string' && !Number.isNaN(Date.parse(value))) diff --git a/apps/sim/lib/table/column-types/select.ts b/apps/sim/lib/table/column-types/select.ts index 5f4d92bfc3b..c282d681638 100644 --- a/apps/sim/lib/table/column-types/select.ts +++ b/apps/sim/lib/table/column-types/select.ts @@ -67,6 +67,18 @@ export const selectColumnType: ColumnTypeDefinition = { return resolved === null ? { ok: false } : { ok: true, value: resolved } }, + salvage(value, column) { + // Where the write cannot fail, a multi cell keeps the members that DO + // resolve rather than being blanked: `Alpha, opt_b, ghost` from a CSV or a + // block output stores `[opt_a, opt_b]`, which is what it stored before the + // write path started refusing partial matches. Dropping one unmatched name + // is a smaller loss than erasing the two that matched. A single cell holds + // one option and has nothing partial to keep, so it stays blanked. + if (!column.multiple) return { ok: false } + const resolved = resolveSelectCellValue(value, column) + return resolved === null ? { ok: false } : { ok: true, value: resolved } + }, + validateCell(value, column) { const ids = optionIds(column) if (column.multiple) { diff --git a/apps/sim/lib/table/column-types/types.ts b/apps/sim/lib/table/column-types/types.ts index 0de148ac1d0..f481cea0a28 100644 --- a/apps/sim/lib/table/column-types/types.ts +++ b/apps/sim/lib/table/column-types/types.ts @@ -184,6 +184,21 @@ export interface ColumnTypeDefinition { */ isCompatibleWith?(value: unknown, target: ColumnDefinition): boolean + /** + * Last-resort reading of a value {@link coerce} refused, consulted **only** + * where the write may not fail: a machine-produced value on a path with no + * caller to answer with a 400 — a computed/enrichment cell, a CSV import row, + * the cell-write snapshot. The alternative there is not an error, it is a + * blanked cell, so a lossy-but-faithful reading beats losing the value + * outright. + * + * Omitted by types where nothing is salvageable. Because it never runs on a + * caller-supplied write it may be looser than `coerce` without weakening what + * the API refuses — the opposite direction from {@link isCompatibleWith}, + * which may only ever be stricter. + */ + salvage?(value: JsonValue, column: ColumnDefinition): CoerceResult + /** Stored value → display text (grid cell, CSV, clipboard, width measurement). */ formatForDisplay(value: unknown, column: ColumnDefinition): string diff --git a/apps/sim/lib/table/rows/cursor.test.ts b/apps/sim/lib/table/rows/cursor.test.ts index 3429fb3e39f..967fef7a6b5 100644 --- a/apps/sim/lib/table/rows/cursor.test.ts +++ b/apps/sim/lib/table/rows/cursor.test.ts @@ -192,3 +192,31 @@ describe('cursor↔filter binding', () => { expect(canonicalFilterKey({ filter: {} })).toBeUndefined() }) }) + +/** + * The filter stamp is additive, and the payload version is deliberately not + * bumped for it (see `CURSOR_VERSION`). These pin what a token minted by the + * previous deploy does when it is replayed after this one. + */ +describe('tokens minted before the filter stamp', () => { + function legacyToken(payload: Record): string { + return Buffer.from(JSON.stringify({ ...payload, v: 1 })).toString('base64url') + } + + it('still decodes, and still resumes an unfiltered read', () => { + const decoded = decodeCursor(legacyToken({ k: 'a1', i: 'row_1' })) + expect(decoded.after).toEqual({ orderKey: 'a1', id: 'row_1' }) + expect(decoded.filterKey).toBeUndefined() + expect(() => assertCursorQueryBinding(decoded, {})).not.toThrow() + }) + + it('fails a filtered read with the filter conflict, not an unreadable cursor', () => { + const decoded = decodeCursor(legacyToken({ o: 100 })) + expect(() => assertCursorQueryBinding(decoded, { predicate: ACTIVE })).toThrow( + TableQueryValidationError + ) + expect(() => assertCursorQueryBinding(decoded, { predicate: ACTIVE })).toThrow( + /Restart paging without the cursor/ + ) + }) +}) diff --git a/apps/sim/lib/table/rows/cursor.ts b/apps/sim/lib/table/rows/cursor.ts index 3688a04c1b7..fea2890cefa 100644 --- a/apps/sim/lib/table/rows/cursor.ts +++ b/apps/sim/lib/table/rows/cursor.ts @@ -14,9 +14,10 @@ * This only resolves correctly because the seek admits `order_key IS NULL` * rows; a bare `(order_key, id) > (…)` excludes them and strands the tail. * - * Any shape carrying an offset is stamped with the query state that offset - * counts positions within — the sort AND the filters — and refuses to resume - * under a different one. See {@link assertCursorQueryBinding}. + * Every shape is stamped with the filters its page was produced under, and any + * shape carrying an offset is additionally stamped with the sort that offset + * counts positions within. A token refuses to resume under a different one. See + * {@link assertCursorQueryBinding}. */ import { canonicalJson, fingerprint } from '@/lib/api/cursor-binding' @@ -27,6 +28,15 @@ import type { Filter, Sort, TablePredicate, TableRow, TableRowsCursor } from '@/ * Cursor payload version. Every encoded token carries `v`; decode rejects any * other value so a future shape change (new `v`) fails cleanly instead of being * misread against the current field set. + * + * Deliberately NOT bumped for the filter stamp. Adding `p` is additive: a token + * minted before it still decodes, and an unfiltered read — where the stamp is + * absent on both sides — resumes normally across the deploy. A pre-stamp token + * replayed against a filtered query is the only one that fails, and it fails + * with `CURSOR_FILTER_CONFLICT` and "Restart paging without the cursor", which + * is both accurate and actionable. Bumping the version would trade that for a + * generic unreadable-cursor 400 on EVERY in-flight token, including the + * unfiltered ones that would otherwise have kept working. */ const CURSOR_VERSION = 1 @@ -35,10 +45,10 @@ type QueryBinding = { s?: string; p?: string } type CursorPayload = CursorBody & QueryBinding & { v: number } /** - * The filters an offset counts positions within. A cursor carrying an offset is - * bound to both this and the sort; a pure keyset cursor is bound to neither, - * because `(order_key, id)` names an absolute position that stays correct under - * any membership change. + * The query state a page was produced under. Every cursor shape is bound to the + * filters — a keyset position is absolute in `(order_key, id)` but not + * complete, so replaying it under different filters returns a page of the wrong + * sequence. Only a cursor carrying an offset is additionally bound to the sort. */ export interface CursorQueryScope { sort?: Sort | null @@ -82,13 +92,14 @@ export function canonicalFilterKey( * sequence — rows skipped or duplicated with no error. Throws * `CURSOR_SORT_CONFLICT` so callers restart paging without the cursor. * - * Any offset — the whole-view one and the compound cursor's offset-from-anchor - * alike — counts rows in the FILTERED sequence, so it is bound to the filters as - * well. Replaying an offset under a different predicate lands at that ordinal of - * a sequence the caller never asked for: a narrower filter silently returns an - * empty page the caller reads as "no more matches". That mismatch throws - * `CURSOR_FILTER_CONFLICT`. A pure keyset cursor carries no offset and is left - * unbound — `(order_key, id)` is an absolute position, correct under any filter. + * The filter binding applies to EVERY shape, not only the ones carrying an + * offset. An offset counts rows in the FILTERED sequence, so replaying it under + * a different predicate lands at that ordinal of a sequence the caller never + * asked for: a narrower filter silently returns an empty page the caller reads + * as "no more matches". A pure keyset cursor names an absolute position in + * `(order_key, id)`, but absolute is not complete — resumed under a wider + * filter it silently omits every newly matching row that sorts before it. Both + * mismatches throw `CURSOR_FILTER_CONFLICT`. */ export function assertCursorQueryBinding( decoded: { after?: TableRowsCursor; offset?: number; sortKey?: string; filterKey?: string }, diff --git a/apps/sim/lib/table/rows/service.ts b/apps/sim/lib/table/rows/service.ts index 939b8c2b553..c0b446c44ec 100644 --- a/apps/sim/lib/table/rows/service.ts +++ b/apps/sim/lib/table/rows/service.ts @@ -1567,6 +1567,10 @@ export interface UpdateRowOptions { * and failing the write would strand the whole cell run over one output that * does not fit its bound column. It blanks that cell instead. Every other write * carries a value someone asked to store, so an uncoercible one is refused. + * + * The policy governs the patch's own keys. A merged row also carries cells this + * request never touched; those always follow `null`, whatever the policy — + * see `PatchedKeys` in `@/lib/table/validation`. */ function uncoercibleValuePolicy(options: { computedWrite?: boolean }): UncoercibleValuePolicy { return options.computedWrite ? 'null' : 'reject' @@ -1633,7 +1637,8 @@ export async function updateRow( const schemaValidation = coerceRowToSchema( mergedData, table.schema, - uncoercibleValuePolicy(options) + uncoercibleValuePolicy(options), + Object.keys(data.data) ) if (!schemaValidation.valid) { throw new OrchestrationError( @@ -1824,7 +1829,7 @@ function bulkUpdateValidationError( const sizeValidation = validateRowSize(mergedData) if (!sizeValidation.valid) return sizeValidation.errors.join(', ') - const schemaValidation = coerceRowToSchema(mergedData, table.schema) + const schemaValidation = coerceRowToSchema(mergedData, table.schema, 'reject', Object.keys(patch)) return schemaValidation.valid ? null : schemaValidation.errors.join(', ') } @@ -2258,7 +2263,8 @@ export async function batchUpdateRows( const schemaValidation = coerceRowToSchema( merged, table.schema, - uncoercibleValuePolicy(options) + uncoercibleValuePolicy(options), + Object.keys(update.data) ) if (!schemaValidation.valid) { throw new OrchestrationError( diff --git a/apps/sim/lib/table/update-runner.ts b/apps/sim/lib/table/update-runner.ts index b3ed3380002..84cff0857a3 100644 --- a/apps/sim/lib/table/update-runner.ts +++ b/apps/sim/lib/table/update-runner.ts @@ -162,14 +162,17 @@ export async function runTableUpdate(payload: TableUpdatePayload): Promise afterId = page[page.length - 1].id // Validate each merged result before writing the page — a row that would overflow the size - // cap or violate the schema fails the job (earlier pages stay applied; best-effort). + // cap or violate the schema fails the job (earlier pages stay applied; best-effort). Only + // the patch's own keys are held to the strict policy: a legacy cell this job never touches + // must not fail it halfway through, after the earlier pages have committed. + const patchedKeys = Object.keys(data) for (const row of page) { const merged = { ...row.data, ...data } const sizeValidation = validateRowSize(merged) if (!sizeValidation.valid) { throw new Error(`Row ${row.id}: ${sizeValidation.errors.join(', ')}`) } - const schemaValidation = coerceRowToSchema(merged, table.schema) + const schemaValidation = coerceRowToSchema(merged, table.schema, 'reject', patchedKeys) if (!schemaValidation.valid) { throw new Error(`Row ${row.id}: ${schemaValidation.errors.join(', ')}`) } diff --git a/apps/sim/lib/table/validation.test.ts b/apps/sim/lib/table/validation.test.ts index 96b001468df..9b4cc3012c1 100644 --- a/apps/sim/lib/table/validation.test.ts +++ b/apps/sim/lib/table/validation.test.ts @@ -136,11 +136,18 @@ describe('coerceRowToSchema — multiselect', () => { expect(data.col_tags).not.toEqual([]) }) - it('drops unmatched entries under the `null` policy', () => { + it('keeps the entries that resolve under the `null` policy', () => { const data: RowData = { col_tags: ['Alpha', 'opt_b', 'ghost'] } const result = coerceRowToSchema(data, schemaWith(multiselectColumn), 'null') expect(result.valid).toBe(true) - expect(data.col_tags).toBeNull() + expect(data.col_tags).toEqual(['opt_a', 'opt_b']) + }) + + it('nulls the cell under the `null` policy only when nothing resolves', () => { + const data: RowData = { col_tags: ['ghost'] } + const result = coerceRowToSchema(data, schemaWith(multiselectColumn), 'null') + expect(result.valid).toBe(true) + expect(data.col_tags).toEqual([]) }) it('wraps a single string into a one-element array', () => { @@ -211,6 +218,51 @@ describe('coerceRowToSchema — uncoercible values are refused, not silently nul const data: RowData = { col_d: '2020-09-13T12:26:40Z' } expect(coerceRowToSchema(data, schemaWith(dateColumn)).valid).toBe(true) }) + + it('reads a bare epoch number as milliseconds under the `null` policy', () => { + const data: RowData = { col_d: 1600000000000 } + const result = coerceRowToSchema(data, schemaWith(dateColumn), 'null') + expect(result.valid).toBe(true) + expect(data.col_d).toBe('2020-09-13T12:26:40.000Z') + }) + + it('still nulls an out-of-range epoch number under the `null` policy', () => { + const data: RowData = { col_d: 1e20 } + const result = coerceRowToSchema(data, schemaWith(dateColumn), 'null') + expect(result.valid).toBe(true) + expect(data.col_d).toBeNull() + }) +}) + +/** + * A partial update coerces the caller's patch and then validates the MERGED + * row, so the merged pass sees cells this write never touched. Those are + * storage, not caller input — a legacy cell that no longer fits its column must + * not fail an update of a different column, and must not be persisted either + * (the write only sends the patched keys). + */ +describe('coerceRowToSchema — merged row', () => { + const numberColumn: ColumnDefinition = { id: 'col_n', name: 'n', type: 'number' } + const stringColumn: ColumnDefinition = { id: 'col_s', name: 's', type: 'string' } + const schema = schemaWith(numberColumn, stringColumn) + + it('does not fail an update over an untouched cell that no longer coerces', () => { + const merged: RowData = { col_n: 'legacy', col_s: 'new' } + const result = coerceRowToSchema(merged, schema, 'reject', ['col_s']) + expect(result.valid).toBe(true) + }) + + it('still refuses the same value when this write is the one supplying it', () => { + const merged: RowData = { col_n: 'legacy', col_s: 'new' } + const result = coerceRowToSchema(merged, schema, 'reject', ['col_n', 'col_s']) + expect(result.valid).toBe(false) + expect(merged.col_n).toBe('legacy') + }) + + it('treats every key as caller-supplied when no patch key set is given', () => { + const merged: RowData = { col_n: 'legacy', col_s: 'new' } + expect(coerceRowToSchema(merged, schema, 'reject').valid).toBe(false) + }) }) describe('resolveSelectOptionId', () => { diff --git a/apps/sim/lib/table/validation.ts b/apps/sim/lib/table/validation.ts index d63497ec569..e8298f1e41b 100644 --- a/apps/sim/lib/table/validation.ts +++ b/apps/sim/lib/table/validation.ts @@ -290,16 +290,44 @@ function coerceValueToColumnType(value: JsonValue, column: ColumnDefinition): Co * in a 100k-row file must not fail the file. Nothing there has a caller to * return a 400 to. * - * A `required` column is never blanked under either policy — a null would fail - * the required check immediately after. + * Under `null` a value the column type can still read lossily is kept rather + * than blanked — see `ColumnTypeDefinition.salvage`, which is why a CSV cell + * naming two live options and one deleted one imports as the two rather than as + * an empty cell. A `required` column is never blanked under either policy: a + * null would fail the required check immediately after. */ export type UncoercibleValuePolicy = 'reject' | 'null' +/** + * The keys of `data` this write's caller actually supplied, for when `data` is a + * MERGED row (stored cells overlaid with a patch) rather than the patch alone. + * Keys outside the set are pre-existing storage, so they fall back to the `null` + * policy whatever the caller's policy is: a legacy cell that no longer fits its + * column was written by an earlier request, and failing this one over it refuses + * an unrelated column's update — and, on a paged bulk job, refuses it after the + * earlier pages have already committed. The blanking stays in the in-memory + * copy; every merged-row caller persists only the patched keys. + * + * Omit it when every key in `data` is caller-supplied — a whole-row insert, or a + * patch validated on its own. + */ +export type PatchedKeys = ReadonlySet | readonly string[] + +function policyResolver( + policy: UncoercibleValuePolicy, + patchedKeys: PatchedKeys | undefined +): (key: string) => UncoercibleValuePolicy { + if (patchedKeys === undefined) return () => policy + const patched = patchedKeys instanceof Set ? patchedKeys : new Set(patchedKeys) + return (key) => (patched.has(key) ? policy : 'null') +} + /** * Coerces each present value in `data` toward its column's declared type **in * place**. Values that already match are untouched; unambiguous conversions * (e.g. `"1999"` → `1999`) are applied; values that cannot be coerced are - * handled per {@link UncoercibleValuePolicy}. + * handled per {@link UncoercibleValuePolicy}, narrowed per key by + * {@link PatchedKeys}. * * Operates per-present-column, so it is safe on a partial patch (columns absent * from `data` are skipped — it never invents a missing-required-field error). @@ -307,8 +335,10 @@ export type UncoercibleValuePolicy = 'reject' | 'null' export function coerceRowValues( data: RowData, schema: TableSchema, - policy: UncoercibleValuePolicy = 'reject' + policy: UncoercibleValuePolicy = 'reject', + patchedKeys?: PatchedKeys ): void { + const policyFor = policyResolver(policy, patchedKeys) for (const column of schema.columns) { const key = getColumnId(column) const value = data[key] @@ -317,7 +347,14 @@ export function coerceRowValues( const coerced = coerceValueToColumnType(value, column) if (coerced.ok) { data[key] = coerced.value - } else if (policy === 'null' && !column.required) { + continue + } + if (policyFor(key) !== 'null') continue + + const salvaged = columnTypeOf(column).salvage?.(value, column) + if (salvaged?.ok) { + data[key] = salvaged.value + } else if (!column.required) { data[key] = null } } @@ -329,16 +366,19 @@ export function coerceRowValues( * * This is the write-path entry point — callers that persist a complete row use * it instead of {@link validateRowAgainstSchema} so the coercion and the check - * that follows it can never disagree about what a cell holds. Callers persisting - * only a partial patch should use {@link coerceRowValues} on the patch and - * validate the merged row separately. + * that follows it can never disagree about what a cell holds. + * + * A caller validating a MERGED row — stored cells overlaid with a patch — passes + * the patch's keys as {@link PatchedKeys} so the strict policy applies to what + * this request sent and not to what was already there. */ export function coerceRowToSchema( data: RowData, schema: TableSchema, - policy: UncoercibleValuePolicy = 'reject' + policy: UncoercibleValuePolicy = 'reject', + patchedKeys?: PatchedKeys ): ValidationResult { - coerceRowValues(data, schema, policy) + coerceRowValues(data, schema, policy, patchedKeys) return validateRowAgainstSchema(data, schema) } diff --git a/apps/sim/lib/table/views/service.test.ts b/apps/sim/lib/table/views/service.test.ts index fd0c21d3c01..ca6c1267b8a 100644 --- a/apps/sim/lib/table/views/service.test.ts +++ b/apps/sim/lib/table/views/service.test.ts @@ -301,6 +301,27 @@ describe('saved-view ceiling', () => { expect(mockSignalTableViewsChanged).not.toHaveBeenCalled() }) + it('serializes on the views lock rather than the table schema lock', async () => { + queueTableRows(tableViews, [{ total: 0 }]) + dbChainMockFns.returning.mockResolvedValueOnce([ + { + id: 'view-100', + tableId: 'table-1', + workspaceId: 'ws-1', + name: 'Another View', + config: {}, + isDefault: false, + createdBy: 'user-1', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + }, + ]) + + await create() + + expect(mockWithLockedTable).not.toHaveBeenCalled() + }) + it('allows the create that lands exactly on the ceiling', async () => { queueTableRows(tableViews, [{ total: TABLE_LIMITS.MAX_VIEWS_PER_TABLE - 1 }]) dbChainMockFns.returning.mockResolvedValueOnce([ @@ -447,6 +468,42 @@ describe('view config column-reference normalization', () => { ).rejects.toMatchObject({ name: 'TableViewValidationError' }) }) + /** + * A column delete leaves the referencing views behind, and `pruneViewConfig` + * deliberately does not prune a filter. The write must therefore let the + * already-stored reference through — otherwise the first save of anything else + * on that view (a sort change, a hidden-column change, the Save chip's whole + * config) 400s on a condition the user did not touch. + */ + it('lets a save carry forward a stale filter reference the view already stored', async () => { + const stale = { all: [{ field: 'col_gone', op: 'eq' as const, value: 'x' }] } + queueTableRows(tableViews, [{ ...storedRow, config: { filter: stale } }]) + dbChainMockFns.returning.mockResolvedValueOnce([storedRow]) + + await expect( + updateTableView({ + viewId: 'view-1', + tableId: 'table-1', + config: { filter: stale, sort: [{ field: 'col_a', direction: 'asc' }] }, + columns, + }) + ).resolves.not.toBeNull() + }) + + it('still refuses a NEW unknown reference on a view that already had a stale one', async () => { + const stale = { all: [{ field: 'col_gone', op: 'eq' as const, value: 'x' }] } + queueTableRows(tableViews, [{ ...storedRow, config: { filter: stale } }]) + + await expect( + updateTableView({ + viewId: 'view-1', + tableId: 'table-1', + config: { filter: { all: [{ field: 'col_other_ghost', op: 'eq', value: 'x' }] } }, + columns, + }) + ).rejects.toMatchObject({ name: 'TableViewValidationError' }) + }) + it('keeps a sort on a system row column, which is sortable but not in schema.columns', () => { expect( pruneViewConfig({ sort: [{ field: 'createdAt', direction: 'desc' }] }, columns).sort diff --git a/apps/sim/lib/table/views/service.ts b/apps/sim/lib/table/views/service.ts index ae17f361115..7ff0068492d 100644 --- a/apps/sim/lib/table/views/service.ts +++ b/apps/sim/lib/table/views/service.ts @@ -22,13 +22,14 @@ import { import { NAME_PATTERN, TABLE_LIMITS } from '@/lib/table/constants' import { TableQueryValidationError } from '@/lib/table/errors' import { signalTableViewsChanged } from '@/lib/table/events' +import type { DbTransaction } from '@/lib/table/planner' import { filterRulesToPredicate, filterToRules } from '@/lib/table/query-builder/converters' import { SYSTEM_COLUMN_FIELDS, validateStoragePredicate, validateStorageSortSpec, } from '@/lib/table/query-builder/validate' -import { withLockedTable } from '@/lib/table/service' +import { setTableTxTimeouts } from '@/lib/table/tx' import type { ColumnDefinition, Filter, @@ -99,6 +100,48 @@ export function pruneViewConfig( return pruned } +/** + * Every column reference the row-selecting half of a stored config holds — each + * `filter` leaf field and each `sort` field. Feeds the carried-forward exemption + * in {@link normalizeViewConfigForStorage}, so it lists refs whether or not they + * still resolve. + */ +function configColumnRefs(config: TableViewConfig): string[] { + const refs: string[] = [] + for (const { field } of config.sort ?? []) refs.push(field) + const visit = (node: PredicateNode): void => { + if (!node || typeof node !== 'object') return + if ('all' in node || 'any' in node) { + const members = 'all' in node ? node.all : node.any + if (Array.isArray(members)) for (const child of members) visit(child) + return + } + if ('field' in node && typeof node.field === 'string') refs.push(node.field) + } + if (config.filter) visit(config.filter) + return refs +} + +/** + * `columns` plus a placeholder for each exempt reference that no longer resolves, + * so the shared query validators accept it without being taught about views. The + * placeholders exist for the length of one validation call and are never stored. + */ +function tolerantColumns( + columns: ColumnDefinition[], + carriedForward: readonly string[] +): ColumnDefinition[] { + if (carriedForward.length === 0) return columns + const live = new Set(columns.map(getColumnId)) + const extra: ColumnDefinition[] = [] + for (const ref of carriedForward) { + if (live.has(ref)) continue + live.add(ref) + extra.push({ id: ref, name: ref, type: 'string' }) + } + return extra.length > 0 ? [...columns, ...extra] : columns +} + /** * Canonicalizes a caller-supplied config for storage: every column reference is * rewritten to the column's stable **id**, then the row-selecting parts are @@ -115,15 +158,25 @@ export function pruneViewConfig( * can never load. Column LAYOUT is deliberately not validated — it auto-saves as * the user drags, and racing a concurrent column delete must self-heal through * {@link pruneViewConfig}, not fail the drag. + * + * `carriedForward` names the references the STORED config already holds, and + * they are exempt. Deleting a column leaves every view that filtered on it + * dangling — `pruneViewConfig` deliberately does not prune a filter — so without + * the exemption the view becomes unwritable: the Save chip sends the whole + * `{filter, sort, hiddenColumns}` slice, and a user changing the sort would be + * refused over a condition they did not touch, with no way to save the removal + * of anything else first. A reference the caller INTRODUCES is still refused. */ export function normalizeViewConfigForStorage( config: TableViewConfig, - columns: ColumnDefinition[] + columns: ColumnDefinition[], + carriedForward: readonly string[] = [] ): TableViewConfig { const stored = remapViewConfigColumnRefs(config, buildColumnIdByName(columns)) + const known = tolerantColumns(columns, carriedForward) try { - if (stored.filter) validateStoragePredicate(stored.filter, columns) - if (stored.sort) validateStorageSortSpec(stored.sort, columns) + if (stored.filter) validateStoragePredicate(stored.filter, known) + if (stored.sort) validateStorageSortSpec(stored.sort, known) } catch (error) { if (error instanceof TableQueryValidationError) { throw new TableViewValidationError(error.message) @@ -265,6 +318,26 @@ function normalizeName(name: string): string { return trimmed } +/** + * Serializes the saved-view writers for one table on a transaction-scoped + * advisory lock of their own, so a count-then-insert cannot be raced. Keyed + * `user_table_views:`, deliberately distinct from the + * `user_table_schema:` key the column/row mutators hold: presentation + * state must not queue behind a schema rewrite. + */ +async function withTableViewsLock( + tableId: string, + write: (trx: DbTransaction) => Promise +): Promise { + return db.transaction(async (trx) => { + await setTableTxTimeouts(trx) + await trx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`user_table_views:${tableId}`}, 0))` + ) + return write(trx) + }) +} + export interface CreateTableViewData { tableId: string workspaceId: string @@ -279,16 +352,21 @@ export interface CreateTableViewData { * {@link TABLE_LIMITS.MAX_VIEWS_PER_TABLE}. * * The list read returns every view in one unpaginated page, so that promise only - * holds if the write side enforces it. The count and the insert share the - * table's advisory lock — the same device the column mutators use — which is - * what makes the count authoritative against a concurrent create rather than a - * check two racing writers can both pass. + * holds if the write side enforces it. The count and the insert share an advisory + * lock, which is what makes the count authoritative against a concurrent create + * rather than a check two racing writers can both pass. + * + * The lock is keyed to this table's VIEWS, not to its schema. A view is + * presentation state and contends only with another view create; taking the + * schema lock would queue it behind a column rewrite or a bulk row job, whose + * statement timeouts run far past this transaction's 3s `lock_timeout`, so + * creating a view would fail for the duration of an unrelated long mutation. */ export async function createTableView(data: CreateTableViewData): Promise { const name = normalizeName(data.name) const config = normalizeViewConfigForStorage(data.config, data.columns) - const row = await withLockedTable(data.tableId, async (_table, trx) => { + const row = await withTableViewsLock(data.tableId, async (trx) => { const [existing] = await trx .select({ total: count() }) .from(tableViews) @@ -343,23 +421,12 @@ export interface UpdateTableViewData { * `configPatch` merges in the database (`||`) rather than client-side, so two * overlapping partial writes — a column resize landing while a pin is in flight — * can't each replace the whole blob from their own stale snapshot. + * + * The config is normalized inside the transaction, against the stored row, so + * the references that row already carries stay writable — see + * {@link normalizeViewConfigForStorage}. */ export async function updateTableView(data: UpdateTableViewData): Promise { - const config = - data.config === undefined ? undefined : normalizeViewConfigForStorage(data.config, data.columns) - const configPatch = - data.configPatch === undefined - ? undefined - : normalizeViewConfigForStorage(data.configPatch, data.columns) - - const patch: Partial = { updatedAt: new Date() } - if (data.name !== undefined) patch.name = normalizeName(data.name) - if (config !== undefined) patch.config = config - if (configPatch !== undefined) { - patch.config = sql`${tableViews.config} || ${JSON.stringify(configPatch)}::jsonb` - } - if (data.isDefault !== undefined) patch.isDefault = data.isDefault - const outcome = await db.transaction(async (tx) => { // Confirm the target exists BEFORE demoting. The demotion has to run first — // the partial unique index rejects a second default — but on a PATCH naming a @@ -378,6 +445,24 @@ export async function updateTableView(data: UpdateTableViewData): Promise = { updatedAt: new Date() } + if (data.name !== undefined) patch.name = normalizeName(data.name) + if (config !== undefined) patch.config = config + if (configPatch !== undefined) { + patch.config = sql`${tableViews.config} || ${JSON.stringify(configPatch)}::jsonb` + } + if (data.isDefault !== undefined) patch.isDefault = data.isDefault + const nextName = data.name === undefined ? existing.name : normalizeName(data.name) const storedConfig = (existing.config ?? {}) as TableViewConfig const nextConfig = config ?? (configPatch ? { ...storedConfig, ...configPatch } : storedConfig) From 75357db59cad95c7c62bd4145f4ae0777f1b775c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 21:07:14 -0700 Subject: [PATCH 32/56] test(db): narrow the mapped timestamp to Date mapFromDriverValue is typed unknown, so the composition assertions did not type-check outside the test's own runner. --- packages/db/timestamps.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/db/timestamps.test.ts b/packages/db/timestamps.test.ts index fc19f3c674a..3c779138ed6 100644 --- a/packages/db/timestamps.test.ts +++ b/packages/db/timestamps.test.ts @@ -116,10 +116,10 @@ describe('naive timestamp UTC pinning', () => { it('recovers the same UTC instant through either parser once drizzle maps it', () => { const throughDrizzleParser = naiveColumn.mapFromDriverValue( resolveTimestampParser(true)(NAIVE_WIRE_VALUE) - ) + ) as Date const throughUtcParser = naiveColumn.mapFromDriverValue( resolveTimestampParser(false)(NAIVE_WIRE_VALUE) - ) + ) as Date expect(throughDrizzleParser.toISOString()).toBe(NAIVE_WIRE_INSTANT) expect(throughUtcParser.toISOString()).toBe(NAIVE_WIRE_INSTANT) From 9f9aca8a57b878cd0dc0241b092252ee27eaf6ee Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 21:26:57 -0700 Subject: [PATCH 33/56] fix(v2): correct four stale contracts and clear the merge debris behind them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five of the reported defects were real and four of them were documentation that had stopped describing its own code. `cleanCellValue` said only "coerce a raw input value"; it also answers `null` for anything the column type refuses, and since the multiselect write path started refusing partial matches that is the difference between a paste storing one option and blanking the cell. It deliberately does not consult `salvage`, which would read the same paste as the option that did resolve — that reading is for writes with no caller to answer, and a typed cell has one. The pairing is now asserted, so a future helper that "improves" the paste by salvaging it fails. `EXECUTE_OPTION_CONSTRAINTS` carried two stacked TSDoc blocks, the second explaining that the enumeration had moved onto the fields; the body schema still told a reader the six combinations were enumerated in the constant. The deployment route's second block orphaned the endpoint documentation above it, and `list-query.ts` kept the TSDoc for a cursor message that now lives, with its own rewritten doc, in `cursor-binding.ts`. Two agents left near-identical essays arguing the same 400-vs-403-vs-409 question about the table ceilings and concluding that neither status changes; the decision is recorded once, in `billing.ts`, and `service.ts` points at it. The credentials use case echoed `sortBy`/`sortOrder` back with a TSDoc explaining that the presenter needs them, which it no longer does — it reads `query.*`. The local upload roots move from the data-plane provider to `core/storage-key.ts`, beside the sidecar suffix, so the cleanup sweep can name what it reclaims without importing the transport that writes it. `documents.test.ts` justified sweeping only knowledge and files for the 413 by saying the same sweep over the other five documents still reported gaps. It does not: widened to all seven, every body-carrying operation publishes it. Three reports did not survive checking, and the evidence is recorded where the next reader will look. An empty rerank result is not the reranker matching nothing — `rerank` asks for `top_n` over a non-empty document list, so an empty array means the response carried nothing usable, which is what `unavailable` already promises. The zero-byte knowledge document is refused on the upload-session path too, by `validateFile`, under both boundary contracts; that parity is now pinned, and it fails if the guard is removed. The MCP re-registration reports exactly the connection fields its SET clause writes, and the create mutation already drops both caches — what lags is the status badge, not the tools, because discovery is gated on `connected` for OAuth rows only. --- .../api/v2/workflows/[id]/deployment/route.ts | 6 +-- .../tables/[tableId]/utils.test.ts | 27 ++++++++++++++ .../[workspaceId]/tables/[tableId]/utils.ts | 14 ++++++- apps/sim/hooks/queries/mcp.ts | 8 ++++ .../lib/api/contracts/v2/openapi/shared.ts | 8 ++++ apps/sim/lib/api/contracts/v2/workflows.ts | 22 +++++------ apps/sim/lib/api/list-query.ts | 11 ------ .../application/list-workspace-credentials.ts | 10 +---- .../lib/knowledge/application/search.test.ts | 16 ++++++++ apps/sim/lib/knowledge/application/search.ts | 14 ++++++- apps/sim/lib/table/billing.ts | 16 +++----- apps/sim/lib/table/service.ts | 14 ++----- apps/sim/lib/uploads/core/storage-key.ts | 12 ++++++ .../sim/lib/uploads/upload-session/cleanup.ts | 2 +- .../lib/uploads/upload-session/provider.ts | 16 +++----- .../uploads/upload-session/service.test.ts | 37 +++++++++++++++++++ scripts/openapi/documents.test.ts | 15 ++++---- 17 files changed, 168 insertions(+), 80 deletions(-) diff --git a/apps/sim/app/api/v2/workflows/[id]/deployment/route.ts b/apps/sim/app/api/v2/workflows/[id]/deployment/route.ts index 1019dc6dd77..abcdd465b7d 100644 --- a/apps/sim/app/api/v2/workflows/[id]/deployment/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/deployment/route.ts @@ -21,11 +21,9 @@ export const revalidate = 0 * fallback: it retains the timestamp of a deployment that has since been * undeployed, so reading it would report a deploy time alongside * `isDeployed: false`. - */ -/** - * Deliberately head-safe despite issuing a write. * - * Reading a workflow can trigger a migrate-on-read `workflow_blocks` update when + * Deliberately head-safe despite issuing a write. Reading a workflow can trigger + * a migrate-on-read `workflow_blocks` update when * `applyBlockMigrations` upgrades a stored block. That write is convergent: it is * conditional on a migration actually applying, idempotent, and would be issued by * the next ordinary read regardless, so a `HEAD` only brings it forward. diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.test.ts index f6805133548..ee3aabb867e 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' +import { columnTypeOf } from '@/lib/table/column-types' import { cleanCellValue, dateValueToLocalParts, @@ -160,6 +161,32 @@ describe('cleanCellValue', () => { expect(cleanCellValue('Nope', column)).toBeNull() expect(cleanCellValue('Bug, Nope', column)).toBeNull() }) + + /** + * The refusal above is `coerce`'s, not the last word the registry has on the + * value: `salvage` reads the same paste as the one option that resolved. That + * reading is reserved for writes with no caller to answer — a CSV row, a block + * output — and a typed cell has one, so this helper must not reach for it. The + * pairing is asserted rather than described so a future helper that "improves" + * the paste by salvaging it fails here. + */ + it('refuses a partial multiselect paste the registry could still salvage', () => { + const column = { + name: 'tags', + type: 'select', + multiple: true, + options: [ + { id: 'opt_a', name: 'Bug' }, + { id: 'opt_b', name: 'Docs' }, + ], + } as const + + expect(columnTypeOf(column).salvage?.('Bug, Nope', column)).toEqual({ + ok: true, + value: ['opt_a'], + }) + expect(cleanCellValue('Bug, Nope', column)).toBeNull() + }) }) describe('formatValueForInput', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts index d4c3fc5b3ce..bce92873a42 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts @@ -19,8 +19,18 @@ export function generateColumnName(columns: ReadonlyArray<{ name: string }>): st } /** - * Coerce a raw input value to the appropriate type for a column. - * Throws on invalid JSON. + * Coerce a value a person typed or pasted into a cell to that column's type. + * Throws on invalid JSON, and answers `null` for everything else the column + * type refuses. + * + * `null` is what the server would store for the same value, which is the point: + * the optimistic cache and the row that comes back agree. It deliberately does + * not consult `ColumnTypeDefinition.salvage`, which reads a refused value + * lossily — a multiselect paste naming one option that no longer exists blanks + * the cell here rather than storing the members that did resolve. Salvage is + * reserved for writes with no caller to answer, and this one has one: a person + * watching the cell, who is better served seeing the paste refused than seeing + * part of it silently kept. */ export function cleanCellValue( value: unknown, diff --git a/apps/sim/hooks/queries/mcp.ts b/apps/sim/hooks/queries/mcp.ts index b1b78100a08..ae4868e632d 100644 --- a/apps/sim/hooks/queries/mcp.ts +++ b/apps/sim/hooks/queries/mcp.ts @@ -317,6 +317,14 @@ export function useCreateMcpServer() { authType, } }, + /** + * Both caches are dropped, so neither waits out its stale time — but the + * refetched row still reads `disconnected`, because the discovery that + * moves it runs on the tools query this same invalidation kicks off, after + * the list has already come back. The status catches up on the next list + * refetch; the tools do not wait for it, since + * {@link isServerEligibleForDiscovery} gates only OAuth rows on `connected`. + */ onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: mcpKeys.serversList(variables.workspaceId) }) queryClient.invalidateQueries({ diff --git a/apps/sim/lib/api/contracts/v2/openapi/shared.ts b/apps/sim/lib/api/contracts/v2/openapi/shared.ts index 34e9d24f157..c4a1a08f664 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/shared.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/shared.ts @@ -257,6 +257,14 @@ export const WORKSPACE_API_KEY_DENIED = * {@link WORKSPACE_API_KEY_DENIED} for an operation behind the resource-concealment * error policy, which rewrites the authorization failure to a not-found response so * the caller learns nothing about the resource. + * + * Published on no operation today: every one audited so far refuses a workspace + * key through its principal-kind list, which raises an error the concealment + * policy does not rewrite, so all of them say 403. Kept because a concealed + * operation that denies the key through the policy itself would need this exact + * wording, and because `scripts/openapi/documents.test.ts` asserts the file-share + * description does not carry it — inlining the string there would let the guard + * and the wording it guards drift apart. */ export const WORKSPACE_API_KEY_DENIED_AS_NOT_FOUND = 'A workspace API key is rejected as `404` rather than `403`, because unauthorized resources are concealed; use a personal API key.' diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index a959101b30d..7127591b4e0 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -816,18 +816,15 @@ export const v2ExecutionErrorSchema = z export type V2ExecutionError = z.output /** - * The mutually-exclusive execute option matrix, mirrored from the route's - * post-parse checks in `app/api/v2/workflows/[id]/execute/route.ts`. Kept as one + * That the execute options constrain each other, said once. Kept as one * exported string so the request-body description and the OpenAPI operation * description cannot drift from each other. - */ -/** - * The six rejected option combinations used to be enumerated here and pasted - * onto both the operation and the request-body description, restating what each - * field already says. A caller reads the constraint where it applies — on the - * field it constrains — so the enumeration lives on `async`, `stream`, - * `executionTimeoutSeconds`, `includeThinking`, and `includeToolCalls`, and the - * operation says only that the options are mutually constrained. + * + * It deliberately does not enumerate the combinations the route rejects. That + * list used to be pasted onto both the operation and the request-body + * description, restating what each field already says; a caller reads a + * constraint where it applies, so it lives on `async`, `stream`, + * `executionTimeoutSeconds`, `includeThinking`, and `includeToolCalls`. */ export const EXECUTE_OPTION_CONSTRAINTS = 'Each option carries the modes it requires and the modes that reject it; a violated combination is a 400.' @@ -838,8 +835,9 @@ export const EXECUTE_OPTION_CONSTRAINTS = * (triggerType, draft state, deployment pinning) are NEVER wire fields; they * are typed options on the execution service. * - * The six rejected option combinations are enumerated in - * {@link EXECUTE_OPTION_CONSTRAINTS} and enforced by the route after parsing. + * The rejected option combinations are enforced by the route after parsing and + * described on the fields they constrain; {@link EXECUTE_OPTION_CONSTRAINTS} + * only tells a caller that the options constrain each other. */ export const v2ExecuteWorkflowBodySchema = z .object({ diff --git a/apps/sim/lib/api/list-query.ts b/apps/sim/lib/api/list-query.ts index ba9978107b5..e45560a78fc 100644 --- a/apps/sim/lib/api/list-query.ts +++ b/apps/sim/lib/api/list-query.ts @@ -57,17 +57,6 @@ export type CursorKey = string | number export const INVALID_CURSOR_MESSAGE = 'cursor does not match the requested sortBy/sortOrder. Restart pagination without a cursor after changing the sort.' -/** - * Caller-facing message for a cursor that cannot be read back at all. - * - * Separate from {@link INVALID_CURSOR_MESSAGE} because that one names - * `sortBy`/`sortOrder`, and the lists that mint a wrapped domain token accept - * neither param — `GET /logs` carries its direction in `order`, and - * `GET /billing/logs` takes no sort param whatsoever. Sending those callers to - * inspect a knob their operation does not have is the same wrong-signpost - * problem `UNKNOWN_CURSOR_MESSAGE` was written to avoid on the ledger. The - * actionable half — restart without a cursor — is identical. - */ /** * One column of a keyset ordering, with the codec that moves its value through * the opaque cursor. diff --git a/apps/sim/lib/credentials/application/list-workspace-credentials.ts b/apps/sim/lib/credentials/application/list-workspace-credentials.ts index 643c24a2074..28658304025 100644 --- a/apps/sim/lib/credentials/application/list-workspace-credentials.ts +++ b/apps/sim/lib/credentials/application/list-workspace-credentials.ts @@ -25,12 +25,6 @@ export interface ListWorkspaceCredentialsInput { export interface ListWorkspaceCredentialsResult { credentials: VisibleWorkspaceCredential[] nextCursorKeys: CursorKey[] | null - /** - * Echoed back because the route's presenter receives only this result, and the - * cursor it hands out has to be stamped with the sort that produced it. - */ - sortBy: ListWorkspaceCredentialsInput['sortBy'] - sortOrder: ListSortOrder } export const listWorkspaceCredentials = defineAuthorizedWorkspaceUseCase({ @@ -57,7 +51,7 @@ export const listWorkspaceCredentials = defineAuthorizedWorkspaceUseCase({ limit: input.limit, cursorKeys: input.cursorKeys, }) - return { credentials: page.data, nextCursorKeys: page.nextCursorKeys, ...sort } + return { credentials: page.data, nextCursorKeys: page.nextCursorKeys } } const workspaceAccess = await checkWorkspaceAccess(context.workspaceId, principal.userId) @@ -82,6 +76,6 @@ export const listWorkspaceCredentials = defineAuthorizedWorkspaceUseCase({ limit: input.limit, cursorKeys: input.cursorKeys, }) - return { credentials: page.data, nextCursorKeys: page.nextCursorKeys, ...sort } + return { credentials: page.data, nextCursorKeys: page.nextCursorKeys } }, }) diff --git a/apps/sim/lib/knowledge/application/search.test.ts b/apps/sim/lib/knowledge/application/search.test.ts index d7e5571c10e..bd8990759c1 100644 --- a/apps/sim/lib/knowledge/application/search.test.ts +++ b/apps/sim/lib/knowledge/application/search.test.ts @@ -352,6 +352,22 @@ describe('knowledge search application use case', () => { expect(result.results[0]).not.toHaveProperty('rerankerScore') }) + /** + * A resolved call with an empty ordering leaves the caller in the same place a + * thrown one does — vector order, no `rerankerScore` — so it reports the same + * status. It is not "the reranker matched nothing": `rerank` sends a non-empty + * document list and asks for `top_n` of it, so an empty array means the + * response carried nothing usable rather than a legitimate empty ranking. + */ + it('reports unavailable when the call resolves without a usable ordering', async () => { + mocks.rerank.mockResolvedValueOnce({ results: [], isBYOK: false }) + + const result = await rerankedSearch(true) + + expect(result.rerankerStatus).toBe('unavailable') + expect(result.results[0]).not.toHaveProperty('rerankerScore') + }) + it('reports skipped for a tag-only search, which has no query to rank against', async () => { mocks.getTagDefinitions.mockResolvedValue([ { tagSlot: 'tag1', displayName: 'team', fieldType: 'text' }, diff --git a/apps/sim/lib/knowledge/application/search.ts b/apps/sim/lib/knowledge/application/search.ts index 3b4e9d7328a..310447d060b 100644 --- a/apps/sim/lib/knowledge/application/search.ts +++ b/apps/sim/lib/knowledge/application/search.ts @@ -328,8 +328,18 @@ export const searchKnowledge = defineAuthorizedKnowledgeUseCase({ * never completes, so only the success path has to move it. A request with * nothing to rank — no query text, or no candidate rows — is `skipped` rather * than `unavailable`: the reranker was never the obstacle. Anything else that - * was asked for and did not run is `unavailable`, including a request that - * reaches here with no model, which no HTTP contract can now produce. + * was asked for and did not produce a usable ordering is `unavailable`, + * including a request that reaches here with no model, which no HTTP contract + * can now produce. + * + * A call that returns without raising but hands back an empty ordering counts + * as `unavailable` too, and it is not the reranker "matching nothing": + * `rerank` asks for `top_n` over a non-empty document list, so a provider that + * ranked them returns one entry per document. Empty means the response carried + * nothing usable — no results, or only indices outside the batch, which + * `rerank` drops. The caller is left in vector order with no `rerankerScore`, + * which is exactly what `unavailable` promises, and retrying is exactly the + * right advice. */ let rerankerStatus: RerankerStatus = !input.rerankerEnabled ? 'not_requested' diff --git a/apps/sim/lib/table/billing.ts b/apps/sim/lib/table/billing.ts index c76e32197a3..ba931ad0c93 100644 --- a/apps/sim/lib/table/billing.ts +++ b/apps/sim/lib/table/billing.ts @@ -189,16 +189,12 @@ function cacheLimits(workspaceId: string, limits: TablePlanLimits): void { * `row limit` token for a substring match to find it, which made the wording * load-bearing. * - * The 400 disagrees with the sibling ceiling on how many tables a workspace may - * hold, which answers 403 with `error.details.code` - * `WORKSPACE_RESOURCE_LIMIT_REACHED`. Two ceilings of the same kind reporting as - * different statuses is a real inconsistency, and 409 is arguably the right - * answer for both: the request is well formed and the caller is authorized, and - * the conflict is with the collection's current state, which the caller can - * clear. It is not changed here because this error is reachable from the - * internal surface as well, where the 400 is shipped and not behind the v2 - * flag — unifying the two is a deliberate cross-surface change, not part of a - * v2-only pass. + * The canonical record of the two table ceilings disagreeing on status: this one + * answers 400 and the workspace table ceiling answers 403 + * (`WORKSPACE_RESOURCE_LIMIT_REACHED`), where 409 arguably fits both. Both are + * left as shipped — this error is also reachable from the internal surface, + * which is not behind the v2 flag, so unifying them is a deliberate + * cross-surface change rather than part of a v2-only pass. */ export class TableRowLimitError extends OrchestrationError { constructor(readonly limit: number) { diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index b971addd78a..f094bd390b4 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -474,17 +474,9 @@ function workspaceTableLimitReached(maxTables: number): ForbiddenOperationError * A quota ceiling, not bad input — both create routes have always answered * 403 for it. It names its cause so a client can tell a ceiling apart from a * role or key-kind refusal: one is cleared by deleting a table, the other by - * changing who is calling. - * - * The status is left as it shipped, and it disagrees with its sibling: - * {@link TableRowLimitError} answers 400 for the row ceiling. Neither is - * obviously right — a capacity ceiling is arguably a 409, since the request is - * well-formed, the caller is authorized, and the conflict is with the - * collection's current state, which the caller can clear. The disagreement is - * recorded rather than resolved here because the row ceiling is also reachable - * from the internal surface, where the 400 is shipped and not behind the v2 - * flag, so restatusing one and not the other would widen the split instead of - * closing it. + * changing who is calling. The status is left as it shipped, and it disagrees + * with the row ceiling's 400 — see `TableRowLimitError` in `lib/table/billing` + * for why both are recorded rather than unified here. */ return new ForbiddenOperationError( 'WORKSPACE_RESOURCE_LIMIT_REACHED', diff --git a/apps/sim/lib/uploads/core/storage-key.ts b/apps/sim/lib/uploads/core/storage-key.ts index ee751e4b1d1..efc66906937 100644 --- a/apps/sim/lib/uploads/core/storage-key.ts +++ b/apps/sim/lib/uploads/core/storage-key.ts @@ -11,6 +11,18 @@ const MAX_STORAGE_KEY_SEGMENT_BYTES = 255 /** Sidecar attached to local objects promoted through the upload-session transport. */ export const LOCAL_UPLOAD_METADATA_SUFFIX = '.upload-metadata.json' +/** + * Roots the local data plane owns inside the upload directory. + * + * Both hold work-in-progress rather than stored objects, so both are swept by + * the local cleanup job. They live beside the other local-artifact names rather + * than in the data-plane provider so the sweep can name what it reclaims + * without importing the transport that writes it; a root known only to its + * writer accumulates forever. + */ +export const LOCAL_MULTIPART_ROOT = '.multipart' +export const LOCAL_STAGING_ROOT = '.staging' + /** * Every suffix local storage appends to a stored object's own path component. * diff --git a/apps/sim/lib/uploads/upload-session/cleanup.ts b/apps/sim/lib/uploads/upload-session/cleanup.ts index 22e473781ff..1b5b5d29aa7 100644 --- a/apps/sim/lib/uploads/upload-session/cleanup.ts +++ b/apps/sim/lib/uploads/upload-session/cleanup.ts @@ -2,7 +2,7 @@ import type { Dirent } from 'node:fs' import { opendir, rm, stat } from 'node:fs/promises' import { join } from 'node:path' import { UPLOAD_DIR_SERVER } from '@/lib/uploads/core/setup.server' -import { LOCAL_MULTIPART_ROOT, LOCAL_STAGING_ROOT } from '@/lib/uploads/upload-session/provider' +import { LOCAL_MULTIPART_ROOT, LOCAL_STAGING_ROOT } from '@/lib/uploads/core/storage-key' export const LOCAL_UPLOAD_CLEANUP_INTERVAL_MS = 15 * 60 * 1000 export const LOCAL_UPLOAD_ARTIFACT_TTL_MS = 25 * 60 * 60 * 1000 diff --git a/apps/sim/lib/uploads/upload-session/provider.ts b/apps/sim/lib/uploads/upload-session/provider.ts index b3aa22d76c6..8b8f7c77bd5 100644 --- a/apps/sim/lib/uploads/upload-session/provider.ts +++ b/apps/sim/lib/uploads/upload-session/provider.ts @@ -22,7 +22,11 @@ import { USE_S3_STORAGE, } from '@/lib/uploads/config' import { UPLOAD_DIR_SERVER } from '@/lib/uploads/core/setup.server' -import { LOCAL_UPLOAD_METADATA_SUFFIX } from '@/lib/uploads/core/storage-key' +import { + LOCAL_MULTIPART_ROOT, + LOCAL_STAGING_ROOT, + LOCAL_UPLOAD_METADATA_SUFFIX, +} from '@/lib/uploads/core/storage-key' import { createBlobConfig, createGcsConfig, @@ -604,16 +608,6 @@ export async function writeLocalMultipartPart(params: { } } -/** - * Roots the local data plane owns inside the upload directory. - * - * Both hold work-in-progress rather than stored objects, so both are swept by - * the local cleanup job. Naming them here keeps that sweep and the writers - * agreeing on one set — a root known only to its writer accumulates forever. - */ -export const LOCAL_MULTIPART_ROOT = '.multipart' -export const LOCAL_STAGING_ROOT = '.staging' - function localPartsDirectory(uploadId: string): string { return join(UPLOAD_DIR_SERVER, LOCAL_MULTIPART_ROOT, uploadId) } diff --git a/apps/sim/lib/uploads/upload-session/service.test.ts b/apps/sim/lib/uploads/upload-session/service.test.ts index fa1ba2d3d04..33292b72ab2 100644 --- a/apps/sim/lib/uploads/upload-session/service.test.ts +++ b/apps/sim/lib/uploads/upload-session/service.test.ts @@ -178,6 +178,43 @@ describe('upload sessions', () => { ).toBeLessThanOrEqual(255) }) + /** + * A knowledge document the pipeline provably refuses is rejected on admission + * whichever route carries it: the direct upload use case rejects a zero-byte + * buffer, and the session path refuses the same file before it hands out a + * transfer URL for it. `workspace_file` is the deliberate exception — an empty + * file is a legitimate thing to keep in a workspace — so pinning both keeps + * the split a decision rather than an omission. + */ + it.each([ + ['knowledge_document', { knowledgeBaseId: 'kb-1' }, true], + ['workspace_file', {}, false], + ])( + 'admits a zero-byte %s only where an empty file is legitimate', + async (purpose, extra, refused) => { + dbChainMockFns.returning.mockResolvedValue([uploadRow({ purpose })]) + + const create = createUploadSession({ + id: 'upload-1', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + purpose: purpose as Parameters[0]['purpose'], + fileName: 'empty.txt', + contentType: 'text/plain', + fileSize: 0, + localOrigin: 'http://localhost:3000', + ...(extra as object), + } as Parameters[0]) + + if (refused) { + await expect(create).rejects.toThrow('fileSize must be a positive integer') + } else { + await expect(create).resolves.toBeDefined() + } + } + ) + it('allocates distinct keys for same-named execution attachments', async () => { dbChainMockFns.returning .mockResolvedValueOnce([ diff --git a/scripts/openapi/documents.test.ts b/scripts/openapi/documents.test.ts index f66495223ec..21ebac6ba87 100644 --- a/scripts/openapi/documents.test.ts +++ b/scripts/openapi/documents.test.ts @@ -332,15 +332,14 @@ describe('generated OpenAPI documents', () => { }) /** - * Documented error sets for the knowledge and files/audit families. + * Documented error sets. * - * Scoped to those two documents deliberately: they are the families this pass - * audited, and the same sweep over tables and resources still reports gaps that - * belong to their owners. + * The 413 sweep runs over all seven documents rather than the two families it + * first audited: the gaps the narrower scope was written around are closed, and + * leaving it narrow would let a new body-carrying operation in any other family + * ship without publishing the 413 its body read raises. */ -describe('knowledge and files documented error sets', () => { - const SCOPED_DOCUMENTS = [knowledgeOpenApiDocument, filesAuditOpenApiDocument] as const - +describe('documented error sets', () => { /** * A v2 JSON route whose contract declares a body reads that body through * `parseJsonBody` under `DEFAULT_MAX_JSON_BODY_BYTES` *before* schema @@ -352,7 +351,7 @@ describe('knowledge and files documented error sets', () => { * directional. */ it.each( - SCOPED_DOCUMENTS.flatMap((document) => + DOCUMENTS.flatMap((document) => document.routes .filter((route) => route.contract.body !== undefined) .map((route) => [route.operation.operationId, route.operation.errors] as const) From 4fb855d032cc2892a484ea6913beda6a20225854 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 21:29:01 -0700 Subject: [PATCH 34/56] fix(v2): de-duplicate a set filter before fingerprinting it The filters compile to inArray, which is set membership, so workflowIds=A,A,B selects exactly what A,B does. Sorting alone still bound them to different pages, so an equivalent filter with a repeated member 400d mid-walk. --- apps/sim/lib/api/cursor-binding.test.ts | 9 +++++++++ apps/sim/lib/api/cursor-binding.ts | 20 +++++++++++++------- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/apps/sim/lib/api/cursor-binding.test.ts b/apps/sim/lib/api/cursor-binding.test.ts index 6b94b284897..21fe4e7f289 100644 --- a/apps/sim/lib/api/cursor-binding.test.ts +++ b/apps/sim/lib/api/cursor-binding.test.ts @@ -199,6 +199,15 @@ describe('unordered filter scope parts', () => { const b = cursorScopeKey({ workflowIds: unorderedScopePart('B,A') }) expect(a).toBe(b) }) + it('fingerprints a duplicate-bearing set identically', () => { + // The filters compile to `inArray`, which is set membership, so A,A,B + // selects exactly what A,B does and must resume the same page. + expect(cursorScopeKey({ workflowIds: unorderedScopePart('A,A,B') })).toBe( + cursorScopeKey({ workflowIds: unorderedScopePart('A,B') }) + ) + expect(unorderedScopePart('B,A,B')).toBe('A,B') + }) + it('still separates genuinely different sets', () => { expect(cursorScopeKey({ workflowIds: unorderedScopePart('A,B') })).not.toBe( cursorScopeKey({ workflowIds: unorderedScopePart('A,C') }) diff --git a/apps/sim/lib/api/cursor-binding.ts b/apps/sim/lib/api/cursor-binding.ts index 24ff3797930..18b231fc535 100644 --- a/apps/sim/lib/api/cursor-binding.ts +++ b/apps/sim/lib/api/cursor-binding.ts @@ -68,16 +68,22 @@ export type CursorScopePart = * spelling, so a caller who reorders an equivalent filter mid-walk gets a 400 * for a page that is genuinely the next one. * - * {@link canonicalJson} already sorts object keys, so this only has to sort the - * list members. Empty members are dropped because the parsers drop them too. + * {@link canonicalJson} already sorts object keys, so this only has to normalize + * the list. Members are de-duplicated as well as sorted: the filters compile to + * `inArray`, which is set membership, so `A,A,B` selects exactly what `A,B` does + * and must not bind to a different page. Empty members are dropped because the + * parsers drop them too. */ export function unorderedScopePart(raw: string | undefined): string | undefined { if (raw === undefined) return undefined - const members = raw - .split(',') - .map((member) => member.trim()) - .filter((member) => member.length > 0) - .sort() + const members = [ + ...new Set( + raw + .split(',') + .map((member) => member.trim()) + .filter((member) => member.length > 0) + ), + ].sort() return members.length > 0 ? members.join(',') : undefined } From 922ac91a571f169bd3eadc3cee0d19b01ca5b217 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 21:29:46 -0700 Subject: [PATCH 35/56] fix(w6): close a head-authorization hole, a TZ leak, and five tests that could not fail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six risks an adversarial read of this week's diff raised, verified one at a time. Two of the six were already correct and are reported as such rather than changed. `v2HeadAuthorizationResponse` optional-called the use case's authorization phase, so a use case without one would have answered the bodiless 200 that `headSafe: false` exists to prevent. The definition-time guard does cover both builders that reach it — they are its only callers — but an optional call turns a missing phase into that leak silently, so the responder now refuses instead of skipping. `packages/db/timestamps.test.ts` assigned `process.env.TZ` at module scope and never restored it. `TZ` is process state: a worker running files back to back carried Asia/Tokyo into every file that followed, and only when the ordering put it after this one. The zone is now set and restored around the file, with both properties the suite depends on intact. Upload publication moved its staging area out of the destination's own directory into a shared `.staging` root, which makes the publishing `link` a cross-subtree one. A volume mounted under part of the uploads tree puts the two on different devices and `link` answers `EXDEV`, which the same-directory link could not. Publication now copies onto the destination's device and links from there, keeping the create-or-fail step that stops a replay from overwriting a stored object. Five tests that passed regardless of the code: - `resolveFolderPathFilter` was only ever exercised through hand-written reimplementations in the suites that mock it out, so widening a miss to unfiltered — every filtered list answering with the whole workspace — left them all green. The real helper is now tested where it lives. - The only measurement of `generateWorkspaceFileKey` asserted the key's last component against `NAME_MAX` rather than the component plus the sidecar written beside it, so it passed with the sidecar reservation removed. - `GET /logs` asserted only that a rejected cursor does NOT name `sortBy`, which almost any wording satisfies, including one saying nothing at all. - The skills lifecycle test asserted that the four writes agree on a workspace-key policy, which a lifecycle uniformly allowing one also satisfies; it now pins the policy they agree on and the kinds they admit. - The v2 skills create test lost `expect(capture).not.toHaveBeenCalled()` when the create path moved to a personal key. The behaviour it pinned is gone — the workspace-key create is refused now — so it is re-homed as the refusal reaching the caller as a 403 with no analytics behind it. Two claims did not hold. `CURSOR_VERSION` is correctly left at 1: the filter stamp is additive, a pre-stamp token still decodes, an unfiltered read still resumes, and only a filtered replay fails — with a conflict that names the filter, where a version bump would answer a generic unreadable-cursor 400 to every in-flight token. Tests pin all three, plus the minted version itself. And the upload-session key-budget cases do exercise the real shared budget through the real segment builder; only the workspace-key prefix is the stub's, which is now stated where the stub is declared. --- apps/sim/app/api/v2/logs/route.test.ts | 12 +++- apps/sim/app/api/v2/skills/route.test.ts | 28 +++++++++ .../api/server/routes/v2-json-route.test.ts | 20 ++++++ .../lib/api/server/routes/v2-json-route.ts | 16 ++++- apps/sim/lib/folders/queries.test.ts | 35 +++++++++++ .../lib/skills/application/operations.test.ts | 13 +++- apps/sim/lib/table/rows/cursor.test.ts | 22 +++++++ .../workspace/workspace-file-manager.test.ts | 19 +++++- .../uploads/upload-session/provider.test.ts | 63 ++++++++++++++++++- .../lib/uploads/upload-session/provider.ts | 38 ++++++++++- .../uploads/upload-session/service.test.ts | 8 +++ packages/db/timestamps.test.ts | 32 ++++++++-- 12 files changed, 291 insertions(+), 15 deletions(-) diff --git a/apps/sim/app/api/v2/logs/route.test.ts b/apps/sim/app/api/v2/logs/route.test.ts index c600524076b..b4ab140e4f1 100644 --- a/apps/sim/app/api/v2/logs/route.test.ts +++ b/apps/sim/app/api/v2/logs/route.test.ts @@ -24,6 +24,7 @@ vi.mock('@/lib/logs/application/list-public-logs', () => ({ listPublicLogs: { operation: { id: 'logs.list' }, execute: mocks.execute }, })) +import { UNREADABLE_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' import { OrchestrationError } from '@/lib/core/orchestration/types' import { cursorFilterScope, encodeScopedCursor } from '@/app/api/v2/lib/response' import { GET } from '@/app/api/v2/logs/route' @@ -216,7 +217,14 @@ describe('GET /api/v2/logs', () => { expect(mocks.execute).not.toHaveBeenCalled() }) - /** Neither param exists on this operation, so naming them sends the caller nowhere. */ + /** + * An undecodable token says nothing about which param changed, and this + * operation declares neither `sortBy` nor `sortOrder` under a `.strict()` + * query schema — so the sort-mismatch message would answer one 400 with + * advice that earns a second. The message is asserted exactly rather than by + * absence: "does not say sortBy" is satisfied by almost any wording, including + * one that tells the caller nothing at all. + */ it('names the params a rejected cursor is actually bound to', async () => { const response = await GET( new NextRequest( @@ -225,6 +233,8 @@ describe('GET /api/v2/logs', () => { ) const body = await response.json() + expect(body.error.message).toBe(UNREADABLE_CURSOR_MESSAGE) + expect(body.error.message).toContain('Restart pagination without a cursor') expect(body.error.message).not.toContain('sortBy') expect(body.error.message).not.toContain('sortOrder') }) diff --git a/apps/sim/app/api/v2/skills/route.test.ts b/apps/sim/app/api/v2/skills/route.test.ts index 70d142d6bce..913ecca6aee 100644 --- a/apps/sim/app/api/v2/skills/route.test.ts +++ b/apps/sim/app/api/v2/skills/route.test.ts @@ -50,6 +50,7 @@ vi.mock('@/lib/skills/application/use-cases', () => ({ createSkillUseCase: { operation: { id: 'skills.create' }, execute: mocks.create }, })) +import { PrincipalKindAuthorizationError } from '@/lib/core/application' import { cursorFilterScope, cursorSortKey, encodeOffsetCursor } from '@/app/api/v2/lib/response' import { GET, POST } from '@/app/api/v2/skills/route' @@ -311,6 +312,33 @@ describe('/api/v2/skills', () => { ) }) + /** + * The workspace-key create used to be the case this file pinned analytics + * against: it succeeded, and the assertion was that no `skill_created` event + * was attributed to a principal with no human subject. `skills.create` now + * denies the key outright, so what needs pinning here is the surface's half of + * that — the refusal reaches the caller as the operation's own 403, and a + * create that never happened emits nothing. + */ + it('refuses a workspace-key create and records no analytics for it', async () => { + mocks.create.mockRejectedValueOnce( + new PrincipalKindAuthorizationError('workspace_api_key', 'skills.create') + ) + + const response = await POST( + request('POST', '/api/v2/skills', { + workspaceId: WORKSPACE_ID, + name: skill.name, + description: skill.description, + content: skill.content, + }) + ) + + expect(response.status).toBe(403) + expect(mocks.create).toHaveBeenCalledWith(expect.objectContaining({ principal: PRINCIPAL })) + expect(mocks.capture).not.toHaveBeenCalled() + }) + it('authenticates before parsing skill input', async () => { mocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) diff --git a/apps/sim/lib/api/server/routes/v2-json-route.test.ts b/apps/sim/lib/api/server/routes/v2-json-route.test.ts index 00994330a11..a63b6d13c10 100644 --- a/apps/sim/lib/api/server/routes/v2-json-route.test.ts +++ b/apps/sim/lib/api/server/routes/v2-json-route.test.ts @@ -35,6 +35,7 @@ import { defineV2JsonRoute, type V2ErrorPolicy, v2ApiKeyAuth, + v2HeadAuthorizationResponse, v2OrchestrationErrorPolicy, v2RateLimits, } from '@/lib/api/server/routes/v2-json-route' @@ -623,4 +624,23 @@ describe('defineV2JsonRoute HEAD on a route that is not head-safe', () => { it('refuses at definition time to build the route when the use case cannot authorize', () => { expect(() => createHeadHandler({ omitAuthorize: true })).toThrow(/authorize/) }) + + /** + * The definition-time guard is what a route hits, and it covers both builders + * that answer a `HEAD` this way. This pins the responder's own behaviour if it + * is ever reached another way: a missing authorization phase has to fail, + * because skipping it hands back the bodiless 200 for a resource nothing + * authorized — the leak the guard exists to prevent, restored. + */ + it('refuses to answer 200 when the authorization phase is missing', async () => { + await expect( + v2HeadAuthorizationResponse({ + useCase: { authorize: undefined }, + principal, + input: { widgetId: 'widget-1', workspaceId: 'workspace-1' }, + request: headRequest(), + errorPolicy: v2OrchestrationErrorPolicy, + }) + ).rejects.toThrow(/authorize/) + }) }) diff --git a/apps/sim/lib/api/server/routes/v2-json-route.ts b/apps/sim/lib/api/server/routes/v2-json-route.ts index 2a4b3f0adec..396a36e0040 100644 --- a/apps/sim/lib/api/server/routes/v2-json-route.ts +++ b/apps/sim/lib/api/server/routes/v2-json-route.ts @@ -195,6 +195,14 @@ export function requireHeadAuthorizableUseCase( * produced — 400, 401, 403, 404, 429 — and only an authorized caller reaches the * 200. What a `HEAD` never reaches is the use case's business phase, so the * outbound connection, the row write, and the audit event stay unfired. + * + * A use case with no `authorize` is refused here rather than skipped. Both + * builders that call this already refuse such a route at module load through + * {@link requireHeadAuthorizableUseCase}, and they are its only callers, so the + * refusal is unreachable through them. It is not written as a comment because + * the alternative — an optional call — degrades a missing phase into exactly the + * bodiless 200 this function exists to stop, and it does so silently. Failing + * closed makes an authorization that actually ran the only route to that 200. */ export async function v2HeadAuthorizationResponse(args: { useCase: Pick, 'authorize'> @@ -203,8 +211,14 @@ export async function v2HeadAuthorizationResponse(args: { request: NextRequest errorPolicy: V2ErrorPolicy }): Promise { + const { authorize } = args.useCase + if (typeof authorize !== 'function') { + throw new Error( + 'HEAD on a route that is not head-safe reached a use case with no authorize(); answering 200 would leak the existence of a resource the GET never authorized.' + ) + } try { - await args.useCase.authorize?.({ + await authorize({ principal: args.principal, input: args.input, request: args.request, diff --git a/apps/sim/lib/folders/queries.test.ts b/apps/sim/lib/folders/queries.test.ts index 74dfa1604dc..6992e317072 100644 --- a/apps/sim/lib/folders/queries.test.ts +++ b/apps/sim/lib/folders/queries.test.ts @@ -17,6 +17,7 @@ import { listActiveFolderRows, listFoldersForWorkspace, loadActiveFolderPathIndex, + resolveFolderPathFilter, resolveRestoredFolderId, toFolderApi, wouldCreateFolderCycle, @@ -318,6 +319,40 @@ describe('folder queries', () => { }) }) + /** + * The one place the real helper is exercised. Every list use case that filters + * by `folderPath` mocks this module out and stands a reimplementation in for + * it, so a defect here — a miss widening to unfiltered, a root path that stops + * resolving — would leave all of those suites green while every filtered list + * answered with the wrong rows. + */ + describe('resolveFolderPathFilter', () => { + const index = { + pathById: new Map([['f-1', 'Reports']]), + idByPath: new Map([['Reports', 'f-1']]), + } + + it('treats an omitted path as no filter at all', () => { + expect(resolveFolderPathFilter(index, undefined)).toEqual({ kind: 'unfiltered' }) + }) + + it('resolves the root path to the workspace root rather than to a folder id', () => { + expect(resolveFolderPathFilter(index, '/')).toEqual({ kind: 'folder', folderId: null }) + }) + + it('resolves a named path to its folder id', () => { + expect(resolveFolderPathFilter(index, 'Reports')).toEqual({ kind: 'folder', folderId: 'f-1' }) + }) + + /** + * A path naming no active folder narrows the list to nothing. Widening it to + * `unfiltered` would answer a scoped read with every row in the workspace. + */ + it('narrows to nothing for a path that names no active folder', () => { + expect(resolveFolderPathFilter(index, 'Archive')).toEqual({ kind: 'noMatch' }) + }) + }) + describe('toFolderApi', () => { it('serializes timestamps to ISO strings and preserves a null deletedAt', () => { expect(toFolderApi(ROW)).toMatchObject({ diff --git a/apps/sim/lib/skills/application/operations.test.ts b/apps/sim/lib/skills/application/operations.test.ts index 9577e779dcb..1d85523dcc5 100644 --- a/apps/sim/lib/skills/application/operations.test.ts +++ b/apps/sim/lib/skills/application/operations.test.ts @@ -27,6 +27,13 @@ describe('skill operation registry', () => { * The invariant the create/delete split violated. A principal kind that can * create a skill must be able to remove it, or its only possible interaction * with the resource is to accumulate rows it can never reach again. + * + * Symmetry alone is not the property. A lifecycle that uniformly ALLOWED a + * workspace key is just as symmetric and reopens the hole, because the edit + * paths cannot resolve an acting subject for one. So both halves are pinned: + * the writes agree on a policy, and the policy they agree on is the one every + * edit path can honour. The principal kinds are compared directly rather than + * left to the test's own name. */ it('admits the same principal kinds to every write in the lifecycle', () => { const writes = [ @@ -35,9 +42,11 @@ describe('skill operation registry', () => { skillOperations.upsert, skillOperations.delete, ] - const policies = writes.map((operation) => operation.workspaceApiKey) - expect(new Set(policies).size).toBe(1) + expect(new Set(writes.map((operation) => operation.workspaceApiKey))).toEqual(new Set(['deny'])) + for (const operation of writes) { + expect(operation.principalKinds).toEqual(skillOperations.delete.principalKinds) + } }) it('gates every edit path on a human subject rather than workspace role', () => { diff --git a/apps/sim/lib/table/rows/cursor.test.ts b/apps/sim/lib/table/rows/cursor.test.ts index 967fef7a6b5..4f4d1439695 100644 --- a/apps/sim/lib/table/rows/cursor.test.ts +++ b/apps/sim/lib/table/rows/cursor.test.ts @@ -218,5 +218,27 @@ describe('tokens minted before the filter stamp', () => { expect(() => assertCursorQueryBinding(decoded, { predicate: ACTIVE })).toThrow( /Restart paging without the cursor/ ) + /** + * The code, not just the wording, is what a bumped `CURSOR_VERSION` would + * cost: every in-flight token would fail `INVALID_CURSOR` at decode instead, + * including the unfiltered ones that resume fine today. + */ + try { + assertCursorQueryBinding(decoded, { predicate: ACTIVE }) + expect.unreachable('a re-filtered replay must be refused') + } catch (e) { + expect((e as TableQueryValidationError).code).toBe('CURSOR_FILTER_CONFLICT') + } + }) + + /** + * The version a token minted today carries. Pinned so a bump is a deliberate + * edit here rather than a silent one that strands every cursor a running + * deploy already handed out. + */ + it('mints tokens at the version the previous deploy could already read', () => { + const token = encodeCursor({ lastRow: ROW, keysetValid: true, nextOffset: 10 }) + + expect(JSON.parse(Buffer.from(token, 'base64url').toString('utf8')).v).toBe(1) }) }) diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.test.ts index 83c4186067d..a5570d9a950 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.test.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.test.ts @@ -3,6 +3,7 @@ */ import { describe, expect, it } from 'vitest' +import { LOCAL_UPLOAD_METADATA_SUFFIX } from '@/lib/uploads/core/storage-key' import { findWorkspaceFileRecord, generateWorkspaceFileKey, @@ -92,12 +93,26 @@ describe('workspace file reference normalization', () => { }) }) +/** + * The only place the real key builder is measured — the upload-session suites + * stand a stub in for it — so the budget is asserted here the way the filesystem + * enforces it. Local storage writes a metadata sidecar beside the object under + * the object's own name, so `NAME_MAX` bounds the key's last component PLUS that + * suffix, not the component alone. Measuring the component alone passes with the + * sidecar reservation removed, and the overflow returns as an `ENAMETOOLONG` 500 + * on a name the contract already admitted. + */ describe('workspace file storage keys', () => { - it('keeps the last key component within one path component for the longest admitted name', () => { + /** POSIX `NAME_MAX`, in bytes, for one path component. */ + const NAME_MAX = 255 + + it('leaves the longest admitted name room for its local sidecar', () => { const key = generateWorkspaceFileKey('ws_123', `${'a'.repeat(251)}.txt`) const lastSegment = key.slice(key.lastIndexOf('/') + 1) - expect(Buffer.byteLength(lastSegment, 'utf-8')).toBeLessThanOrEqual(255) + expect( + Buffer.byteLength(`${lastSegment}${LOCAL_UPLOAD_METADATA_SUFFIX}`, 'utf-8') + ).toBeLessThanOrEqual(NAME_MAX) expect(key.startsWith('workspace/ws_123/')).toBe(true) expect(lastSegment.endsWith('.txt')).toBe(true) }) diff --git a/apps/sim/lib/uploads/upload-session/provider.test.ts b/apps/sim/lib/uploads/upload-session/provider.test.ts index 9e12995f487..40c61ccc4d9 100644 --- a/apps/sim/lib/uploads/upload-session/provider.test.ts +++ b/apps/sim/lib/uploads/upload-session/provider.test.ts @@ -1,9 +1,16 @@ /** * @vitest-environment node */ -import { mkdir, readdir, readFile, rm, stat } from 'node:fs/promises' +import { link, mkdir, readdir, readFile, rm, stat } from 'node:fs/promises' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +/** + * Spied rather than replaced: every assertion in this file reads the real + * filesystem, and the only behaviour worth faking is a single `link` answering + * `EXDEV`, which no temporary directory can be made to produce on its own. + */ +vi.mock('node:fs/promises', { spy: true }) + const { testUploadDirectory, mockS3Presign, mockS3PartUrls } = vi.hoisted(() => ({ testUploadDirectory: `/tmp/sim-upload-session-provider-${process.pid}`, mockS3Presign: vi.fn(), @@ -117,6 +124,60 @@ describe('local upload-session provider', () => { }) }) + /** + * Staging moved out of the destination's own directory into one shared + * `.staging` root, which is what makes this reachable: a volume mounted under + * part of the uploads tree puts the staged object and its destination on + * different devices, and a hard link cannot span them. Publication has to + * survive that without giving up the create-or-fail the link provides. + */ + it('publishes across a filesystem boundary a hard link cannot span', async () => { + vi.mocked(link).mockRejectedValueOnce( + Object.assign(new Error('EXDEV: cross-device link'), { code: 'EXDEV' }) + ) + + await writeLocalPutObject({ + uploadId: 'upload-1', + key: 'workspace/workspace-1/file.bin', + body: byteStream('ab', 'cd'), + expectedSize: 4, + contentType: 'application/octet-stream', + metadata: METADATA, + }) + + await expect(readFile(localPath('workspace/workspace-1/file.bin'), 'utf8')).resolves.toBe( + 'abcd' + ) + await expect( + headProviderObject({ + provider: 'local', + key: 'workspace/workspace-1/file.bin', + context: CONTEXT, + }) + ).resolves.toMatchObject({ size: 4, uploadId: 'upload-1' }) + expect(await temporaryFiles('workspace/workspace-1')).toEqual([]) + expect(await allEntries('.staging')).toEqual([]) + }) + + it('still refuses to overwrite an existing object when the link cannot span devices', async () => { + const params = { + uploadId: 'upload-1', + key: 'workspace/workspace-1/file.bin', + expectedSize: 3, + contentType: 'application/octet-stream', + metadata: METADATA, + } + await writeLocalPutObject({ ...params, body: byteStream('one') }) + vi.mocked(link).mockRejectedValueOnce( + Object.assign(new Error('EXDEV: cross-device link'), { code: 'EXDEV' }) + ) + + await expect(writeLocalPutObject({ ...params, body: byteStream('two') })).rejects.toThrow() + + await expect(readFile(localPath(params.key), 'utf8')).resolves.toBe('one') + expect(await temporaryFiles('workspace/workspace-1')).toEqual([]) + }) + it('does not let a replayed PUT overwrite the final object', async () => { const params = { uploadId: 'upload-1', diff --git a/apps/sim/lib/uploads/upload-session/provider.ts b/apps/sim/lib/uploads/upload-session/provider.ts index b3aa22d76c6..68aa92608f3 100644 --- a/apps/sim/lib/uploads/upload-session/provider.ts +++ b/apps/sim/lib/uploads/upload-session/provider.ts @@ -1,5 +1,6 @@ import { createReadStream, createWriteStream } from 'node:fs' import { + copyFile, link, mkdir, readdir, @@ -711,15 +712,48 @@ async function listLocalMultipartParts(uploadId: string): Promise { + try { + await link(source, destination) + return + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EXDEV') throw error + } + const sameDeviceCopy = join(dirname(destination), `.${generateId()}.publish`) + try { + await copyFile(source, sameDeviceCopy) + await link(sameDeviceCopy, destination) + } finally { + await rm(sameDeviceCopy, { force: true }) + } +} + async function publishLocalObject( temporary: string, temporaryMetadata: string, destination: string, destinationMetadata: string ): Promise { - await link(temporary, destination) + await linkLocalArtifact(temporary, destination) try { - await link(temporaryMetadata, destinationMetadata) + await linkLocalArtifact(temporaryMetadata, destinationMetadata) } catch (error) { await rm(destination, { force: true }) throw error diff --git a/apps/sim/lib/uploads/upload-session/service.test.ts b/apps/sim/lib/uploads/upload-session/service.test.ts index fa1ba2d3d04..216ee12a9ea 100644 --- a/apps/sim/lib/uploads/upload-session/service.test.ts +++ b/apps/sim/lib/uploads/upload-session/service.test.ts @@ -34,6 +34,14 @@ vi.mock('@/lib/billing/storage', () => ({ resolveStorageBillingContext: mockResolveBillingContext, })) +/** + * Stands in for the workspace-files barrel, which pulls the whole file manager. + * The stub still builds its key through the real {@link buildStorageKeySegment}, + * so the shared name budget below is genuinely exercised — but the prefix is the + * stub's own, so the two workspace-keyed purposes below prove nothing about + * `generateWorkspaceFileKey`'s prefix. That one is measured against the real + * function in `contexts/workspace/workspace-file-manager.test.ts`. + */ vi.mock('@/lib/uploads/contexts/workspace', async () => { const { buildStorageKeySegment } = await import('@/lib/uploads/core/storage-key') return { diff --git a/packages/db/timestamps.test.ts b/packages/db/timestamps.test.ts index 3c779138ed6..78b39cb7628 100644 --- a/packages/db/timestamps.test.ts +++ b/packages/db/timestamps.test.ts @@ -4,10 +4,14 @@ * These assertions are only meaningful when the process is NOT running in UTC: * a local-time defect is invisible when local time *is* UTC. `TZ` is therefore * pinned to a non-UTC zone, and {@link isProcessInUtc} fails the suite outright - * if the runtime ignored it, rather than letting the file pass vacuously. The - * assignment sits below the imports because ESM hoists them regardless; what - * matters is that it runs before any `Date` is constructed, and every `Date` - * here is built inside a test body. + * if the runtime ignored it, rather than letting the file pass vacuously. + * + * The zone is set and restored around this file rather than assigned at module + * scope. `TZ` is process state, not module state, and a worker that runs test + * files back to back in one process carries the assignment into every file that + * follows — an unrelated suite would then read local time as Tokyo, and only + * when the file ordering put it after this one. Every `Date` here is built + * inside a test body, so a hook is early enough to pin the zone for all of them. */ import { @@ -18,9 +22,9 @@ import { import { pgTable, timestamp } from 'drizzle-orm/pg-core' import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' -import { describe, expect, it } from 'vitest' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' -process.env.TZ = 'Asia/Tokyo' +const TEST_TIME_ZONE = 'Asia/Tokyo' /** Postgres oid of `timestamp without time zone`. */ const TIMESTAMP_OID = 1114 @@ -58,6 +62,22 @@ function resolveTimestampParser(wrapInDrizzle: boolean): TimestampParser { } describe('naive timestamp UTC pinning', () => { + const ambientTimeZone = process.env.TZ + + beforeAll(() => { + process.env.TZ = TEST_TIME_ZONE + }) + + afterAll(() => { + /** + * Removed rather than assigned `undefined`: assigning it would leave the + * literal string `"undefined"` in the environment, which is a zone name no + * runtime resolves. + */ + if (ambientTimeZone === undefined) Reflect.deleteProperty(process.env, 'TZ') + else process.env.TZ = ambientTimeZone + }) + it('runs outside UTC, so a local-time defect is observable', () => { expect(isProcessInUtc()).toBe(false) }) From f2bdd58fe300e6e0a8a0a95b611e71ef699c0278 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 23:43:29 -0700 Subject: [PATCH 36/56] refactor(v2): collapse two names for the cursor scope key onto one helper `cursorFilterScope` in the v2 response module was a one-line pass-through to `cursorScopeKey` in `lib/api/cursor-binding`, so the same function was reachable under two names from two modules. Routes now call `cursorScopeKey` directly, the way they already import `unorderedScopePart` and the cursor messages from that module, and the wrapper plus its duplicated doc comment are gone. Also folds the `id -> name` column map in the v2 tables presenter onto `buildColumnNameById`, which the same file already imports and calls thirteen lines above; restores two doc comments that had drifted onto the wrong declaration; and replaces three `as Date` casts in the timestamp test with `toEqual(new Date(...))`, which needs no cast and additionally fails when the mapped value is not a Date at all. --- apps/sim/app/api/v2/audit-logs/route.ts | 5 ++-- .../sim/app/api/v2/billing/logs/route.test.ts | 5 ++-- apps/sim/app/api/v2/billing/logs/route.ts | 5 ++-- apps/sim/app/api/v2/credentials/route.ts | 10 ++----- apps/sim/app/api/v2/custom-tools/route.ts | 10 ++----- apps/sim/app/api/v2/files/route.ts | 10 ++----- .../api/v2/knowledge/[id]/documents/route.ts | 13 +++------ apps/sim/app/api/v2/knowledge/route.ts | 10 ++----- apps/sim/app/api/v2/lib/response.ts | 28 ++----------------- apps/sim/app/api/v2/logs/route.test.ts | 6 ++-- apps/sim/app/api/v2/logs/route.ts | 10 +++++-- apps/sim/app/api/v2/mcp-servers/route.ts | 10 ++----- apps/sim/app/api/v2/secrets/route.ts | 10 ++----- apps/sim/app/api/v2/skills/route.test.ts | 5 ++-- apps/sim/app/api/v2/skills/route.ts | 10 ++----- apps/sim/app/api/v2/tables/route.ts | 10 ++----- apps/sim/app/api/v2/tables/utils.ts | 8 ++---- .../app/api/v2/workflows/[id]/runs/route.ts | 11 ++------ apps/sim/app/api/v2/workflows/route.ts | 10 ++----- .../v2/__tests__/list-pagination.test.ts | 2 +- apps/sim/lib/api/cursor-binding.test.ts | 11 ++++---- apps/sim/lib/api/cursor-binding.ts | 16 +++++------ packages/db/timestamps.test.ts | 19 ++++++------- 23 files changed, 83 insertions(+), 151 deletions(-) diff --git a/apps/sim/app/api/v2/audit-logs/route.ts b/apps/sim/app/api/v2/audit-logs/route.ts index 73a66fb58e0..014c21597b4 100644 --- a/apps/sim/app/api/v2/audit-logs/route.ts +++ b/apps/sim/app/api/v2/audit-logs/route.ts @@ -1,4 +1,5 @@ import { v2ListAuditLogsContract } from '@/lib/api/contracts/v2/audit-logs' +import { cursorScopeKey } from '@/lib/api/cursor-binding' import { defineV2JsonRoute, v2ApiKeyAuth, @@ -8,7 +9,7 @@ import { import { listAuditLogs } from '@/lib/audit-logs/application/list-audit-logs' import { auditLogOperations } from '@/lib/audit-logs/application/operations' import { formatV2AuditLogEntry } from '@/app/api/v2/audit-logs/format' -import { cursorFilterScope, encodeScopedCursor, readScopedCursor } from '@/app/api/v2/lib/response' +import { encodeScopedCursor, readScopedCursor } from '@/app/api/v2/lib/response' /** Every param that changes which audit entries, in which order, this list returns. */ function auditLogCursorFilters(query: { @@ -22,7 +23,7 @@ function auditLogCursorFilters(query: { startDate?: string endDate?: string }) { - return cursorFilterScope({ + return cursorScopeKey({ organizationId: query.organizationId, includeDeparted: query.includeDeparted, action: query.action, diff --git a/apps/sim/app/api/v2/billing/logs/route.test.ts b/apps/sim/app/api/v2/billing/logs/route.test.ts index 61a795ab7da..a28ddef4fb8 100644 --- a/apps/sim/app/api/v2/billing/logs/route.test.ts +++ b/apps/sim/app/api/v2/billing/logs/route.test.ts @@ -24,17 +24,18 @@ vi.mock('@/lib/billing/application/list-billing-logs', () => ({ listBillingLogs: { operation: { id: 'billing.logs.list' }, execute: mocks.execute }, })) +import { cursorScopeKey } from '@/lib/api/cursor-binding' import { UNKNOWN_CURSOR_MESSAGE } from '@/lib/billing/core/usage-log' import { OrchestrationError } from '@/lib/core/orchestration/types' import { GET } from '@/app/api/v2/billing/logs/route' -import { cursorFilterScope, encodeScopedCursor } from '@/app/api/v2/lib/response' +import { encodeScopedCursor } from '@/app/api/v2/lib/response' /** A ledger cursor exactly as the route mints one, for the filters given. */ function ledgerCursor( inner: string, filters: { source?: string; workspaceId?: string; period?: string } ): string { - return encodeScopedCursor(cursorFilterScope(filters), inner) + return encodeScopedCursor(cursorScopeKey(filters), inner) } const auth = { diff --git a/apps/sim/app/api/v2/billing/logs/route.ts b/apps/sim/app/api/v2/billing/logs/route.ts index 26f098acf33..5f6f17ccd43 100644 --- a/apps/sim/app/api/v2/billing/logs/route.ts +++ b/apps/sim/app/api/v2/billing/logs/route.ts @@ -1,11 +1,12 @@ import { v2ListBillingLogsContract } from '@/lib/api/contracts/v2/billing' +import { cursorScopeKey } from '@/lib/api/cursor-binding' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { v2BillingErrorPolicies } from '@/lib/billing/api/route-policies' import { listBillingLogs } from '@/lib/billing/application/list-billing-logs' import { billingOperations } from '@/lib/billing/application/operations' import { toBillingUsageLogSource, toInternalUsageLogSources } from '@/lib/billing/usage-sources' import { resolveDateRange } from '@/app/api/users/me/usage-logs/shared' -import { cursorFilterScope, encodeScopedCursor, readScopedCursor } from '@/app/api/v2/lib/response' +import { encodeScopedCursor, readScopedCursor } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -27,7 +28,7 @@ function billingLogCursorFilters(query: { startDate?: string endDate?: string }) { - return cursorFilterScope({ + return cursorScopeKey({ source: query.source, workspaceId: query.workspaceId, period: query.period, diff --git a/apps/sim/app/api/v2/credentials/route.ts b/apps/sim/app/api/v2/credentials/route.ts index 6e6f80552fd..4f416e02efb 100644 --- a/apps/sim/app/api/v2/credentials/route.ts +++ b/apps/sim/app/api/v2/credentials/route.ts @@ -1,4 +1,5 @@ import { v2ListCredentialsContract } from '@/lib/api/contracts/v2/credentials' +import { cursorScopeKey } from '@/lib/api/cursor-binding' import { defineV2JsonRoute, v2ApiKeyAuth, @@ -8,12 +9,7 @@ import { import { listWorkspaceCredentials } from '@/lib/credentials/application/list-workspace-credentials' import { credentialOperations } from '@/lib/credentials/application/operations' import { toV2Credential } from '@/app/api/v2/credentials/utils' -import { - cursorFilterScope, - cursorSortKey, - encodeSortedCursor, - readSortedCursor, -} from '@/app/api/v2/lib/response' +import { cursorSortKey, encodeSortedCursor, readSortedCursor } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -25,7 +21,7 @@ function credentialCursorFilters(query: { providerId?: string search?: string }) { - return cursorFilterScope({ + return cursorScopeKey({ workspaceId: query.workspaceId, type: query.type, providerId: query.providerId, diff --git a/apps/sim/app/api/v2/custom-tools/route.ts b/apps/sim/app/api/v2/custom-tools/route.ts index 546e0de9e93..7b0f86f91f9 100644 --- a/apps/sim/app/api/v2/custom-tools/route.ts +++ b/apps/sim/app/api/v2/custom-tools/route.ts @@ -2,6 +2,7 @@ import { v2CreateCustomToolContract, v2ListCustomToolsContract, } from '@/lib/api/contracts/v2/custom-tools' +import { cursorScopeKey } from '@/lib/api/cursor-binding' import { defineV2JsonRoute, v2ApiKeyAuth, @@ -14,19 +15,14 @@ import { listWorkspaceCustomToolsUseCase, } from '@/lib/custom-tools/application/use-cases' import { toV2CustomTool } from '@/app/api/v2/custom-tools/utils' -import { - cursorFilterScope, - cursorSortKey, - encodeSortedCursor, - readSortedCursor, -} from '@/app/api/v2/lib/response' +import { cursorSortKey, encodeSortedCursor, readSortedCursor } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 /** Every param that changes which custom tools, in which order, this list returns. */ function customToolCursorFilters(query: { workspaceId: string; search?: string }) { - return cursorFilterScope({ + return cursorScopeKey({ workspaceId: query.workspaceId, search: query.search, }) diff --git a/apps/sim/app/api/v2/files/route.ts b/apps/sim/app/api/v2/files/route.ts index f91711e248d..0f9286db44b 100644 --- a/apps/sim/app/api/v2/files/route.ts +++ b/apps/sim/app/api/v2/files/route.ts @@ -3,6 +3,7 @@ import { v2CreateFileContract, v2ListFilesContract, } from '@/lib/api/contracts/v2/files' +import { cursorScopeKey } from '@/lib/api/cursor-binding' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' import { v2FileErrorPolicies } from '@/lib/workspace-files/api' @@ -11,12 +12,7 @@ import { queryWorkspaceFilePage } from '@/lib/workspace-files/application/list-w import { fileOperations } from '@/lib/workspace-files/application/operations' import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES } from '@/lib/workspace-files/orchestration' import { toV2File, toV2Files } from '@/app/api/v2/files/utils' -import { - cursorFilterScope, - cursorSortKey, - encodeSortedCursor, - readSortedCursor, -} from '@/app/api/v2/lib/response' +import { cursorSortKey, encodeSortedCursor, readSortedCursor } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -28,7 +24,7 @@ function fileCursorFilters(query: { folderPath?: string search?: string }) { - return cursorFilterScope({ + return cursorScopeKey({ workspaceId: query.workspaceId, scope: query.scope, folderPath: query.folderPath, diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts index 0ecc8462481..88676fb65e8 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts @@ -4,7 +4,7 @@ import { v2ListKnowledgeDocumentsContract, v2UploadKnowledgeDocumentContract, } from '@/lib/api/contracts/v2/knowledge' -import { canonicalJson } from '@/lib/api/cursor-binding' +import { canonicalJson, cursorScopeKey } from '@/lib/api/cursor-binding' import { defineV2BodyLifecycleRoute, defineV2JsonRoute, @@ -33,19 +33,13 @@ import { captureServerEvent } from '@/lib/posthog/server' import { MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE } from '@/lib/uploads/shared/types' import { validateFileType } from '@/lib/uploads/utils/validation' import { toV2DocumentSummary, toV2TaggedDocument } from '@/app/api/v2/knowledge/utils' -import { - cursorFilterScope, - cursorSortKey, - decodeOffsetCursor, - encodeOffsetCursor, -} from '@/app/api/v2/lib/response' +import { cursorSortKey, decodeOffsetCursor, encodeOffsetCursor } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 const MAX_FILE_SIZE = MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE -/** Every param that changes which documents, in which order, this list returns. */ /** * Canonical form of `tagFilters` so two equivalent filters differing only in * key order fingerprint the same. {@link canonicalJson} sorts object keys. An @@ -61,11 +55,12 @@ function canonicalTagFilters(raw: string | undefined): string | undefined { } } +/** Every param that changes which documents, in which order, this list returns. */ function documentCursorFilters( knowledgeBaseId: string, query: { workspaceId: string; enabledFilter?: string; search?: string; tagFilters?: string } ) { - return cursorFilterScope({ + return cursorScopeKey({ knowledgeBaseId, workspaceId: query.workspaceId, enabledFilter: query.enabledFilter, diff --git a/apps/sim/app/api/v2/knowledge/route.ts b/apps/sim/app/api/v2/knowledge/route.ts index 830142f961b..a83346a825d 100644 --- a/apps/sim/app/api/v2/knowledge/route.ts +++ b/apps/sim/app/api/v2/knowledge/route.ts @@ -2,6 +2,7 @@ import { v2CreateKnowledgeBaseContract, v2ListKnowledgeBasesContract, } from '@/lib/api/contracts/v2/knowledge' +import { cursorScopeKey } from '@/lib/api/cursor-binding' import { defineV2JsonRoute, v2ApiKeyAuth, @@ -16,12 +17,7 @@ import { import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { captureServerEvent } from '@/lib/posthog/server' import { toV2KnowledgeBase, toV2KnowledgeBases } from '@/app/api/v2/knowledge/utils' -import { - cursorFilterScope, - cursorSortKey, - encodeSortedCursor, - readSortedCursor, -} from '@/app/api/v2/lib/response' +import { cursorSortKey, encodeSortedCursor, readSortedCursor } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -32,7 +28,7 @@ function knowledgeCursorFilters(query: { folderPath?: string search?: string }) { - return cursorFilterScope({ + return cursorScopeKey({ workspaceId: query.workspaceId, folderPath: query.folderPath, search: query.search, diff --git a/apps/sim/app/api/v2/lib/response.ts b/apps/sim/app/api/v2/lib/response.ts index 9bb1ff83b56..050f611e510 100644 --- a/apps/sim/app/api/v2/lib/response.ts +++ b/apps/sim/app/api/v2/lib/response.ts @@ -1,11 +1,6 @@ import { NextResponse } from 'next/server' import type { ZodError } from 'zod' -import { - type CursorScopePart, - cursorScopeKey, - REFILTERED_CURSOR_MESSAGE, - UNREADABLE_CURSOR_MESSAGE, -} from '@/lib/api/cursor-binding' +import { REFILTERED_CURSOR_MESSAGE, UNREADABLE_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' import { type CursorKey, INVALID_CURSOR_MESSAGE } from '@/lib/api/list-query' import { getValidationErrorMessage, serializeZodIssues } from '@/lib/api/server' import { ADMISSION_RETRY_AFTER_SECONDS } from '@/lib/core/admission/transient-failure' @@ -286,25 +281,6 @@ interface OffsetCursorPayload { offset: number } -/** - * The filters a v2 cursor was minted under, as one fingerprint. - * - * A cursor is only meaningful against one exact sequence, so everything that - * re-filters that sequence has to travel with it. Build the stamp from every - * such param; a value that does not affect membership or ordering — the page - * size, or a param that only shapes the response body — must stay out, or - * paging with a different `limit` would be rejected for no reason. The sort - * travels separately, as {@link cursorSortKey}, so a mismatch can name which - * half of the query changed. - * - * Shared with the table-row codec through {@link cursorScopeKey}, so a filter - * stamp is one format across every paginated surface rather than one per list. - * `undefined` is a list read with no filters applied at all. - */ -export function cursorFilterScope(parts: Record): string | undefined { - return cursorScopeKey(parts) -} - /** An offset cursor stamped with the sort and filters that produced it. */ export function encodeOffsetCursor( sort: string, @@ -439,7 +415,7 @@ export function decodeSortedCursor( * keys reach `keysetAfter` or a stale position reach a re-filtered read. Sharing * it is what keeps "a bad cursor is a 400" from being re-decided per route. * - * Build `filter` with {@link cursorFilterScope} from the same params on both + * Build `filter` with `cursorScopeKey` from the same params on both * sides of the request. A list with no filters at all passes nothing. */ export function readSortedCursor( diff --git a/apps/sim/app/api/v2/logs/route.test.ts b/apps/sim/app/api/v2/logs/route.test.ts index b4ab140e4f1..bf91febb176 100644 --- a/apps/sim/app/api/v2/logs/route.test.ts +++ b/apps/sim/app/api/v2/logs/route.test.ts @@ -24,9 +24,9 @@ vi.mock('@/lib/logs/application/list-public-logs', () => ({ listPublicLogs: { operation: { id: 'logs.list' }, execute: mocks.execute }, })) -import { UNREADABLE_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' +import { cursorScopeKey, UNREADABLE_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' import { OrchestrationError } from '@/lib/core/orchestration/types' -import { cursorFilterScope, encodeScopedCursor } from '@/app/api/v2/lib/response' +import { encodeScopedCursor } from '@/app/api/v2/lib/response' import { GET } from '@/app/api/v2/logs/route' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' @@ -203,7 +203,7 @@ describe('GET /api/v2/logs', () => { */ it('rejects a cursor whose inner token is empty instead of restarting at page one', async () => { const cursor = encodeScopedCursor( - cursorFilterScope({ workspaceId: WORKSPACE_ID, order: 'desc' }), + cursorScopeKey({ workspaceId: WORKSPACE_ID, order: 'desc' }), '' ) diff --git a/apps/sim/app/api/v2/logs/route.ts b/apps/sim/app/api/v2/logs/route.ts index 0f0420d4b22..2f024611c68 100644 --- a/apps/sim/app/api/v2/logs/route.ts +++ b/apps/sim/app/api/v2/logs/route.ts @@ -4,14 +4,18 @@ import { v2ListLogsContract, v2LogStatusSchema, } from '@/lib/api/contracts/v2/logs' -import { UNREADABLE_CURSOR_MESSAGE, unorderedScopePart } from '@/lib/api/cursor-binding' +import { + cursorScopeKey, + UNREADABLE_CURSOR_MESSAGE, + unorderedScopePart, +} from '@/lib/api/cursor-binding' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { OrchestrationError } from '@/lib/core/orchestration/types' import { v2LogErrorPolicies } from '@/lib/logs/api/route-policies' import { listPublicLogs } from '@/lib/logs/application/list-public-logs' import { logOperations } from '@/lib/logs/application/operations' import { decodePublicLogCursor } from '@/lib/logs/public-queries' -import { cursorFilterScope, encodeScopedCursor, readScopedCursor } from '@/app/api/v2/lib/response' +import { encodeScopedCursor, readScopedCursor } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -39,7 +43,7 @@ function logCursorFilters(query: { folderPaths?: string order?: string }) { - return cursorFilterScope({ + return cursorScopeKey({ workspaceId: query.workspaceId, workflowIds: unorderedScopePart(query.workflowIds), triggers: unorderedScopePart(query.triggers), diff --git a/apps/sim/app/api/v2/mcp-servers/route.ts b/apps/sim/app/api/v2/mcp-servers/route.ts index 2d11561972c..4d9a1838d8d 100644 --- a/apps/sim/app/api/v2/mcp-servers/route.ts +++ b/apps/sim/app/api/v2/mcp-servers/route.ts @@ -2,6 +2,7 @@ import { v2CreateMcpServerContract, v2ListMcpServersContract, } from '@/lib/api/contracts/v2/mcp-servers' +import { cursorScopeKey } from '@/lib/api/cursor-binding' import { defineV2JsonRoute, v2ApiKeyAuth, @@ -11,12 +12,7 @@ import { import { mcpServerOperations } from '@/lib/mcp/application/operations' import { createMcpServerUseCase, listMcpServersUseCase } from '@/lib/mcp/application/use-cases' import { captureServerEvent } from '@/lib/posthog/server' -import { - cursorFilterScope, - cursorSortKey, - encodeSortedCursor, - readSortedCursor, -} from '@/app/api/v2/lib/response' +import { cursorSortKey, encodeSortedCursor, readSortedCursor } from '@/app/api/v2/lib/response' import { toV2McpServer } from '@/app/api/v2/mcp-servers/utils' export const dynamic = 'force-dynamic' @@ -24,7 +20,7 @@ export const revalidate = 0 /** Every param that changes which MCP servers, in which order, this list returns. */ function mcpServerCursorFilters(query: { workspaceId: string; search?: string }) { - return cursorFilterScope({ + return cursorScopeKey({ workspaceId: query.workspaceId, search: query.search, }) diff --git a/apps/sim/app/api/v2/secrets/route.ts b/apps/sim/app/api/v2/secrets/route.ts index 53fdcacd534..86c21fe7e55 100644 --- a/apps/sim/app/api/v2/secrets/route.ts +++ b/apps/sim/app/api/v2/secrets/route.ts @@ -1,4 +1,5 @@ import { v2ListSecretsContract } from '@/lib/api/contracts/v2/secrets' +import { cursorScopeKey } from '@/lib/api/cursor-binding' import { defineV2JsonRoute, v2ApiKeyAuth, @@ -7,12 +8,7 @@ import { } from '@/lib/api/server/routes' import { secretOperations } from '@/lib/secrets/application/operations' import { listSecretsUseCase } from '@/lib/secrets/application/use-cases' -import { - cursorFilterScope, - cursorSortKey, - encodeSortedCursor, - readSortedCursor, -} from '@/app/api/v2/lib/response' +import { cursorSortKey, encodeSortedCursor, readSortedCursor } from '@/app/api/v2/lib/response' import { toV2Secret } from '@/app/api/v2/secrets/utils' export const dynamic = 'force-dynamic' @@ -20,7 +16,7 @@ export const revalidate = 0 /** Every param that changes which secrets, in which order, this list returns. */ function secretCursorFilters(query: { workspaceId: string; scope?: string; search?: string }) { - return cursorFilterScope({ + return cursorScopeKey({ workspaceId: query.workspaceId, scope: query.scope, search: query.search, diff --git a/apps/sim/app/api/v2/skills/route.test.ts b/apps/sim/app/api/v2/skills/route.test.ts index 913ecca6aee..fc868cf954d 100644 --- a/apps/sim/app/api/v2/skills/route.test.ts +++ b/apps/sim/app/api/v2/skills/route.test.ts @@ -50,8 +50,9 @@ vi.mock('@/lib/skills/application/use-cases', () => ({ createSkillUseCase: { operation: { id: 'skills.create' }, execute: mocks.create }, })) +import { cursorScopeKey } from '@/lib/api/cursor-binding' import { PrincipalKindAuthorizationError } from '@/lib/core/application' -import { cursorFilterScope, cursorSortKey, encodeOffsetCursor } from '@/app/api/v2/lib/response' +import { cursorSortKey, encodeOffsetCursor } from '@/app/api/v2/lib/response' import { GET, POST } from '@/app/api/v2/skills/route' const WORKSPACE_ID = 'workspace-1' @@ -74,7 +75,7 @@ function skillCursor({ }): string { return encodeOffsetCursor( cursorSortKey(sortBy, sortOrder), - cursorFilterScope({ workspaceId: WORKSPACE_ID, search }), + cursorScopeKey({ workspaceId: WORKSPACE_ID, search }), offset ) } diff --git a/apps/sim/app/api/v2/skills/route.ts b/apps/sim/app/api/v2/skills/route.ts index d839331e2e2..a0bf8acdeb2 100644 --- a/apps/sim/app/api/v2/skills/route.ts +++ b/apps/sim/app/api/v2/skills/route.ts @@ -1,4 +1,5 @@ import { v2CreateSkillContract, v2ListSkillsContract } from '@/lib/api/contracts/v2/skills' +import { cursorScopeKey } from '@/lib/api/cursor-binding' import { defineV2JsonRoute, v2ApiKeyAuth, @@ -8,17 +9,12 @@ import { import { captureServerEvent } from '@/lib/posthog/server' import { skillOperations } from '@/lib/skills/application/operations' import { createSkillUseCase, listSkillsUseCase } from '@/lib/skills/application/use-cases' -import { - cursorFilterScope, - cursorSortKey, - decodeOffsetCursor, - encodeOffsetCursor, -} from '@/app/api/v2/lib/response' +import { cursorSortKey, decodeOffsetCursor, encodeOffsetCursor } from '@/app/api/v2/lib/response' import { toV2Skill, toV2SkillSummary } from '@/app/api/v2/skills/utils' /** Every param that changes which skills, in which order, this list returns. */ function skillCursorFilters(query: { workspaceId: string; search?: string }) { - return cursorFilterScope({ workspaceId: query.workspaceId, search: query.search }) + return cursorScopeKey({ workspaceId: query.workspaceId, search: query.search }) } export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/v2/tables/route.ts b/apps/sim/app/api/v2/tables/route.ts index c4caddbd024..ad9c104c3f2 100644 --- a/apps/sim/app/api/v2/tables/route.ts +++ b/apps/sim/app/api/v2/tables/route.ts @@ -1,14 +1,10 @@ import { v2CreateTableContract, v2ListTablesContract } from '@/lib/api/contracts/v2/tables' +import { cursorScopeKey } from '@/lib/api/cursor-binding' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { v2TableErrorPolicies } from '@/lib/table/api' import { tableOperations } from '@/lib/table/application/operations' import { createTableUseCase, listTablesUseCase } from '@/lib/table/application/tables' -import { - cursorFilterScope, - cursorSortKey, - encodeSortedCursor, - readSortedCursor, -} from '@/app/api/v2/lib/response' +import { cursorSortKey, encodeSortedCursor, readSortedCursor } from '@/app/api/v2/lib/response' import { toApiTable, toApiTables } from '@/app/api/v2/tables/utils' export const dynamic = 'force-dynamic' @@ -16,7 +12,7 @@ export const revalidate = 0 /** Every param that changes which tables, in which order, this list returns. */ function tableCursorFilters(query: { workspaceId: string; folderPath?: string; search?: string }) { - return cursorFilterScope({ + return cursorScopeKey({ workspaceId: query.workspaceId, folderPath: query.folderPath, search: query.search, diff --git a/apps/sim/app/api/v2/tables/utils.ts b/apps/sim/app/api/v2/tables/utils.ts index bcada019744..8c76bd452d7 100644 --- a/apps/sim/app/api/v2/tables/utils.ts +++ b/apps/sim/app/api/v2/tables/utils.ts @@ -4,11 +4,7 @@ import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' import type { MultipartError } from '@/lib/core/utils/multipart' import type { RowData, TableDefinition, TablePredicate, TableSchema } from '@/lib/table' import { getMaxRowsPerTable } from '@/lib/table/billing' -import { - buildColumnNameById, - getColumnId, - remapViewConfigColumnRefs, -} from '@/lib/table/column-keys' +import { buildColumnNameById, remapViewConfigColumnRefs } from '@/lib/table/column-keys' import { TableLockedError } from '@/lib/table/mutation-locks' import { predicateToFilter } from '@/lib/table/query-builder/converters' import { @@ -181,7 +177,7 @@ export function toApiView( * row `data`. Falls back to the id for a column that no longer exists. */ export function columnNameById(schema: TableSchema): (columnId: string) => string { - const nameById = new Map(schema.columns.map((column) => [getColumnId(column), column.name])) + const nameById = buildColumnNameById(schema.columns) return (columnId) => nameById.get(columnId) ?? columnId } diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/route.ts b/apps/sim/app/api/v2/workflows/[id]/runs/route.ts index a4147b2737d..d096e7d5775 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/route.ts @@ -3,19 +3,14 @@ import { v2ListWorkflowRunsContract, v2WorkflowRunListStatusValueSchema, } from '@/lib/api/contracts/v2/workflows' -import { REFILTERED_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' +import { cursorScopeKey, REFILTERED_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' import { INVALID_CURSOR_MESSAGE } from '@/lib/api/list-query' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { OrchestrationError } from '@/lib/core/orchestration/types' import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' import { listWorkflowRuns } from '@/lib/workflows/application/list-workflow-runs' import { workflowOperations } from '@/lib/workflows/application/operations' -import { - cursorFilterScope, - cursorSortKey, - decodeSortedCursor, - encodeSortedCursor, -} from '@/app/api/v2/lib/response' +import { cursorSortKey, decodeSortedCursor, encodeSortedCursor } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -25,7 +20,7 @@ function runCursorFilters( workflowId: string, query: { status?: string; trigger?: string; startDate?: string; endDate?: string } ) { - return cursorFilterScope({ + return cursorScopeKey({ workflowId, status: query.status, trigger: query.trigger, diff --git a/apps/sim/app/api/v2/workflows/route.ts b/apps/sim/app/api/v2/workflows/route.ts index 18ec3878f6f..516dbadac0d 100644 --- a/apps/sim/app/api/v2/workflows/route.ts +++ b/apps/sim/app/api/v2/workflows/route.ts @@ -1,5 +1,6 @@ import type { V2WorkflowListItem } from '@/lib/api/contracts/v2/workflows' import { v2CreateWorkflowContract, v2ListWorkflowsContract } from '@/lib/api/contracts/v2/workflows' +import { cursorScopeKey } from '@/lib/api/cursor-binding' import { defineV2JsonRoute, v2ApiKeyAuth, @@ -9,12 +10,7 @@ import { import { createWorkflow } from '@/lib/workflows/application/create-workflow' import { listWorkflows } from '@/lib/workflows/application/list-workflows' import { workflowOperations } from '@/lib/workflows/application/operations' -import { - cursorFilterScope, - cursorSortKey, - encodeSortedCursor, - readSortedCursor, -} from '@/app/api/v2/lib/response' +import { cursorSortKey, encodeSortedCursor, readSortedCursor } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -26,7 +22,7 @@ function workflowCursorFilters(query: { deployedOnly: boolean search?: string }) { - return cursorFilterScope({ + return cursorScopeKey({ workspaceId: query.workspaceId, folderPath: query.folderPath, deployedOnly: query.deployedOnly, diff --git a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts index a80ee298cec..b6034e3af3b 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts @@ -104,7 +104,7 @@ const FULL_SET_LISTS = [ * position. So all of them are stamped into the token and re-checked on the way * back in, and a mismatch is a 400 telling the caller to restart paging. * - * The stamp is applied by the route through `cursorFilterScope` + + * The stamp is applied by the route through `cursorScopeKey` + * `cursorSortKey` (`app/api/v2/lib/response.ts`), or, for the three lists whose * token is minted by a domain codec, by wrapping it with `encodeScopedCursor`. * The table-row lists bind inside their own codec (`lib/table/rows/cursor.ts`) diff --git a/apps/sim/lib/api/cursor-binding.test.ts b/apps/sim/lib/api/cursor-binding.test.ts index 21fe4e7f289..ad88846d846 100644 --- a/apps/sim/lib/api/cursor-binding.test.ts +++ b/apps/sim/lib/api/cursor-binding.test.ts @@ -4,7 +4,6 @@ import { describe, expect, it } from 'vitest' import { cursorScopeKey, unorderedScopePart } from '@/lib/api/cursor-binding' import { - cursorFilterScope, cursorSortKey, decodeOffsetCursor, decodeSortedCursor, @@ -31,7 +30,7 @@ import { describe('v2 cursor binding', () => { const sort = cursorSortKey('name', 'asc') const filters = { workspaceId: 'ws-1', search: undefined as string | undefined } - const scope = cursorFilterScope(filters) + const scope = cursorScopeKey(filters) describe('offset cursor', () => { it('resumes a cursor replayed under the same query state', () => { @@ -53,10 +52,10 @@ describe('v2 cursor binding', () => { const cursor = encodeOffsetCursor(sort, scope, 40) expect(() => - decodeOffsetCursor(cursor, sort, cursorFilterScope({ ...filters, search: 'deploy' })) + decodeOffsetCursor(cursor, sort, cursorScopeKey({ ...filters, search: 'deploy' })) ).toThrow(/requested filters/) expect(() => - decodeOffsetCursor(cursor, sort, cursorFilterScope({ ...filters, workspaceId: 'ws-2' })) + decodeOffsetCursor(cursor, sort, cursorScopeKey({ ...filters, workspaceId: 'ws-2' })) ).toThrow(/requested filters/) }) @@ -94,7 +93,7 @@ describe('v2 cursor binding', () => { */ it('rejects a cursor replayed under a different filter', () => { const cursor = encodeSortedCursor(sort, keys, scope) - const narrowed = cursorFilterScope({ ...filters, search: 'deploy' }) + const narrowed = cursorScopeKey({ ...filters, search: 'deploy' }) expect(decodeSortedCursor(cursor, sort, narrowed)).toEqual({ status: 'refiltered' }) expect(() => readSortedCursor(cursor, 'name', 'asc', narrowed)).toThrow(/requested filters/) @@ -136,7 +135,7 @@ describe('v2 cursor binding', () => { const cursor = encodeScopedCursor(scope, 'domain-token') expect(() => - readScopedCursor(cursor, cursorFilterScope({ ...filters, search: 'deploy' })) + readScopedCursor(cursor, cursorScopeKey({ ...filters, search: 'deploy' })) ).toThrow(/requested filters/) }) diff --git a/apps/sim/lib/api/cursor-binding.ts b/apps/sim/lib/api/cursor-binding.ts index 18b231fc535..5d543a66bf2 100644 --- a/apps/sim/lib/api/cursor-binding.ts +++ b/apps/sim/lib/api/cursor-binding.ts @@ -50,14 +50,6 @@ export type CursorScopePart = | null | undefined -/** - * Deterministic JSON: object keys sorted so two structurally equal values - * serialize identically regardless of the key order they arrived in, and - * `undefined` members dropped so an omitted param and an absent one agree. - * - * Array order is preserved — reordering an `in` list is treated as a different - * filter, which only ever costs a restart. - */ /** * Canonical form of a filter the query treats as an unordered SET. * @@ -87,6 +79,14 @@ export function unorderedScopePart(raw: string | undefined): string | undefined return members.length > 0 ? members.join(',') : undefined } +/** + * Deterministic JSON: object keys sorted so two structurally equal values + * serialize identically regardless of the key order they arrived in, and + * `undefined` members dropped so an omitted param and an absent one agree. + * + * Array order is preserved — reordering an `in` list is treated as a different + * filter, which only ever costs a restart. + */ export function canonicalJson(value: unknown): string { if (value instanceof Date) return JSON.stringify(value.toISOString()) if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null' diff --git a/packages/db/timestamps.test.ts b/packages/db/timestamps.test.ts index 78b39cb7628..c186bef130e 100644 --- a/packages/db/timestamps.test.ts +++ b/packages/db/timestamps.test.ts @@ -108,7 +108,7 @@ describe('naive timestamp UTC pinning', () => { it('registers the UTC parser on a bare postgres.js client', () => { const parse = resolveTimestampParser(false) - expect((parse(NAIVE_WIRE_VALUE) as Date).toISOString()).toBe(NAIVE_WIRE_INSTANT) + expect(parse(NAIVE_WIRE_VALUE)).toEqual(new Date(NAIVE_WIRE_INSTANT)) }) /** @@ -134,14 +134,13 @@ describe('naive timestamp UTC pinning', () => { * the read must not depend on which of the two layers got there first. */ it('recovers the same UTC instant through either parser once drizzle maps it', () => { - const throughDrizzleParser = naiveColumn.mapFromDriverValue( - resolveTimestampParser(true)(NAIVE_WIRE_VALUE) - ) as Date - const throughUtcParser = naiveColumn.mapFromDriverValue( - resolveTimestampParser(false)(NAIVE_WIRE_VALUE) - ) as Date - - expect(throughDrizzleParser.toISOString()).toBe(NAIVE_WIRE_INSTANT) - expect(throughUtcParser.toISOString()).toBe(NAIVE_WIRE_INSTANT) + const instant = new Date(NAIVE_WIRE_INSTANT) + + expect(naiveColumn.mapFromDriverValue(resolveTimestampParser(true)(NAIVE_WIRE_VALUE))).toEqual( + instant + ) + expect(naiveColumn.mapFromDriverValue(resolveTimestampParser(false)(NAIVE_WIRE_VALUE))).toEqual( + instant + ) }) }) From f01d93ec6e8dc2a803f2c5c5ce9e925040b52243 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 23:51:17 -0700 Subject: [PATCH 37/56] refactor: delete three pieces of surface this branch added with no consumer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `v2CursorSchema` had one caller, `v2PaginationFields`, in the same file, and its only parameter was a default nobody overrode — so the export and the parameter were both unreachable. Inlined into the pair it belongs to; the emitted schema and its description are byte-identical, so the generated OpenAPI does not move. `PatchedKeys` was declared `ReadonlySet | readonly string[]`, but all four callers pass `Object.keys(...)` and no test passes a set, which left the `instanceof Set` arm of `policyResolver` unreachable. Narrowed to the array form the callers actually use. `NUL_CHARACTER` was exported from `@sim/utils/string` and imported by nobody — every boundary imports `containsNulCharacter` instead. Kept as the module-local constant the predicate reads, dropped from the package surface. --- apps/sim/lib/api/contracts/v2/shared.ts | 27 +++++++++++++------------ apps/sim/lib/table/validation.ts | 4 ++-- packages/utils/src/string.ts | 2 +- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/apps/sim/lib/api/contracts/v2/shared.ts b/apps/sim/lib/api/contracts/v2/shared.ts index ed9e789adfc..50168053e33 100644 --- a/apps/sim/lib/api/contracts/v2/shared.ts +++ b/apps/sim/lib/api/contracts/v2/shared.ts @@ -361,25 +361,26 @@ export function v2LimitSchema(options: V2LimitOptions = {}) { .describe(described) } -/** - * The v2 `cursor` param: the opaque token a previous page returned as - * `nextCursor`. Empty is rejected rather than treated as "start over", so a - * caller that accidentally forwards an empty string learns about it instead of - * looping on page one. - */ -export function v2CursorSchema( - description = 'Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.' -) { - return z.string().min(1, 'cursor must be a non-empty token').optional().describe(description) -} - /** * The `limit` + `cursor` pair for a paged v2 list. Spread into a query object; * a list that returns `nextCursor` must accept both, and must actually apply * them. + * + * `cursor` is the opaque token a previous page returned as `nextCursor`. Empty + * is rejected rather than treated as "start over", so a caller that accidentally + * forwards an empty string learns about it instead of looping on page one. */ export function v2PaginationFields(options: V2LimitOptions = {}) { - return { limit: v2LimitSchema(options), cursor: v2CursorSchema() } + return { + limit: v2LimitSchema(options), + cursor: z + .string() + .min(1, 'cursor must be a non-empty token') + .optional() + .describe( + 'Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.' + ), + } } /** diff --git a/apps/sim/lib/table/validation.ts b/apps/sim/lib/table/validation.ts index e8298f1e41b..82a47579820 100644 --- a/apps/sim/lib/table/validation.ts +++ b/apps/sim/lib/table/validation.ts @@ -311,14 +311,14 @@ export type UncoercibleValuePolicy = 'reject' | 'null' * Omit it when every key in `data` is caller-supplied — a whole-row insert, or a * patch validated on its own. */ -export type PatchedKeys = ReadonlySet | readonly string[] +export type PatchedKeys = readonly string[] function policyResolver( policy: UncoercibleValuePolicy, patchedKeys: PatchedKeys | undefined ): (key: string) => UncoercibleValuePolicy { if (patchedKeys === undefined) return () => policy - const patched = patchedKeys instanceof Set ? patchedKeys : new Set(patchedKeys) + const patched = new Set(patchedKeys) return (key) => (patched.has(key) ? policy : 'null') } diff --git a/packages/utils/src/string.ts b/packages/utils/src/string.ts index 8adfb932e10..63c318271f9 100644 --- a/packages/utils/src/string.ts +++ b/packages/utils/src/string.ts @@ -6,7 +6,7 @@ * scan, the multipart field scan, and the canonical folder-path decoder — * rejects it, so the predicate lives here instead of being restated at each. */ -export const NUL_CHARACTER = '\u0000' +const NUL_CHARACTER = '\u0000' /** Reports whether `value` carries a `U+0000`. See {@link NUL_CHARACTER}. */ export function containsNulCharacter(value: string): boolean { From 872a8d4f8b149c8062113ea0e9cea0e5dee54154 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 13 Aug 2026 00:04:03 -0700 Subject: [PATCH 38/56] docs(v2): state why the local upload data-plane routes bypass the builders Both local-storage PUT routes use raw `withRouteHandler`. The global rule allows that only for documented protocol or lifecycle exceptions, and their TSDoc explained the OpenAPI exemption and the error envelope but never the builder bypass itself. Record the actual reason: a signed `upload-token` is the credential, so there is no API key, `Principal`, or semantic operation for a builder to authenticate and authorize against, and the body streams straight to storage rather than being parsed. --- .../api/v2/uploads/[uploadId]/parts/[partNumber]/route.ts | 4 ++++ apps/sim/app/api/v2/uploads/[uploadId]/route.ts | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.ts b/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.ts index d60c52ca30d..c433fe868ad 100644 --- a/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.ts +++ b/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.ts @@ -27,6 +27,10 @@ interface LocalPartRouteParams { * Local-storage data plane for signed multipart PUT URLs. Cloud deployments return provider URLs * instead, so this route is never in the cloud byte path. * + * Raw `withRouteHandler` rather than a v2 builder, for the same reason as the + * whole-object PUT beside it: a signed token credential and a streamed body, + * with no `Principal` or semantic operation for a builder to act on. + * * Absent from the public OpenAPI documents by design — see * `UNDOCUMENTED_V2_ROUTES` in `scripts/check-openapi-specs.ts` — but it answers * in the canonical `{ error: { code, message } }` envelope like the rest of the diff --git a/apps/sim/app/api/v2/uploads/[uploadId]/route.ts b/apps/sim/app/api/v2/uploads/[uploadId]/route.ts index a388c99a77c..2e8c819b373 100644 --- a/apps/sim/app/api/v2/uploads/[uploadId]/route.ts +++ b/apps/sim/app/api/v2/uploads/[uploadId]/route.ts @@ -22,6 +22,11 @@ interface LocalPutRouteParams { /** * Local-storage data plane for a signed whole-object PUT upload session. * + * Raw `withRouteHandler` rather than a v2 builder: the signed `upload-token` + * header is the credential, so there is no API key, `Principal`, or semantic + * operation for a builder to authenticate and authorize against, and the body + * is streamed straight to storage rather than parsed. + * * Absent from the public OpenAPI documents by design — see * `UNDOCUMENTED_V2_ROUTES` in `scripts/check-openapi-specs.ts` — but the error * envelope is not part of that exemption. This is the one step that moves the From cf186acb25563793d7a95a158cf160e636a20474 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 13 Aug 2026 00:12:19 -0700 Subject: [PATCH 39/56] test(v2): pin cursor-to-filter binding on the tables and runs lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The branch binds every paged cursor to the filters it was minted under, but the binding was enforced end-to-end on only 4 of 16 paged lists. The contract-level CURSOR_BINDINGS sweep looks like the safety net and is not: it checks each contract against a hand-maintained map of param names, never against what a route actually stamps into cursorScopeKey, so it stays green for a route that dropped the stamp entirely. Confirmed by deletion. Removing tableCursorFilters from both call sites on GET /v2/tables left all 8 tests passing, and the runs route was worse — its one relevant assertion was weakened from toEqual to toMatchObject in this same branch, leaving the new filter field unpinned. Adds a mint-then-replay test to each: a cursor minted under one filter set and replayed under another is a 400 that never reaches the use case, with a same-filter resume case as the control so the 400 cannot be satisfied by blanket rejection. Restores toEqual on the runs cursor payload, pinning that a filter is stamped without hardcoding the fingerprint. Both new guards were verified to fail: removing the binding reddens the refiltered test on tables, and both the refiltered and the re-armed toEqual test on runs. --- apps/sim/app/api/v2/tables/route.test.ts | 67 +++++++++++++++++++ .../api/v2/workflows/[id]/runs/route.test.ts | 30 ++++++++- 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/api/v2/tables/route.test.ts b/apps/sim/app/api/v2/tables/route.test.ts index 8c970c86700..89c64f6b4bd 100644 --- a/apps/sim/app/api/v2/tables/route.test.ts +++ b/apps/sim/app/api/v2/tables/route.test.ts @@ -36,6 +36,7 @@ vi.mock('@/lib/table/billing', () => ({ getMaxRowsPerTable: mocks.getMaxRowsPerTable, })) +import { REFILTERED_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { GET, POST } from '@/app/api/v2/tables/route' @@ -95,6 +96,72 @@ describe('/api/v2/tables', () => { mocks.create.mockResolvedValue({ table, folderPath: '/' }) }) + /** + * The cursor a page mints is bound to the filters that produced it, so + * resuming it under a different `search` or `folderPath` is a 400 rather than + * a page silently sequenced against rows the new filter excludes. Pins the + * binding end-to-end — both the mint in `present` and the read in `mapInput` — + * because the contract-level sweep only checks a hand-maintained map of param + * names and stays green when a route drops the stamp entirely. + */ + it('refuses a cursor minted under a different filter', async () => { + mocks.list.mockResolvedValue({ + tables: [{ table, folderPath: '/' }], + nextKeys: ['Contacts', 'table-1'], + sortBy: 'name', + sortOrder: 'asc', + }) + + const minted = await GET( + new NextRequest( + `http://localhost:3000/api/v2/tables?workspaceId=${WORKSPACE_ID}&limit=25&search=alpha` + ) + ) + const { nextCursor } = await minted.json() + expect(nextCursor).toEqual(expect.any(String)) + + mocks.list.mockClear() + const replayed = await GET( + new NextRequest( + `http://localhost:3000/api/v2/tables?workspaceId=${WORKSPACE_ID}&limit=25&search=beta&cursor=${encodeURIComponent(nextCursor)}` + ) + ) + + expect(replayed.status).toBe(400) + expect((await replayed.json()).error.message).toBe(REFILTERED_CURSOR_MESSAGE) + expect(mocks.list).not.toHaveBeenCalled() + }) + + it('resumes a cursor replayed under the filters it was minted with', async () => { + mocks.list.mockResolvedValue({ + tables: [{ table, folderPath: '/' }], + nextKeys: ['Contacts', 'table-1'], + sortBy: 'name', + sortOrder: 'asc', + }) + + const minted = await GET( + new NextRequest( + `http://localhost:3000/api/v2/tables?workspaceId=${WORKSPACE_ID}&limit=25&search=alpha` + ) + ) + const { nextCursor } = await minted.json() + + mocks.list.mockClear() + const resumed = await GET( + new NextRequest( + `http://localhost:3000/api/v2/tables?workspaceId=${WORKSPACE_ID}&limit=25&search=alpha&cursor=${encodeURIComponent(nextCursor)}` + ) + ) + + expect(resumed.status).toBe(200) + expect(mocks.list).toHaveBeenCalledWith({ + principal, + input: expect.objectContaining({ after: ['Contacts', 'table-1'] }), + request: expect.anything(), + }) + }) + it('lists through the semantic use case and preserves the cursor envelope', async () => { const request = new NextRequest( `http://localhost:3000/api/v2/tables?workspaceId=${WORKSPACE_ID}&limit=25` diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/runs/route.test.ts index bd56dece8b8..eb11ee3c02e 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/route.test.ts @@ -28,6 +28,7 @@ vi.mock('@/lib/workflows/application/list-workflow-runs', () => ({ }, })) +import { REFILTERED_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' import { NoWorkspaceAccessError, PersonalApiKeysDisabledError } from '@/lib/core/application' import { GET } from '@/app/api/v2/workflows/[id]/runs/route' @@ -136,12 +137,39 @@ describe('GET /api/v2/workflows/[id]/runs', () => { const body = await (await callGet('?order=asc')).json() - expect(JSON.parse(Buffer.from(body.nextCursor, 'base64').toString())).toMatchObject({ + expect(JSON.parse(Buffer.from(body.nextCursor, 'base64').toString())).toEqual({ sort: 'startedAt:asc', keys: ['2026-08-05T00:01:00.000Z', 'row-1'], + filter: expect.any(String), }) }) + /** + * Resuming a cursor under a different filter is a 400, not a page sequenced + * against rows the new filter excludes. The assertion above pins that a filter + * is stamped at all; this pins that the stamp is read back and enforced. + */ + it('refuses a cursor minted under a different filter', async () => { + mocks.listRuns.mockResolvedValueOnce({ + data: EXECUTIONS, + nextCursor: { startedAt: EXECUTIONS[1].startedAt, rowId: 'row-1' }, + workflowId: 'workflow-1', + order: 'asc', + }) + + const { nextCursor } = await (await callGet('?order=asc&status=completed')).json() + expect(nextCursor).toEqual(expect.any(String)) + + mocks.listRuns.mockClear() + const replayed = await callGet( + `?order=asc&status=failed&cursor=${encodeURIComponent(nextCursor)}` + ) + + expect(replayed.status).toBe(400) + expect((await replayed.json()).error.message).toBe(REFILTERED_CURSOR_MESSAGE) + expect(mocks.listRuns).not.toHaveBeenCalled() + }) + it('rejects an invalid cursor after API-key admission without calling the use case', async () => { const response = await callGet('?cursor=not-a-cursor') From 9aaf8655a0f05fdda0c6c58c177779848aefb48f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 13 Aug 2026 00:12:23 -0700 Subject: [PATCH 40/56] fix(tables): keep the v2 write strictness inside v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The write-path tightening on this branch changed shared code that every first-party surface reaches, so the workspace grid, the internal `/api/table` routes, `/api/v1`, the Copilot table tools, and the executor's Table block all inherited a contract only `/api/v2` publishes. Each of them now behaves exactly as it does on staging again, and v2 keeps the strictness by opting into it. - `coerceRowValues`/`coerceRowToSchema` default to the `null` policy again — an uncoercible optional cell is blanked and the row is written. `reject` is reached through `RowWriteOptions.uncoercibleValues`, which the v2 row routes set via `strictWrite` on the application input. - The same `strictWrite` scopes the unknown-column refusal to v2. Copilot feeds the model's raw arguments in unfiltered, so a hallucinated key, an echoed `id`, or a name left over from a rename had begun refusing the whole write. - Multiselect and bare-epoch values land again for first-party callers through the registry's existing `salvage` hook, which the `null` policy already consults; the grid's `cleanCellValue` consults it too, so a paste naming one live option and one deleted one keeps the live one instead of erasing the cell. - The saved-view name→id remap no longer rewrites a ref that already means something else, so a user column named `id`/`createdAt`/`updatedAt` cannot hijack a view's system-column sort or filter. - `createTableView` tolerates the refs its own config carries unless the caller is strict, so "Save as view" stops 400ing on a dangling filter the Save chip accepts. - The bulk update runner is byte-identical to staging again. The 100-view cap stays: the list read is unpaginated, so the promise it makes only holds if the write side enforces it, and it refuses a new view rather than an existing config. --- .../[tableId]/rows/[rowId]/route.test.ts | 1 + .../v2/tables/[tableId]/rows/[rowId]/route.ts | 1 + .../v2/tables/[tableId]/rows/route.test.ts | 5 + .../app/api/v2/tables/[tableId]/rows/route.ts | 3 + .../[tableId]/rows/upsert/route.test.ts | 1 + .../v2/tables/[tableId]/rows/upsert/route.ts | 1 + .../tables/[tableId]/utils.test.ts | 21 ++-- .../[workspaceId]/tables/[tableId]/utils.ts | 23 ++-- .../lib/table/__tests__/update-row.test.ts | 21 +++- .../lib/table/__tests__/validation.test.ts | 44 +++++--- apps/sim/lib/table/application/rows.test.ts | 98 ++++++++++++++--- apps/sim/lib/table/application/rows.ts | 68 +++++++++--- apps/sim/lib/table/application/views.ts | 2 + apps/sim/lib/table/rows/service.ts | 104 +++++++++++------- apps/sim/lib/table/update-runner.ts | 7 +- apps/sim/lib/table/validation.test.ts | 69 ++++++------ apps/sim/lib/table/validation.ts | 44 ++++---- apps/sim/lib/table/views/service.test.ts | 66 ++++++++++- apps/sim/lib/table/views/service.ts | 67 +++++++++-- 19 files changed, 456 insertions(+), 190 deletions(-) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.test.ts index 276751f0723..387473a3935 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.test.ts @@ -131,6 +131,7 @@ describe('/api/v2/tables/[tableId]/rows/[rowId]', () => { rowId: 'row-1', assertedWorkspaceId: WORKSPACE_ID, data: { name: 'Ada' }, + strictWrite: true, }, request: req, }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts index 446ecf0ee97..465577e099a 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts @@ -41,6 +41,7 @@ export const PATCH = defineV2JsonRoute({ rowId: params.rowId, assertedWorkspaceId: body.workspaceId, data: body.data, + strictWrite: true, }), useCase: updateTableRow, present: ({ table, row }) => ({ diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/route.test.ts index dc1036483b6..d36a43f1516 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/route.test.ts @@ -154,6 +154,10 @@ describe('/api/v2/tables/[tableId]/rows', () => { tableId: 'table-1', assertedWorkspaceId: WORKSPACE_ID, data: { name: 'Ada' }, + // v2 alone opts into the strict write contract: an unknown column name + // or a value the column cannot hold is a 400, not a dropped key or a + // nulled cell. Every first-party surface leaves this unset. + strictWrite: true, }, request: single, }) @@ -170,6 +174,7 @@ describe('/api/v2/tables/[tableId]/rows', () => { tableId: 'table-1', assertedWorkspaceId: WORKSPACE_ID, rows: [{ name: 'Ada' }], + strictWrite: true, }, request: batch, }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts index 2d7d9592135..e309607496f 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts @@ -54,6 +54,7 @@ export const POST = defineV2JsonRoute({ tableId: params.tableId, assertedWorkspaceId: body.workspaceId, rows: body.rows, + strictWrite: true, } : { kind: 'single' as const, @@ -62,6 +63,7 @@ export const POST = defineV2JsonRoute({ data: body.data, afterRowId: body.afterRowId, beforeRowId: body.beforeRowId, + strictWrite: true, }, useCase: createTableRows, present: (result) => { @@ -89,6 +91,7 @@ export const PATCH = defineV2JsonRoute({ filter: body.filter, data: body.data, limit: body.limit, + strictWrite: true, }), useCase: updateTableRows, present: ({ affectedCount, affectedRowIds }) => ({ diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.test.ts index 96e633d0834..e17f2760632 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.test.ts @@ -102,6 +102,7 @@ describe('POST /api/v2/tables/[tableId]/rows/upsert', () => { assertedWorkspaceId: WORKSPACE_ID, data: { email: 'ada@example.com' }, conflictTarget: 'email', + strictWrite: true, }, request, }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.ts index 6c2d84285de..26550374d43 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.ts @@ -20,6 +20,7 @@ export const POST = defineV2JsonRoute({ assertedWorkspaceId: body.workspaceId, data: body.data, conflictTarget: body.conflictTarget, + strictWrite: true, }), useCase: upsertTableRow, present: ({ table, row, operation }) => ({ diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.test.ts index ee3aabb867e..146fb11cc23 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.test.ts @@ -156,21 +156,17 @@ describe('cleanCellValue', () => { expect(cleanCellValue('Bug, Docs', column)).toEqual(['opt_a', 'opt_b']) expect(cleanCellValue(['opt_b'], column)).toEqual(['opt_b']) expect(cleanCellValue('Bug, Bug', column)).toEqual(['opt_a']) - // A part matching no option is refused rather than silently dropped: this helper - // runs the same registry coercion the server does, and the server now rejects it. - expect(cleanCellValue('Nope', column)).toBeNull() - expect(cleanCellValue('Bug, Nope', column)).toBeNull() + expect(cleanCellValue('Nope', column)).toEqual([]) }) /** - * The refusal above is `coerce`'s, not the last word the registry has on the - * value: `salvage` reads the same paste as the one option that resolved. That - * reading is reserved for writes with no caller to answer — a CSV row, a block - * output — and a typed cell has one, so this helper must not reach for it. The - * pairing is asserted rather than described so a future helper that "improves" - * the paste by salvaging it fails here. + * The grid writes through a first-party route, which runs the `null` policy — + * a member the paste names that resolves to no option is dropped, and the ones + * that do resolve are kept. Erasing the cell instead would lose two live + * options over one deleted one. The registry pairing is asserted rather than + * described so a helper that stops consulting `salvage` fails here. */ - it('refuses a partial multiselect paste the registry could still salvage', () => { + it('keeps the members of a partial multiselect paste that still resolve', () => { const column = { name: 'tags', type: 'select', @@ -181,11 +177,12 @@ describe('cleanCellValue', () => { ], } as const + expect(columnTypeOf(column).coerce('Bug, Nope', column)).toEqual({ ok: false }) expect(columnTypeOf(column).salvage?.('Bug, Nope', column)).toEqual({ ok: true, value: ['opt_a'], }) - expect(cleanCellValue('Bug, Nope', column)).toBeNull() + expect(cleanCellValue('Bug, Nope', column)).toEqual(['opt_a']) }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts index bce92873a42..69f7722d11d 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts @@ -21,16 +21,14 @@ export function generateColumnName(columns: ReadonlyArray<{ name: string }>): st /** * Coerce a value a person typed or pasted into a cell to that column's type. * Throws on invalid JSON, and answers `null` for everything else the column - * type refuses. + * type can read nothing from. * - * `null` is what the server would store for the same value, which is the point: - * the optimistic cache and the row that comes back agree. It deliberately does - * not consult `ColumnTypeDefinition.salvage`, which reads a refused value - * lossily — a multiselect paste naming one option that no longer exists blanks - * the cell here rather than storing the members that did resolve. Salvage is - * reserved for writes with no caller to answer, and this one has one: a person - * watching the cell, who is better served seeing the paste refused than seeing - * part of it silently kept. + * The result is what the server would store for the same value, which is the + * point: the optimistic cache and the row that comes back agree. The grid + * writes through a first-party route, which runs the `null` policy — so a + * refused value falls back to `ColumnTypeDefinition.salvage` here exactly as it + * does there, and a multiselect paste naming one live option and one deleted + * one keeps the live one instead of erasing the cell. */ export function cleanCellValue( value: unknown, @@ -56,8 +54,11 @@ export function cleanCellValue( // Everything else runs the SAME coercion the server will run, so the // optimistic cache holds exactly the value that gets persisted. - const coerced = columnTypeOf(column).coerce(value as JsonValue, column) - return coerced.ok ? coerced.value : null + const columnType = columnTypeOf(column) + const coerced = columnType.coerce(value as JsonValue, column) + if (coerced.ok) return coerced.value + const salvaged = columnType.salvage?.(value as JsonValue, column) + return salvaged?.ok ? salvaged.value : null } /** diff --git a/apps/sim/lib/table/__tests__/update-row.test.ts b/apps/sim/lib/table/__tests__/update-row.test.ts index 57a820f122f..cc2ed28df81 100644 --- a/apps/sim/lib/table/__tests__/update-row.test.ts +++ b/apps/sim/lib/table/__tests__/update-row.test.ts @@ -138,14 +138,31 @@ describe('updateRow — partial merge', () => { expect(data?.values).not.toContain(JSON.stringify({ name: 'Alice', age: 31 })) }) - it('holds only the patched keys to the strict policy when validating the merge', async () => { + it('blanks an uncoercible cell for a first-party caller, as it always has', async () => { + const { coerceRowToSchema } = await import('@/lib/table/validation') + await updateRow( + { tableId: 'tbl-1', rowId: 'row-1', data: { age: 31 }, workspaceId: 'ws-1' }, + TABLE, + 'req-1' + ) + + expect(coerceRowToSchema).toHaveBeenCalledWith( + { name: 'Alice', age: 31 }, + TABLE.schema, + undefined, + ['age'] + ) + }) + + it('holds only the patched keys to the strict policy a v2 caller opts into', async () => { // The merged row carries cells this request never sent. A legacy value in one // of them belongs to an earlier write and must not decide this one. const { coerceRowToSchema } = await import('@/lib/table/validation') await updateRow( { tableId: 'tbl-1', rowId: 'row-1', data: { age: 31 }, workspaceId: 'ws-1' }, TABLE, - 'req-1' + 'req-1', + { uncoercibleValues: 'reject' } ) expect(coerceRowToSchema).toHaveBeenCalledWith( diff --git a/apps/sim/lib/table/__tests__/validation.test.ts b/apps/sim/lib/table/__tests__/validation.test.ts index 308f73c143d..9d698c9d96b 100644 --- a/apps/sim/lib/table/__tests__/validation.test.ts +++ b/apps/sim/lib/table/__tests__/validation.test.ts @@ -358,15 +358,15 @@ describe('Validation', () => { expect(data.founded).toBe(1999) }) - it('rejects an un-coercible value for an optional number column', () => { + it('rejects an un-coercible value for an optional number column under `reject`', () => { const data = { name: 'Acme', founded: 2000, age: 'unknown' } - const result = coerceRowToSchema(data, schema) + const result = coerceRowToSchema(data, schema, 'reject') expect(result.valid).toBe(false) }) - it('nulls an un-coercible optional value under the `null` policy', () => { + it('nulls an un-coercible optional value by default', () => { const data = { name: 'Acme', founded: 2000, age: 'unknown' } - const result = coerceRowToSchema(data, schema, 'null') + const result = coerceRowToSchema(data, schema) expect(result.valid).toBe(true) expect(data.age).toBeNull() }) @@ -393,12 +393,20 @@ describe('Validation', () => { expect(data.active).toBe(false) }) - it('refuses a bare epoch number, whose unit the value cannot state', () => { + it('refuses a bare epoch number under `reject`, whose unit the value cannot state', () => { const data = { name: 'Acme', founded: 2000, created: Date.parse('2024-01-15T00:00:00Z') } - const result = coerceRowToSchema(data, schema) + const result = coerceRowToSchema(data, schema, 'reject') expect(result.valid).toBe(false) }) + it('coerces an epoch number to an ISO date string by default', () => { + const epoch = Date.parse('2024-01-15T00:00:00Z') + const data = { name: 'Acme', founded: 2000, created: epoch } + const result = coerceRowToSchema(data, schema) + expect(result.valid).toBe(true) + expect(data.created).toBe(new Date(epoch).toISOString()) + }) + it('coerces a Date instance to an ISO date string', () => { const date = new Date('2024-01-15T00:00:00Z') const data = { name: 'Acme', founded: 2000, created: date } @@ -407,16 +415,16 @@ describe('Validation', () => { expect(data.created).toBe(date.toISOString()) }) - it('nulls an out-of-range epoch number under the `null` policy without throwing', () => { + it('nulls an out-of-range epoch number without throwing', () => { const data = { name: 'Acme', founded: 2000, created: 1e20 } - const result = coerceRowToSchema(data, schema, 'null') + const result = coerceRowToSchema(data, schema) expect(result.valid).toBe(true) expect(data.created).toBeNull() }) - it('nulls an invalid Date instance under the `null` policy without throwing', () => { + it('nulls an invalid Date instance without throwing', () => { const data = { name: 'Acme', founded: 2000, created: new Date('not-a-date') } - const result = coerceRowToSchema(data, schema, 'null') + const result = coerceRowToSchema(data, schema) expect(result.valid).toBe(true) expect(data.created).toBeNull() }) @@ -451,15 +459,15 @@ describe('Validation', () => { expect(patch.age).toBe(42) }) - it('leaves an un-coercible optional patch value in place for downstream validation', () => { + it('leaves an un-coercible optional patch value in place under `reject`', () => { const patch: { age: unknown } = { age: 'nope' } - coerceRowValues(patch as never, schema) + coerceRowValues(patch as never, schema, 'reject') expect(patch.age).toBe('nope') }) - it('nulls an un-coercible optional patch value under the `null` policy', () => { + it('nulls an un-coercible optional patch value by default', () => { const patch: { age: unknown } = { age: 'nope' } - coerceRowValues(patch as never, schema, 'null') + coerceRowValues(patch as never, schema) expect(patch.age).toBeNull() }) @@ -533,15 +541,15 @@ describe('Validation', () => { expect(patch.price).toBe(42) }) - it('leaves an unreadable amount in place on an optional column so validation reports it', () => { + it('leaves an unreadable amount in place on an optional column under `reject`', () => { const patch: Record = { price: 'ask sales' } - coerceRowValues(patch as never, currencySchema) + coerceRowValues(patch as never, currencySchema, 'reject') expect(patch.price).toBe('ask sales') }) - it('nulls an unreadable amount on an optional column under the `null` policy', () => { + it('nulls an unreadable amount on an optional column by default', () => { const patch: Record = { price: 'ask sales' } - coerceRowValues(patch as never, currencySchema, 'null') + coerceRowValues(patch as never, currencySchema) expect(patch.price).toBeNull() }) diff --git a/apps/sim/lib/table/application/rows.test.ts b/apps/sim/lib/table/application/rows.test.ts index 2cce0ad9671..2f8c9325562 100644 --- a/apps/sim/lib/table/application/rows.test.ts +++ b/apps/sim/lib/table/application/rows.test.ts @@ -493,17 +493,18 @@ describe('replaceTableRows application use case', () => { ], }, TABLE, - 'request-1' + 'request-1', + {} ) expect(result).toMatchObject({ deletedCount: 2, insertedCount: 1 }) expect(mockSignalRowsChanged).toHaveBeenCalledWith(TABLE.id) }) - it('refuses a replacement row naming a column the table does not have', async () => { + it('refuses a replacement row naming an unknown column for a strict caller', async () => { await expect( replaceTableRows.execute({ principal: PRINCIPAL, - input: { tableId: TABLE.id, rows: [{ name: 'Ada', unknown: 'x' }] }, + input: { tableId: TABLE.id, rows: [{ name: 'Ada', unknown: 'x' }], strictWrite: true }, }) ).rejects.toThrow(/Row 1: Unknown column: unknown/) expect(mockReplaceRowsPrimitive).not.toHaveBeenCalled() @@ -803,7 +804,8 @@ describe('row query and upsert application semantics', () => { userId: PRINCIPAL.userId, }), TABLE, - 'request-1' + 'request-1', + {} ) }) }) @@ -850,7 +852,8 @@ describe('table row write secret provenance defaulting', () => { expect(mockInsertRow).toHaveBeenCalledWith( expect.objectContaining({ secretProvenance: EXACT_EMPTY_NAME }), TABLE, - expect.any(String) + expect.any(String), + {} ) }) @@ -865,7 +868,8 @@ describe('table row write secret provenance defaulting', () => { secretProvenance: [EXACT_EMPTY_NAME, EXACT_EMPTY_NAME], }), TABLE, - expect.any(String) + expect.any(String), + {} ) }) @@ -878,7 +882,8 @@ describe('table row write secret provenance defaulting', () => { expect(mockUpdateRow).toHaveBeenCalledWith( expect.objectContaining({ secretProvenance: EXACT_EMPTY_NAME }), TABLE, - expect.any(String) + expect.any(String), + {} ) }) @@ -913,7 +918,8 @@ describe('table row write secret provenance defaulting', () => { columns: { column_name: { version: 1, complete: true, entries: [] } }, }, }), - expect.any(String) + expect.any(String), + {} ) }) @@ -926,7 +932,8 @@ describe('table row write secret provenance defaulting', () => { expect(mockUpsertRow).toHaveBeenCalledWith( expect.objectContaining({ secretProvenance: EXACT_EMPTY_NAME }), TABLE, - expect.any(String) + expect.any(String), + {} ) }) @@ -939,7 +946,8 @@ describe('table row write secret provenance defaulting', () => { expect(mockReplaceRowsPrimitive).toHaveBeenCalledWith( expect.objectContaining({ secretProvenance: [EXACT_EMPTY_NAME] }), TABLE, - expect.any(String) + expect.any(String), + {} ) }) @@ -959,7 +967,8 @@ describe('table row write secret provenance defaulting', () => { expect(mockUpdateRow).toHaveBeenCalledWith( expect.objectContaining({ secretProvenance: unknown }), TABLE, - expect.any(String) + expect.any(String), + {} ) }) }) @@ -970,8 +979,13 @@ describe('table row write secret provenance defaulting', () => { * therefore answered 201 having created an empty row, and a patch of * `{"zzz":"x"}` answered `updatedCount: 0` — the same answer a predicate that * matched nothing gives, so a caller could not tell a typo from an empty match. + * + * The refusal is scoped to `strictWrite`, which only `/api/v2` sets. A + * first-party caller still has the key dropped: Copilot feeds the model's raw + * arguments in unfiltered, so a hallucinated key, an echoed `id`, or a name + * left over from a rename would otherwise refuse the whole write. */ -describe('unknown column names are refused, not dropped', () => { +describe('unknown column names under strictWrite', () => { beforeEach(() => { vi.clearAllMocks() mockResolvePermission.mockResolvedValue('write') @@ -996,17 +1010,37 @@ describe('unknown column names are refused, not dropped', () => { await expect( createTableRows.execute({ principal: PRINCIPAL, - input: { kind: 'single', tableId: TABLE.id, data: { nosuchcol: 'x' } }, + input: { kind: 'single', tableId: TABLE.id, data: { nosuchcol: 'x' }, strictWrite: true }, }) ).rejects.toThrow(/Unknown column: nosuchcol/) expect(mockInsertRow).not.toHaveBeenCalled() }) + it('drops the same key for a first-party caller instead of refusing the write', async () => { + await expect( + createTableRows.execute({ + principal: PRINCIPAL, + input: { kind: 'single', tableId: TABLE.id, data: { name: 'Ada', nosuchcol: 'x' } }, + }) + ).resolves.toBeDefined() + expect(mockInsertRow).toHaveBeenCalledWith( + expect.objectContaining({ data: { 'column-name': 'Ada' } }), + TABLE, + expect.any(String), + {} + ) + }) + it('names every unknown column at once', async () => { await expect( createTableRows.execute({ principal: PRINCIPAL, - input: { kind: 'single', tableId: TABLE.id, data: { zzz: 'x', qqq: 'y' } }, + input: { + kind: 'single', + tableId: TABLE.id, + data: { zzz: 'x', qqq: 'y' }, + strictWrite: true, + }, }) ).rejects.toThrow(/Unknown columns: zzz, qqq/) }) @@ -1015,7 +1049,12 @@ describe('unknown column names are refused, not dropped', () => { await expect( createTableRows.execute({ principal: PRINCIPAL, - input: { kind: 'batch', tableId: TABLE.id, rows: [{ name: 'Ada' }, { zzz: 'x' }] }, + input: { + kind: 'batch', + tableId: TABLE.id, + rows: [{ name: 'Ada' }, { zzz: 'x' }], + strictWrite: true, + }, }) ).rejects.toThrow(/Row 2: Unknown column: zzz/) expect(mockBatchInsertRows).not.toHaveBeenCalled() @@ -1029,6 +1068,7 @@ describe('unknown column names are refused, not dropped', () => { tableId: TABLE.id, filter: { all: [{ field: 'name', op: 'eq', value: 'Ada' }] }, data: { zzz: 'x' }, + strictWrite: true, }, }) ).rejects.toThrow(/Unknown column: zzz/) @@ -1039,12 +1079,22 @@ describe('unknown column names are refused, not dropped', () => { await expect( updateTableRow.execute({ principal: PRINCIPAL, - input: { tableId: TABLE.id, rowId: 'row-1', data: { zzz: 'x' } }, + input: { tableId: TABLE.id, rowId: 'row-1', data: { zzz: 'x' }, strictWrite: true }, }) ).rejects.toThrow(/Unknown column: zzz/) expect(mockUpdateRow).not.toHaveBeenCalled() }) + it('reports an empty match for the same first-party update instead of refusing', async () => { + await expect( + updateTableRow.execute({ + principal: PRINCIPAL, + input: { tableId: TABLE.id, rowId: 'row-1', data: { zzz: 'x' } }, + }) + ).resolves.toBeDefined() + expect(mockUpdateRow).toHaveBeenCalled() + }) + it('still accepts a write naming only known columns', async () => { await expect( createTableRows.execute({ @@ -1053,4 +1103,20 @@ describe('unknown column names are refused, not dropped', () => { }) ).resolves.toBeDefined() }) + + it('carries the strict value policy to the primitive, and nothing without it', async () => { + await createTableRows.execute({ + principal: PRINCIPAL, + input: { kind: 'single', tableId: TABLE.id, data: { name: 'Ada' }, strictWrite: true }, + }) + expect(mockInsertRow).toHaveBeenLastCalledWith(expect.anything(), TABLE, expect.any(String), { + uncoercibleValues: 'reject', + }) + + await createTableRows.execute({ + principal: PRINCIPAL, + input: { kind: 'single', tableId: TABLE.id, data: { name: 'Ada' } }, + }) + expect(mockInsertRow).toHaveBeenLastCalledWith(expect.anything(), TABLE, expect.any(String), {}) + }) }) diff --git a/apps/sim/lib/table/application/rows.ts b/apps/sim/lib/table/application/rows.ts index 5bc80ef2b8e..28de2afdf05 100644 --- a/apps/sim/lib/table/application/rows.ts +++ b/apps/sim/lib/table/application/rows.ts @@ -61,7 +61,7 @@ import { createUnknownTableRowSecretProvenance, loadTableRowSecretProvenance, } from '@/lib/table/rows/secret-provenance' -import type { FindRowMatch } from '@/lib/table/rows/service' +import type { FindRowMatch, RowWriteOptions } from '@/lib/table/rows/service' import { replaceTableRowsWithTx } from '@/lib/table/rows/service' import { predicateToStorage } from '@/lib/table/select-values' import { coerceRowValues } from '@/lib/table/validation' @@ -81,6 +81,23 @@ interface TableScopedInput { tableId: string assertedWorkspaceId?: string requestId?: string + /** + * Whether the calling surface publishes the stricter `/api/v2` write contract: + * a row naming a column the table does not have is refused rather than having + * that key dropped, and a value the column's type cannot coerce is answered + * with a 400 rather than stored as `null`. + * + * Absent — every first-party surface, and the only behavior any of them has + * ever had: the workspace grid, the internal `/api/table` routes, `/api/v1`, + * the Copilot table tools, and the executor's Table block all drop the + * unknown key and blank the uncoercible cell. Read-only use cases ignore it. + */ + strictWrite?: boolean +} + +/** The write policy `strictWrite` selects, for the row-service primitives. */ +function rowWriteOptions(input: TableScopedInput): RowWriteOptions { + return input.strictWrite ? { uncoercibleValues: 'reject' } : {} } interface TableResult { @@ -116,7 +133,8 @@ function actorUserId( } /** - * Refuses a wire row naming a column the table does not have. + * Refuses a wire row naming a column the table does not have. Applied only to a + * `strictWrite` caller — see {@link TableScopedInput.strictWrite}. * * The name→id remap drops unrecognised keys, so without this an insert of * `{"nosuchcol":"x"}` created an empty row under a 201, and a patch of @@ -138,9 +156,9 @@ function assertKnownColumnNames( ) } -function namedDataToStorage(data: RowData, table: TableDefinition): RowData { +function namedDataToStorage(data: RowData, table: TableDefinition, strict = false): RowData { const idByName = buildIdByName(table.schema) - assertKnownColumnNames(data, idByName) + if (strict) assertKnownColumnNames(data, idByName) return rowDataNameToId(data, idByName) } @@ -149,10 +167,14 @@ function namedDataToStorage(data: RowData, table: TableDefinition): RowData { * whole batch rather than per row — these paths run over up to * `MAX_BATCH_INSERT_SIZE` rows. */ -function namedRowsToStorage(rows: readonly RowData[], table: TableDefinition): RowData[] { +function namedRowsToStorage( + rows: readonly RowData[], + table: TableDefinition, + strict = false +): RowData[] { const idByName = buildIdByName(table.schema) return rows.map((row, index) => { - assertKnownColumnNames(row, idByName, `Row ${index + 1}`) + if (strict) assertKnownColumnNames(row, idByName, `Row ${index + 1}`) return rowDataNameToId(row, idByName) }) } @@ -433,12 +455,14 @@ export const createTableRows = defineAuthorizedTableUseCase({ ) { throw new TableRowsValidationError('Position must be 0 or greater') } - const data = namedDataToStorage(input.data, context.table) + const data = namedDataToStorage(input.data, context.table, input.strictWrite) + const writeOptions = rowWriteOptions(input) await throwValidationResponse( await validateRowData({ rowData: data, schema: context.table.schema, tableId: context.tableId, + uncoercibleValues: writeOptions.uncoercibleValues, }) ) const row = await insertRow( @@ -453,7 +477,8 @@ export const createTableRows = defineAuthorizedTableUseCase({ secretProvenance: defaultedRowSecretProvenance(data, input.secretProvenance), }, context.table, - requestId(input) + requestId(input), + writeOptions ) return { kind: 'single', table: context.table, row } } @@ -468,12 +493,14 @@ export const createTableRows = defineAuthorizedTableUseCase({ if (input.orderKeys && input.orderKeys.length !== input.rows.length) { throw new TableRowsValidationError('orderKeys must align one-to-one with rows') } - const rows = namedRowsToStorage(input.rows, context.table) + const rows = namedRowsToStorage(input.rows, context.table, input.strictWrite) + const batchWriteOptions = rowWriteOptions(input) await throwValidationResponse( await validateBatchRows({ rows, schema: context.table.schema, tableId: context.tableId, + uncoercibleValues: batchWriteOptions.uncoercibleValues, }) ) const created = await batchInsertRows( @@ -486,7 +513,8 @@ export const createTableRows = defineAuthorizedTableUseCase({ secretProvenance: defaultedRowsSecretProvenance(rows, input.secretProvenance), }, context.table, - requestId(input) + requestId(input), + batchWriteOptions ) return { kind: 'batch', table: context.table, rows: created } }, @@ -518,7 +546,7 @@ export const replaceTableRows = defineAuthorizedTableUseCase({ throw new TableRowsValidationError('Secret provenance must align one-to-one with rows') } - const rows = namedRowsToStorage(input.rows, context.table) + const rows = namedRowsToStorage(input.rows, context.table, input.strictWrite) const result = await replaceTableRowsPrimitive( { tableId: context.tableId, @@ -528,7 +556,8 @@ export const replaceTableRows = defineAuthorizedTableUseCase({ secretProvenance: defaultedRowsSecretProvenance(rows, input.secretProvenance), }, context.table, - requestId(input) + requestId(input), + rowWriteOptions(input) ) return { table: context.table, ...result } }, @@ -722,7 +751,7 @@ export const updateTableRow = defineAuthorizedTableUseCase({ operation: tableOperations.updateRow, resolveContext: ({ input }: { input: UpdateTableRowInput }) => resolveActiveTableContext(input), async execute({ principal, input, context }): Promise { - const data = namedDataToStorage(input.data, context.table) + const data = namedDataToStorage(input.data, context.table, input.strictWrite) const row = await updateRow( { tableId: context.tableId, @@ -733,7 +762,8 @@ export const updateTableRow = defineAuthorizedTableUseCase({ secretProvenance: defaultedRowSecretProvenance(data, input.secretProvenance), }, context.table, - requestId(input) + requestId(input), + rowWriteOptions(input) ) if (!row) throw new Error('Unconditional table row update was rejected') return { @@ -770,7 +800,7 @@ export const updateTableRows = defineAuthorizedTableUseCase({ if (input.limit !== undefined) { requireIntegerInRange(input.limit, 1, TABLE_LIMITS.MAX_BULK_OPERATION_SIZE, 'Limit') } - const data = namedDataToStorage(input.data, context.table) + const data = namedDataToStorage(input.data, context.table, input.strictWrite) const result = await updateRowsByFilter( context.table, { @@ -780,7 +810,8 @@ export const updateTableRows = defineAuthorizedTableUseCase({ actorUserId: actorUserId(principal, context.billedAccountUserId), secretProvenance: defaultedRowSecretProvenance(data, input.secretProvenance), }, - requestId(input) + requestId(input), + rowWriteOptions(input) ) return { table: context.table, ...result } } catch (error) { @@ -893,7 +924,7 @@ export const upsertTableRow = defineAuthorizedTableUseCase({ const conflictTarget = input.conflictTarget ? (buildIdByName(context.table.schema).get(input.conflictTarget) ?? input.conflictTarget) : undefined - const data = namedDataToStorage(input.data, context.table) + const data = namedDataToStorage(input.data, context.table, input.strictWrite) const result = await upsertRow( { tableId: context.tableId, @@ -904,7 +935,8 @@ export const upsertTableRow = defineAuthorizedTableUseCase({ secretProvenance: defaultedRowSecretProvenance(data, input.secretProvenance), }, context.table, - requestId(input) + requestId(input), + rowWriteOptions(input) ) return { table: context.table, row: result.row, operation: result.operation } }, diff --git a/apps/sim/lib/table/application/views.ts b/apps/sim/lib/table/application/views.ts index b5568e6b237..af59d187e43 100644 --- a/apps/sim/lib/table/application/views.ts +++ b/apps/sim/lib/table/application/views.ts @@ -84,6 +84,7 @@ export const createTableViewUseCase = defineAuthorizedTableUseCase({ config: input.config, userId: attribution.attributedUserId, columns, + strictRefs: true, }) return { view, table: context.table, columns } } catch (error) { @@ -135,6 +136,7 @@ export const updateTableViewUseCase = defineAuthorizedTableUseCase({ configPatch: input.configPatch, isDefault: input.isDefault, columns, + strictRefs: true, }) if (!view) throw new OrchestrationError('not_found', 'View not found') return { diff --git a/apps/sim/lib/table/rows/service.ts b/apps/sim/lib/table/rows/service.ts index c0b446c44ec..3b920598969 100644 --- a/apps/sim/lib/table/rows/service.ts +++ b/apps/sim/lib/table/rows/service.ts @@ -120,10 +120,22 @@ const logger = createLogger('TableRowsService') * @returns Inserted row * @throws Error if validation fails or capacity exceeded */ +export interface RowWriteOptions { + /** + * What this write does with a value its column's type cannot coerce. Defaults + * to `null` — the cell is blanked and the write succeeds, which is what every + * first-party surface (the workspace grid, `/api/table`, `/api/v1`, the + * Copilot table tools, the executor's Table block) has always done. The + * `/api/v2` surface opts into `reject`. See {@link UncoercibleValuePolicy}. + */ + uncoercibleValues?: UncoercibleValuePolicy +} + export async function insertRow( data: InsertRowData, table: TableDefinition, - requestId: string + requestId: string, + options: RowWriteOptions = {} ): Promise { const insertProof = assertRowInsert(table) @@ -134,7 +146,7 @@ export async function insertRow( } // Validate against schema - const schemaValidation = coerceRowToSchema(data.data, table.schema) + const schemaValidation = coerceRowToSchema(data.data, table.schema, options.uncoercibleValues) if (!schemaValidation.valid) { throw new OrchestrationError( 'validation', @@ -228,7 +240,8 @@ export async function insertRow( export async function batchInsertRows( data: BatchInsertData, table: TableDefinition, - requestId: string + requestId: string, + options: RowWriteOptions = {} ): Promise { // Best-effort capacity check against the workspace's current plan limit. Import // paths call `batchInsertRowsWithTx` directly and gate capacity up front instead. @@ -238,7 +251,9 @@ export async function batchInsertRows( addedRows: data.rows.length, }) - const result = await db.transaction((trx) => batchInsertRowsWithTx(trx, data, table, requestId)) + const result = await db.transaction((trx) => + batchInsertRowsWithTx(trx, data, table, requestId, options) + ) notifyTableRowUsage({ workspaceId: table.workspaceId, currentRowCount: table.rowCount, @@ -262,7 +277,8 @@ export async function batchInsertRowsWithTx( trx: DbTransaction, data: BatchInsertData, table: TableDefinition, - requestId: string + requestId: string, + options: RowWriteOptions = {} ): Promise { assertRowInsert(table) @@ -277,7 +293,7 @@ export async function batchInsertRowsWithTx( ) } - const schemaValidation = coerceRowToSchema(row, table.schema) + const schemaValidation = coerceRowToSchema(row, table.schema, options.uncoercibleValues) if (!schemaValidation.valid) { throw new OrchestrationError( 'validation', @@ -402,7 +418,8 @@ export function dispatchAfterBatchInsert( export async function replaceTableRows( data: ReplaceRowsData, table: TableDefinition, - requestId: string + requestId: string, + options: RowWriteOptions = {} ): Promise { // All existing rows are deleted, so the footprint is just the new set. Checked // before the tx opens — never inside it (the plan lookup is a separate pool read). @@ -411,7 +428,9 @@ export async function replaceTableRows( currentRowCount: 0, addedRows: data.rows.length, }) - const result = await db.transaction((trx) => replaceTableRowsWithTx(trx, data, table, requestId)) + const result = await db.transaction((trx) => + replaceTableRowsWithTx(trx, data, table, requestId, options) + ) notifyTableRowUsage({ workspaceId: table.workspaceId, currentRowCount: 0, @@ -432,7 +451,8 @@ export async function replaceTableRowsWithTx( trx: DbTransaction, data: ReplaceRowsData, table: TableDefinition, - requestId: string + requestId: string, + options: RowWriteOptions = {} ): Promise { assertRowDelete(table) assertRowInsert(table) @@ -455,7 +475,7 @@ export async function replaceTableRowsWithTx( ) } - const schemaValidation = coerceRowToSchema(row, table.schema) + const schemaValidation = coerceRowToSchema(row, table.schema, options.uncoercibleValues) if (!schemaValidation.valid) { throw new OrchestrationError( 'validation', @@ -591,7 +611,8 @@ export async function replaceTableRowsWithTx( export async function upsertRow( data: UpsertRowData, table: TableDefinition, - requestId: string + requestId: string, + options: RowWriteOptions = {} ): Promise { const schema = table.schema const uniqueColumns = getUniqueColumns(schema) @@ -643,7 +664,7 @@ export async function upsertRow( throw new OrchestrationError('validation', sizeValidation.errors.join(', ')) } - const schemaValidation = coerceRowToSchema(data.data, schema) + const schemaValidation = coerceRowToSchema(data.data, schema, options.uncoercibleValues) if (!schemaValidation.valid) { throw new OrchestrationError( 'validation', @@ -1553,7 +1574,7 @@ class GuardRejected extends Error { * @returns Updated row * @throws Error if row not found or validation fails */ -export interface UpdateRowOptions { +export interface UpdateRowOptions extends RowWriteOptions { /** * Marks the write as the workflow/enrichment engine filling its own output * cells, which exempts it from the update lock. Set by `cell-write.ts` only — @@ -1562,20 +1583,6 @@ export interface UpdateRowOptions { computedWrite?: boolean } -/** - * A computed write has no caller to answer with a 400 — the block already ran, - * and failing the write would strand the whole cell run over one output that - * does not fit its bound column. It blanks that cell instead. Every other write - * carries a value someone asked to store, so an uncoercible one is refused. - * - * The policy governs the patch's own keys. A merged row also carries cells this - * request never touched; those always follow `null`, whatever the policy — - * see `PatchedKeys` in `@/lib/table/validation`. - */ -function uncoercibleValuePolicy(options: { computedWrite?: boolean }): UncoercibleValuePolicy { - return options.computedWrite ? 'null' : 'reject' -} - /** * A row stores every cell in one jsonb `data` column, so a row update writes the changed cells as * an in-DB JSONB merge (`data = data || {changed}::jsonb`) rather than replacing the whole object. @@ -1637,7 +1644,7 @@ export async function updateRow( const schemaValidation = coerceRowToSchema( mergedData, table.schema, - uncoercibleValuePolicy(options), + options.uncoercibleValues, Object.keys(data.data) ) if (!schemaValidation.valid) { @@ -1823,13 +1830,14 @@ type BulkUpdateMatch = { id: string; data: RowData } function bulkUpdateValidationError( table: TableDefinition, row: BulkUpdateMatch, - patch: RowData + patch: RowData, + policy: UncoercibleValuePolicy | undefined ): string | null { const mergedData = { ...row.data, ...patch } const sizeValidation = validateRowSize(mergedData) if (!sizeValidation.valid) return sizeValidation.errors.join(', ') - const schemaValidation = coerceRowToSchema(mergedData, table.schema, 'reject', Object.keys(patch)) + const schemaValidation = coerceRowToSchema(mergedData, table.schema, policy, Object.keys(patch)) return schemaValidation.valid ? null : schemaValidation.errors.join(', ') } @@ -1837,10 +1845,11 @@ function bulkUpdateValidationError( function validateBulkUpdateMatches( table: TableDefinition, rows: BulkUpdateMatch[], - patch: RowData + patch: RowData, + policy: UncoercibleValuePolicy | undefined ): void { for (const row of rows) { - const error = bulkUpdateValidationError(table, row, patch) + const error = bulkUpdateValidationError(table, row, patch, policy) if (error) throw new OrchestrationError('validation', `Row ${row.id}: ${error}`) } } @@ -1855,8 +1864,19 @@ async function persistBulkUpdateBatch(params: { now: Date secretProvenance: BulkUpdateData['secretProvenance'] requestId: string + uncoercibleValues: UncoercibleValuePolicy | undefined }): Promise<{ rows: BulkUpdateMatch[]; affectedRowIds: string[] }> { - const { table, rows, patch, patchJson, filterClause, now, secretProvenance, requestId } = params + const { + table, + rows, + patch, + patchJson, + filterClause, + now, + secretProvenance, + requestId, + uncoercibleValues, + } = params const ids = rows.map((row) => row.id) const persistedRows: BulkUpdateMatch[] = [] const affectedRowIds = await db.transaction(async (trx) => { @@ -1881,7 +1901,8 @@ async function persistBulkUpdateBatch(params: { const skippedRowIds: string[] = [] for (const currentRow of currentRows) { const row = { id: currentRow.id, data: currentRow.data as RowData } - if (bulkUpdateValidationError(table, row, patch)) skippedRowIds.push(row.id) + if (bulkUpdateValidationError(table, row, patch, uncoercibleValues)) + skippedRowIds.push(row.id) else persistedRows.push(row) } if (skippedRowIds.length > 0) { @@ -1974,7 +1995,8 @@ function dispatchBulkUpdateEffects( export async function updateRowsByFilter( table: TableDefinition, data: BulkUpdateData, - requestId: string + requestId: string, + options: RowWriteOptions = {} ): Promise { assertRowUpdate(table, patchColumnIds(data.data)) if (Object.keys(data.data).length === 0) { @@ -1993,7 +2015,7 @@ export async function updateRowsByFilter( eq(userTableRows.workspaceId, table.workspaceId) ) - coerceRowValues(data.data, table.schema) + coerceRowValues(data.data, table.schema, options.uncoercibleValues) const uniqueColumns = getUniqueColumns(table.schema) const uniqueColumnsInUpdate = uniqueColumns.filter((col) => getColumnId(col) in data.data) const patchJson = JSON.stringify(data.data) @@ -2017,7 +2039,7 @@ export async function updateRowsByFilter( }) if (page.length === 0) break - validateBulkUpdateMatches(table, page, data.data) + validateBulkUpdateMatches(table, page, data.data, options.uncoercibleValues) matchingRowCount += page.length singleMatchingRow ??= page[0] afterId = page[page.length - 1].id @@ -2077,6 +2099,7 @@ export async function updateRowsByFilter( now, secretProvenance: data.secretProvenance, requestId, + uncoercibleValues: options.uncoercibleValues, }) affectedRowIds.push(...persisted.affectedRowIds) dispatchBulkUpdateEffects( @@ -2109,7 +2132,7 @@ export async function updateRowsByFilter( return { affectedCount: 0, affectedRowIds: [] } } - validateBulkUpdateMatches(table, matchingRows, data.data) + validateBulkUpdateMatches(table, matchingRows, data.data, options.uncoercibleValues) if (uniqueColumnsInUpdate.length > 0) { if (matchingRows.length > 1) { throw new OrchestrationError( @@ -2144,6 +2167,7 @@ export async function updateRowsByFilter( now, secretProvenance: data.secretProvenance, requestId, + uncoercibleValues: options.uncoercibleValues, }) const { affectedRowIds } = persisted @@ -2164,7 +2188,7 @@ export async function updateRowsByFilter( } } -export interface BatchUpdateRowsOptions { +export interface BatchUpdateRowsOptions extends RowWriteOptions { /** * Marks the batch as workflow/enrichment output cells (the backfill runner), * exempting it from the update lock. See {@link assertRowUpdate}. @@ -2263,7 +2287,7 @@ export async function batchUpdateRows( const schemaValidation = coerceRowToSchema( merged, table.schema, - uncoercibleValuePolicy(options), + options.uncoercibleValues, Object.keys(update.data) ) if (!schemaValidation.valid) { diff --git a/apps/sim/lib/table/update-runner.ts b/apps/sim/lib/table/update-runner.ts index 84cff0857a3..b3ed3380002 100644 --- a/apps/sim/lib/table/update-runner.ts +++ b/apps/sim/lib/table/update-runner.ts @@ -162,17 +162,14 @@ export async function runTableUpdate(payload: TableUpdatePayload): Promise afterId = page[page.length - 1].id // Validate each merged result before writing the page — a row that would overflow the size - // cap or violate the schema fails the job (earlier pages stay applied; best-effort). Only - // the patch's own keys are held to the strict policy: a legacy cell this job never touches - // must not fail it halfway through, after the earlier pages have committed. - const patchedKeys = Object.keys(data) + // cap or violate the schema fails the job (earlier pages stay applied; best-effort). for (const row of page) { const merged = { ...row.data, ...data } const sizeValidation = validateRowSize(merged) if (!sizeValidation.valid) { throw new Error(`Row ${row.id}: ${sizeValidation.errors.join(', ')}`) } - const schemaValidation = coerceRowToSchema(merged, table.schema, 'reject', patchedKeys) + const schemaValidation = coerceRowToSchema(merged, table.schema) if (!schemaValidation.valid) { throw new Error(`Row ${row.id}: ${schemaValidation.errors.join(', ')}`) } diff --git a/apps/sim/lib/table/validation.test.ts b/apps/sim/lib/table/validation.test.ts index 9b4cc3012c1..c6341b0012f 100644 --- a/apps/sim/lib/table/validation.test.ts +++ b/apps/sim/lib/table/validation.test.ts @@ -100,52 +100,45 @@ describe('coerceRowToSchema — select', () => { expect(data.col_status).toBe('opt_closed') }) - it('rejects an unmatched value on an optional column', () => { + it('rejects an unmatched value on an optional column under the `reject` policy', () => { const data: RowData = { col_status: 'banana' } - const result = coerceRowToSchema(data, schemaWith(selectColumn)) + const result = coerceRowToSchema(data, schemaWith(selectColumn), 'reject') expect(result.valid).toBe(false) expect(result.errors.join(' ')).toContain('status') }) - it('nulls an unmatched value under the `null` policy', () => { + it('nulls an unmatched value by default', () => { const data: RowData = { col_status: 'banana' } - const result = coerceRowToSchema(data, schemaWith(selectColumn), 'null') + const result = coerceRowToSchema(data, schemaWith(selectColumn)) expect(result.valid).toBe(true) expect(data.col_status).toBeNull() }) }) describe('coerceRowToSchema — multiselect', () => { - it('resolves names', () => { - const data: RowData = { col_tags: ['Alpha', 'opt_b'] } + it('resolves names and keeps the entries that resolve by default', () => { + const data: RowData = { col_tags: ['Alpha', 'opt_b', 'ghost'] } const result = coerceRowToSchema(data, schemaWith(multiselectColumn)) expect(result.valid).toBe(true) expect(data.col_tags).toEqual(['opt_a', 'opt_b']) }) - it('rejects an entry matching no option instead of dropping it', () => { + it('rejects an entry matching no option instead of dropping it under `reject`', () => { const data: RowData = { col_tags: ['Alpha', 'ghost'] } - const result = coerceRowToSchema(data, schemaWith(multiselectColumn)) + const result = coerceRowToSchema(data, schemaWith(multiselectColumn), 'reject') expect(result.valid).toBe(false) }) - it('rejects a lone unmatched entry rather than storing an empty list', () => { + it('rejects a lone unmatched entry rather than storing an empty list under `reject`', () => { const data: RowData = { col_tags: ['green'] } - const result = coerceRowToSchema(data, schemaWith(multiselectColumn)) + const result = coerceRowToSchema(data, schemaWith(multiselectColumn), 'reject') expect(result.valid).toBe(false) expect(data.col_tags).not.toEqual([]) }) - it('keeps the entries that resolve under the `null` policy', () => { - const data: RowData = { col_tags: ['Alpha', 'opt_b', 'ghost'] } - const result = coerceRowToSchema(data, schemaWith(multiselectColumn), 'null') - expect(result.valid).toBe(true) - expect(data.col_tags).toEqual(['opt_a', 'opt_b']) - }) - - it('nulls the cell under the `null` policy only when nothing resolves', () => { + it('empties the cell by default only when nothing resolves', () => { const data: RowData = { col_tags: ['ghost'] } - const result = coerceRowToSchema(data, schemaWith(multiselectColumn), 'null') + const result = coerceRowToSchema(data, schemaWith(multiselectColumn)) expect(result.valid).toBe(true) expect(data.col_tags).toEqual([]) }) @@ -158,12 +151,13 @@ describe('coerceRowToSchema — multiselect', () => { }) /** - * The defect this pins: every one of these answered 200 with the cell stored as - * `null`, on an optional column, with nothing in the response saying a value had - * been discarded. The read side already 400s on the same mismatch in a filter - * predicate, so the two halves of the API disagreed about the same value. + * The `reject` policy, which only `/api/v2` opts into. It answers a value it + * cannot store exactly with a 400 rather than a 200 whose cell is `null`, + * matching the read side, which already refuses the same mismatch in a filter + * predicate. Every first-party surface runs the `null` policy in the sibling + * `it.each` below, which is the default and what they have always done. */ -describe('coerceRowToSchema — uncoercible values are refused, not silently nulled', () => { +describe('coerceRowToSchema — uncoercible values under the `reject` policy', () => { const numberColumn: ColumnDefinition = { id: 'col_n', name: 'n', type: 'number' } const booleanColumn: ColumnDefinition = { id: 'col_b', name: 'b', type: 'boolean' } const dateColumn: ColumnDefinition = { id: 'col_d', name: 'd', type: 'date' } @@ -183,17 +177,20 @@ describe('coerceRowToSchema — uncoercible values are refused, not silently nul it.each(cases)('rejects %s', (_label, column, value) => { const data: RowData = { [column.id as string]: value } - const result = coerceRowToSchema(data, schemaWith(column)) + const result = coerceRowToSchema(data, schemaWith(column), 'reject') expect(result.valid).toBe(false) expect(data[column.id as string]).not.toBeNull() }) - it.each(cases)('nulls %s under the `null` policy', (_label, column, value) => { - const data: RowData = { [column.id as string]: value } - const result = coerceRowToSchema(data, schemaWith(column), 'null') - expect(result.valid).toBe(true) - expect(data[column.id as string]).toBeNull() - }) + it.each(cases)( + 'nulls %s by default, as every first-party surface does', + (_label, column, value) => { + const data: RowData = { [column.id as string]: value } + const result = coerceRowToSchema(data, schemaWith(column)) + expect(result.valid).toBe(true) + expect(data[column.id as string]).toBeNull() + } + ) it('still applies unambiguous conversions', () => { const data: RowData = { col_n: '1999' } @@ -209,7 +206,7 @@ describe('coerceRowToSchema — uncoercible values are refused, not silently nul */ it('refuses a bare epoch number rather than guessing its unit', () => { const data: RowData = { col_d: 1600000000 } - const result = coerceRowToSchema(data, schemaWith(dateColumn)) + const result = coerceRowToSchema(data, schemaWith(dateColumn), 'reject') expect(result.valid).toBe(false) expect(data.col_d).not.toBe('1970-01-19T12:26:40.000Z') }) @@ -219,16 +216,16 @@ describe('coerceRowToSchema — uncoercible values are refused, not silently nul expect(coerceRowToSchema(data, schemaWith(dateColumn)).valid).toBe(true) }) - it('reads a bare epoch number as milliseconds under the `null` policy', () => { + it('reads a bare epoch number as milliseconds by default', () => { const data: RowData = { col_d: 1600000000000 } - const result = coerceRowToSchema(data, schemaWith(dateColumn), 'null') + const result = coerceRowToSchema(data, schemaWith(dateColumn)) expect(result.valid).toBe(true) expect(data.col_d).toBe('2020-09-13T12:26:40.000Z') }) - it('still nulls an out-of-range epoch number under the `null` policy', () => { + it('still nulls an out-of-range epoch number by default', () => { const data: RowData = { col_d: 1e20 } - const result = coerceRowToSchema(data, schemaWith(dateColumn), 'null') + const result = coerceRowToSchema(data, schemaWith(dateColumn)) expect(result.valid).toBe(true) expect(data.col_d).toBeNull() }) diff --git a/apps/sim/lib/table/validation.ts b/apps/sim/lib/table/validation.ts index e8298f1e41b..7a7376043a6 100644 --- a/apps/sim/lib/table/validation.ts +++ b/apps/sim/lib/table/validation.ts @@ -62,6 +62,8 @@ export interface ValidateRowOptions { tableId: string excludeRowId?: string checkUnique?: boolean + /** See {@link UncoercibleValuePolicy}. Defaults to `null` — first-party behavior. */ + uncoercibleValues?: UncoercibleValuePolicy } /** Error information for a single row in batch validation. */ @@ -76,6 +78,8 @@ export interface ValidateBatchRowsOptions { schema: TableSchema tableId: string checkUnique?: boolean + /** See {@link UncoercibleValuePolicy}. Defaults to `null` — first-party behavior. */ + uncoercibleValues?: UncoercibleValuePolicy } /** @@ -85,7 +89,7 @@ export interface ValidateBatchRowsOptions { export async function validateRowData( options: ValidateRowOptions ): Promise { - const { rowData, schema, tableId, excludeRowId, checkUnique = true } = options + const { rowData, schema, tableId, excludeRowId, checkUnique = true, uncoercibleValues } = options const sizeValidation = validateRowSize(rowData) if (!sizeValidation.valid) { @@ -98,7 +102,7 @@ export async function validateRowData( } } - const schemaValidation = coerceRowToSchema(rowData, schema) + const schemaValidation = coerceRowToSchema(rowData, schema, uncoercibleValues) if (!schemaValidation.valid) { return { valid: false, @@ -134,7 +138,7 @@ export async function validateRowData( export async function validateBatchRows( options: ValidateBatchRowsOptions ): Promise { - const { rows, schema, tableId, checkUnique = true } = options + const { rows, schema, tableId, checkUnique = true, uncoercibleValues } = options const errors: BatchRowError[] = [] for (let i = 0; i < rows.length; i++) { @@ -146,7 +150,7 @@ export async function validateBatchRows( continue } - const schemaValidation = coerceRowToSchema(rowData, schema) + const schemaValidation = coerceRowToSchema(rowData, schema, uncoercibleValues) if (!schemaValidation.valid) { errors.push({ row: i, errors: schemaValidation.errors }) } @@ -277,24 +281,22 @@ function coerceValueToColumnType(value: JsonValue, column: ColumnDefinition): Co /** * What a write does with a value its column's type cannot coerce. * + * - `null` — blank the cell rather than fail the row. **The default**, and what + * every first-party surface does: the workspace grid, the internal + * `/api/table` routes, `/api/v1`, the Copilot table tools, the executor's + * Table block, CSV import, and the workflow/enrichment writers. A tool + * returning `"unknown"` for a numeric column nulls that one cell rather than + * failing the entire row write. * - `reject` — leave the value in place so the following - * {@link validateRowAgainstSchema} reports it and the write fails. This is - * the default, and the only policy any caller-supplied value may use: a - * client that sends `"abc"` for a `number` column has made a mistake, and - * answering 200 while storing `null` destroys the cell it was trying to - * write. It also matches the read side, which already refuses the same - * mismatch in a filter predicate rather than matching nothing. - * - `null` — blank the cell instead. Reserved for values a *machine* produced - * for a cell the caller did not type: a workflow/enrichment block whose - * output does not fit its bound column, and a CSV import where one bad cell - * in a 100k-row file must not fail the file. Nothing there has a caller to - * return a 400 to. + * {@link validateRowAgainstSchema} reports it and the write fails. Opted into + * by the `/api/v2` surface only, whose published contract is that a value it + * cannot store exactly is answered with a 400 rather than stored as `null`. * * Under `null` a value the column type can still read lossily is kept rather - * than blanked — see `ColumnTypeDefinition.salvage`, which is why a CSV cell - * naming two live options and one deleted one imports as the two rather than as - * an empty cell. A `required` column is never blanked under either policy: a - * null would fail the required check immediately after. + * than blanked — see `ColumnTypeDefinition.salvage`, which is why a cell naming + * two live options and one deleted one stores the two rather than nothing. A + * `required` column is never blanked under either policy: a null would fail the + * required check immediately after. */ export type UncoercibleValuePolicy = 'reject' | 'null' @@ -335,7 +337,7 @@ function policyResolver( export function coerceRowValues( data: RowData, schema: TableSchema, - policy: UncoercibleValuePolicy = 'reject', + policy: UncoercibleValuePolicy = 'null', patchedKeys?: PatchedKeys ): void { const policyFor = policyResolver(policy, patchedKeys) @@ -375,7 +377,7 @@ export function coerceRowValues( export function coerceRowToSchema( data: RowData, schema: TableSchema, - policy: UncoercibleValuePolicy = 'reject', + policy: UncoercibleValuePolicy = 'null', patchedKeys?: PatchedKeys ): ValidationResult { coerceRowValues(data, schema, policy, patchedKeys) diff --git a/apps/sim/lib/table/views/service.test.ts b/apps/sim/lib/table/views/service.test.ts index ca6c1267b8a..e927dff9ca4 100644 --- a/apps/sim/lib/table/views/service.test.ts +++ b/apps/sim/lib/table/views/service.test.ts @@ -379,7 +379,7 @@ describe('view config column-reference normalization', () => { return values.config } - function create(config: TableViewConfig) { + function create(config: TableViewConfig, strictRefs = true) { return createTableView({ tableId: 'table-1', workspaceId: 'ws-1', @@ -387,6 +387,7 @@ describe('view config column-reference normalization', () => { config, userId: 'user-1', columns, + strictRefs, }) } @@ -435,7 +436,7 @@ describe('view config column-reference normalization', () => { }) }) - it('refuses a filter on a column that does not exist', async () => { + it('refuses a filter on a column that does not exist for a strict caller', async () => { queueTableRows(tableViews, [{ total: 0 }]) dbChainMockFns.returning.mockResolvedValueOnce([storedRow]) @@ -445,7 +446,7 @@ describe('view config column-reference normalization', () => { expect(dbChainMockFns.insert).not.toHaveBeenCalled() }) - it('refuses a sort on a column that does not exist', async () => { + it('refuses a sort on a column that does not exist for a strict caller', async () => { queueTableRows(tableViews, [{ total: 0 }]) dbChainMockFns.returning.mockResolvedValueOnce([storedRow]) @@ -455,6 +456,48 @@ describe('view config column-reference normalization', () => { expect(dbChainMockFns.insert).not.toHaveBeenCalled() }) + /** + * "Save as view" hands back the filter the grid is displaying, dangling leaf + * and all — the same slice the Save chip sends to the update path, which has + * always tolerated it. Refusing one and accepting the other would 400 the two + * menu items against each other. + */ + it('stores the same dangling reference for a first-party caller', async () => { + queueTableRows(tableViews, [{ total: 0 }]) + dbChainMockFns.returning.mockResolvedValueOnce([storedRow]) + + await create( + { filter: { all: [{ field: 'col_gone', op: 'eq', value: 'x' }] }, sort: [] }, + false + ) + + expect(insertedConfig().filter).toEqual({ + all: [{ field: 'col_gone', op: 'eq', value: 'x' }], + }) + }) + + /** + * A user column may legally be named `createdAt`. The name→id rewrite would + * otherwise point the stored ref at that column's JSONB cell while every read + * still resolves the literal to `user_table_rows.created_at`. + */ + it('leaves a system field alone even when a user column carries its name', async () => { + queueTableRows(tableViews, [{ total: 0 }]) + dbChainMockFns.returning.mockResolvedValueOnce([storedRow]) + + await createTableView({ + tableId: 'table-1', + workspaceId: 'ws-1', + name: 'My View', + config: { sort: [{ field: 'createdAt', direction: 'desc' }] }, + userId: 'user-1', + columns: [...columns, { id: 'col_c', name: 'createdAt', type: 'string' }], + strictRefs: true, + }) + + expect(insertedConfig().sort).toEqual([{ field: 'createdAt', direction: 'desc' }]) + }) + it('refuses a nonexistent filter column on a configPatch too', async () => { queueTableRows(tableViews, [{ id: 'view-1' }]) @@ -464,6 +507,7 @@ describe('view config column-reference normalization', () => { tableId: 'table-1', configPatch: { filter: { all: [{ field: 'ghost', op: 'eq', value: 'x' }] } }, columns, + strictRefs: true, }) ).rejects.toMatchObject({ name: 'TableViewValidationError' }) }) @@ -500,10 +544,26 @@ describe('view config column-reference normalization', () => { tableId: 'table-1', config: { filter: { all: [{ field: 'col_other_ghost', op: 'eq', value: 'x' }] } }, columns, + strictRefs: true, }) ).rejects.toMatchObject({ name: 'TableViewValidationError' }) }) + it('accepts that same new reference from a first-party caller', async () => { + const stale = { all: [{ field: 'col_gone', op: 'eq' as const, value: 'x' }] } + queueTableRows(tableViews, [{ ...storedRow, config: { filter: stale } }]) + dbChainMockFns.returning.mockResolvedValueOnce([storedRow]) + + await expect( + updateTableView({ + viewId: 'view-1', + tableId: 'table-1', + config: { filter: { all: [{ field: 'col_other_ghost', op: 'eq', value: 'x' }] } }, + columns, + }) + ).resolves.not.toBeNull() + }) + it('keeps a sort on a system row column, which is sortable but not in schema.columns', () => { expect( pruneViewConfig({ sort: [{ field: 'createdAt', direction: 'desc' }] }, columns).sort diff --git a/apps/sim/lib/table/views/service.ts b/apps/sim/lib/table/views/service.ts index 7ff0068492d..1cd7462091e 100644 --- a/apps/sim/lib/table/views/service.ts +++ b/apps/sim/lib/table/views/service.ts @@ -122,6 +122,28 @@ function configColumnRefs(config: TableViewConfig): string[] { return refs } +/** + * `name → id` for a view config, minus the names that already mean something + * else as a reference. + * + * The rewrite is a lookup with pass-through, so a name entry would otherwise + * beat the meaning a ref already has. A user column may legally be NAMED `id`, + * `createdAt`, or `updatedAt` — nothing reserves those — and a legacy column + * with no `id` has `getColumnId(col) === col.name`, so a rename can leave one + * column's id equal to another's name. In both cases the write would rewrite the + * ref to the user column while every read still resolves the literal to the + * system row column or the original column, so the saved view would silently + * sort or filter on something the caller did not name. + */ +function viewConfigRefMap(columns: readonly ColumnDefinition[]): Map { + const byName = buildColumnIdByName(columns) + const liveIds = new Set(columns.map(getColumnId)) + for (const name of byName.keys()) { + if (liveIds.has(name) || SYSTEM_COLUMN_FIELDS.has(name)) byName.delete(name) + } + return byName +} + /** * `columns` plus a placeholder for each exempt reference that no longer resolves, * so the shared query validators accept it without being taught about views. The @@ -159,20 +181,23 @@ function tolerantColumns( * the user drags, and racing a concurrent column delete must self-heal through * {@link pruneViewConfig}, not fail the drag. * - * `carriedForward` names the references the STORED config already holds, and - * they are exempt. Deleting a column leaves every view that filtered on it - * dangling — `pruneViewConfig` deliberately does not prune a filter — so without - * the exemption the view becomes unwritable: the Save chip sends the whole + * `carriedForward` names the references that are exempt from that refusal. + * Deleting a column leaves every view that filtered on it dangling — + * `pruneViewConfig` deliberately does not prune a filter — so without the + * exemption the view becomes unwritable: the Save chip sends the whole * `{filter, sort, hiddenColumns}` slice, and a user changing the sort would be * refused over a condition they did not touch, with no way to save the removal - * of anything else first. A reference the caller INTRODUCES is still refused. + * of anything else first. The v2 surface exempts only what the STORED config + * already held, so a reference the caller INTRODUCES is refused; a first-party + * caller exempts its own refs too, which is the behavior the grid has always + * had — see {@link CreateTableViewData.strictRefs}. */ export function normalizeViewConfigForStorage( config: TableViewConfig, columns: ColumnDefinition[], carriedForward: readonly string[] = [] ): TableViewConfig { - const stored = remapViewConfigColumnRefs(config, buildColumnIdByName(columns)) + const stored = remapViewConfigColumnRefs(config, viewConfigRefMap(columns)) const known = tolerantColumns(columns, carriedForward) try { if (stored.filter) validateStoragePredicate(stored.filter, known) @@ -345,6 +370,19 @@ export interface CreateTableViewData { config: TableViewConfig userId: string columns: ColumnDefinition[] + /** + * Whether to refuse a filter or sort reference naming no live column. Set by + * the `/api/v2` surface only, whose caller authored the config in this request + * and can be told which reference was wrong. + * + * Absent — the first-party grid, which does not author these refs so much as + * carry them: a view filtered on a since-deleted column keeps the dangling + * leaf through every read (`pruneViewConfig` spares filters) and hands it + * straight back on the next save. Refusing it would 400 "Save as view" on a + * config the Save chip accepts, one menu item apart, over a condition the user + * never touched. + */ + strictRefs?: boolean } /** @@ -364,7 +402,11 @@ export interface CreateTableViewData { */ export async function createTableView(data: CreateTableViewData): Promise { const name = normalizeName(data.name) - const config = normalizeViewConfigForStorage(data.config, data.columns) + const config = normalizeViewConfigForStorage( + data.config, + data.columns, + data.strictRefs ? [] : configColumnRefs(data.config) + ) const row = await withTableViewsLock(data.tableId, async (trx) => { const [existing] = await trx @@ -411,6 +453,8 @@ export interface UpdateTableViewData { configPatch?: TableViewConfig isDefault?: boolean columns: ColumnDefinition[] + /** See {@link CreateTableViewData.strictRefs}. */ + strictRefs?: boolean } /** @@ -445,7 +489,14 @@ export async function updateTableView(data: UpdateTableViewData): Promise Date: Thu, 13 Aug 2026 00:18:29 -0700 Subject: [PATCH 41/56] test(v2): pin cursor-to-filter binding on seven more paged lists Extends the mint-then-replay guard from tables and workflow runs to the remaining paged v2 lists the audit found with no route-level coverage: credentials, audit-logs, custom-tools, mcp-servers, secrets, knowledge bases, and knowledge documents. Each gets a cursor minted by driving GET under one filter and replayed under another, asserting a 400 carrying REFILTERED_CURSOR_MESSAGE that never reaches the use case, plus a same-filter resume control so the 400 cannot be satisfied by blanket rejection. The three cursor schemes are all covered: keyset (readSortedCursor), the scoped wrapper audit-logs uses for its domain token, and the offset cursor on knowledge documents. The documents suite had no GET coverage at all, so its list use case gains a real mock and the route's GET export a describe block. All fourteen were verified to fail: dropping the cursor-filter argument from both call sites on each route reddens exactly that route's refiltered test and leaves every other assertion in the file green, which is the failure mode the contract-level CURSOR_BINDINGS sweep cannot see. --- apps/sim/app/api/v2/audit-logs/route.test.ts | 53 +++++++++++ apps/sim/app/api/v2/credentials/route.test.ts | 67 ++++++++++++++ .../sim/app/api/v2/custom-tools/route.test.ts | 61 ++++++++++++ .../v2/knowledge/[id]/documents/route.test.ts | 92 ++++++++++++++++++- apps/sim/app/api/v2/knowledge/route.test.ts | 71 ++++++++++++++ apps/sim/app/api/v2/mcp-servers/route.test.ts | 66 +++++++++++++ apps/sim/app/api/v2/secrets/route.test.ts | 73 +++++++++++++++ 7 files changed, 481 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/api/v2/audit-logs/route.test.ts b/apps/sim/app/api/v2/audit-logs/route.test.ts index 0932526878c..dd6511eeb5a 100644 --- a/apps/sim/app/api/v2/audit-logs/route.test.ts +++ b/apps/sim/app/api/v2/audit-logs/route.test.ts @@ -29,6 +29,7 @@ vi.mock('@/lib/audit-logs/application/get-audit-log', () => ({ getAuditLog: { operation: { id: 'audit_logs.read_detail' }, execute: mocks.get }, })) +import { REFILTERED_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' import { OrchestrationError } from '@/lib/core/orchestration/types' import { GET as getDetail } from '@/app/api/v2/audit-logs/[id]/route' import { GET as listLogs } from '@/app/api/v2/audit-logs/route' @@ -101,6 +102,58 @@ describe('v2 audit-log routes', () => { expect(response.headers.get('x-ratelimit-limit')).toBe('100') }) + /** + * Pins the binding end-to-end — the mint in `present` and the read in + * `mapInput` — because the contract-level sweep only checks a hand-maintained + * map of param names and stays green when a route drops the stamp entirely. + */ + it('refuses a cursor minted under a different filter', async () => { + const minted = await listLogs( + new NextRequest( + 'http://localhost:3000/api/v2/audit-logs?organizationId=org-1&actorEmail=ada%40example.com' + ) + ) + const { nextCursor } = await minted.json() + expect(nextCursor).toEqual(expect.any(String)) + + mocks.list.mockClear() + const replayed = await listLogs( + new NextRequest( + `http://localhost:3000/api/v2/audit-logs?organizationId=org-1&actorEmail=bob%40example.com&cursor=${encodeURIComponent(nextCursor)}` + ) + ) + + expect(replayed.status).toBe(400) + expect((await replayed.json()).error.message).toBe(REFILTERED_CURSOR_MESSAGE) + expect(mocks.list).not.toHaveBeenCalled() + }) + + it('resumes a cursor replayed under the filters it was minted with', async () => { + const minted = await listLogs( + new NextRequest( + 'http://localhost:3000/api/v2/audit-logs?organizationId=org-1&actorEmail=ada%40example.com' + ) + ) + const { nextCursor } = await minted.json() + + mocks.list.mockClear() + const resumed = await listLogs( + new NextRequest( + `http://localhost:3000/api/v2/audit-logs?organizationId=org-1&actorEmail=ada%40example.com&cursor=${encodeURIComponent(nextCursor)}` + ) + ) + + expect(resumed.status).toBe(200) + expect(mocks.list).toHaveBeenCalledWith({ + principal: auth.principal, + input: expect.objectContaining({ + filters: expect.objectContaining({ actorEmail: 'ada@example.com' }), + cursor: 'next-1', + }), + request: expect.anything(), + }) + }) + it('projects typed admin-policy failures without leaking internals', async () => { mocks.list.mockRejectedValueOnce(new OrchestrationError('forbidden', 'Admin required')) diff --git a/apps/sim/app/api/v2/credentials/route.test.ts b/apps/sim/app/api/v2/credentials/route.test.ts index 3f2819ad83a..60a73509106 100644 --- a/apps/sim/app/api/v2/credentials/route.test.ts +++ b/apps/sim/app/api/v2/credentials/route.test.ts @@ -28,6 +28,7 @@ vi.mock('@/lib/credentials/application/list-workspace-credentials', () => ({ })) import { V2_DEFAULT_PAGE_SIZE } from '@/lib/api/contracts/v2/shared' +import { REFILTERED_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' import { GET } from '@/app/api/v2/credentials/route' const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' @@ -107,6 +108,72 @@ describe('GET /api/v2/credentials', () => { }) }) + /** + * Pins the binding end-to-end — the mint in `present` and the read in + * `mapInput` — because the contract-level sweep only checks a hand-maintained + * map of param names and stays green when a route drops the stamp entirely. + */ + it('refuses a cursor minted under a different filter', async () => { + mocks.execute.mockResolvedValue({ + credentials: [credential], + nextCursorKeys: ['2026-01-01T00:00:00.000Z', 'credential-1'], + sortBy: 'createdAt', + sortOrder: 'desc', + }) + + const minted = await GET( + new NextRequest( + `http://localhost:3000/api/v2/credentials?workspaceId=${WORKSPACE_ID}&search=zoom` + ) + ) + const { nextCursor } = await minted.json() + expect(nextCursor).toEqual(expect.any(String)) + + mocks.execute.mockClear() + const replayed = await GET( + new NextRequest( + `http://localhost:3000/api/v2/credentials?workspaceId=${WORKSPACE_ID}&search=slack&cursor=${encodeURIComponent(nextCursor)}` + ) + ) + + expect(replayed.status).toBe(400) + expect((await replayed.json()).error.message).toBe(REFILTERED_CURSOR_MESSAGE) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('resumes a cursor replayed under the filters it was minted with', async () => { + mocks.execute.mockResolvedValue({ + credentials: [credential], + nextCursorKeys: ['2026-01-01T00:00:00.000Z', 'credential-1'], + sortBy: 'createdAt', + sortOrder: 'desc', + }) + + const minted = await GET( + new NextRequest( + `http://localhost:3000/api/v2/credentials?workspaceId=${WORKSPACE_ID}&search=zoom` + ) + ) + const { nextCursor } = await minted.json() + + mocks.execute.mockClear() + const resumed = await GET( + new NextRequest( + `http://localhost:3000/api/v2/credentials?workspaceId=${WORKSPACE_ID}&search=zoom&cursor=${encodeURIComponent(nextCursor)}` + ) + ) + + expect(resumed.status).toBe(200) + expect(mocks.execute).toHaveBeenCalledWith({ + principal: auth.principal, + input: expect.objectContaining({ + search: 'zoom', + cursorKeys: ['2026-01-01T00:00:00.000Z', 'credential-1'], + }), + request: expect.anything(), + }) + }) + it('projects credential metadata field by field without secret material', async () => { const response = await GET( new NextRequest(`http://localhost:3000/api/v2/credentials?workspaceId=${WORKSPACE_ID}`) diff --git a/apps/sim/app/api/v2/custom-tools/route.test.ts b/apps/sim/app/api/v2/custom-tools/route.test.ts index b4609531cff..3b32d07ccab 100644 --- a/apps/sim/app/api/v2/custom-tools/route.test.ts +++ b/apps/sim/app/api/v2/custom-tools/route.test.ts @@ -55,6 +55,7 @@ vi.mock('@/lib/custom-tools/application/use-cases', () => ({ })) import { V2_DEFAULT_PAGE_SIZE } from '@/lib/api/contracts/v2/shared' +import { REFILTERED_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' import { GET, POST } from '@/app/api/v2/custom-tools/route' const WORKSPACE_ID = 'workspace-1' @@ -135,6 +136,66 @@ describe('/api/v2/custom-tools', () => { ) }) + /** + * Pins the binding end-to-end — the mint in `present` and the read in + * `mapInput` — because the contract-level sweep only checks a hand-maintained + * map of param names and stays green when a route drops the stamp entirely. + */ + it('refuses a cursor minted under a different filter', async () => { + mocks.list.mockResolvedValue({ + tools: [tool], + nextCursorKeys: ['2026-01-01T00:00:00.000Z', 'tool-1'], + }) + + const minted = await GET( + request('GET', `/api/v2/custom-tools?workspaceId=${WORKSPACE_ID}&search=lookup`) + ) + const { nextCursor } = await minted.json() + expect(nextCursor).toEqual(expect.any(String)) + + mocks.list.mockClear() + const replayed = await GET( + request( + 'GET', + `/api/v2/custom-tools?workspaceId=${WORKSPACE_ID}&search=refund&cursor=${encodeURIComponent(nextCursor)}` + ) + ) + + expect(replayed.status).toBe(400) + expect((await replayed.json()).error.message).toBe(REFILTERED_CURSOR_MESSAGE) + expect(mocks.list).not.toHaveBeenCalled() + }) + + it('resumes a cursor replayed under the filters it was minted with', async () => { + mocks.list.mockResolvedValue({ + tools: [tool], + nextCursorKeys: ['2026-01-01T00:00:00.000Z', 'tool-1'], + }) + + const minted = await GET( + request('GET', `/api/v2/custom-tools?workspaceId=${WORKSPACE_ID}&search=lookup`) + ) + const { nextCursor } = await minted.json() + + mocks.list.mockClear() + const resumed = await GET( + request( + 'GET', + `/api/v2/custom-tools?workspaceId=${WORKSPACE_ID}&search=lookup&cursor=${encodeURIComponent(nextCursor)}` + ) + ) + + expect(resumed.status).toBe(200) + expect(mocks.list).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: expect.objectContaining({ + search: 'lookup', + cursorKeys: ['2026-01-01T00:00:00.000Z', 'tool-1'], + }), + request: expect.anything(), + }) + }) + it('creates exactly one custom tool with the v2 source and status', async () => { const response = await POST( request('POST', '/api/v2/custom-tools', { diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/route.test.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/route.test.ts index 607c5b6f13a..a8962620ef0 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/route.test.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/route.test.ts @@ -21,7 +21,9 @@ const { mockCapture, mockIsPayloadSizeLimitError, mockIsMultipartFieldValidationError, + mockListDocuments, } = vi.hoisted(() => ({ + mockListDocuments: vi.fn(), mockAdmitUpload: vi.fn(), mockUploadDocument: vi.fn(), mockReadFormData: vi.fn(), @@ -39,7 +41,7 @@ vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) vi.mock('@/lib/knowledge/application/documents', () => ({ listKnowledgeDocuments: { operation: { id: 'knowledge.documents.list' }, - execute: vi.fn(), + execute: mockListDocuments, }, bulkUpdateKnowledgeDocuments: { operation: { id: 'knowledge.documents.bulk' }, @@ -69,11 +71,12 @@ vi.mock('@/lib/core/telemetry', () => ({ vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCapture })) +import { REFILTERED_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' import { OrchestrationError } from '@/lib/core/orchestration/types' import { KnowledgeUsageLimitExceededError } from '@/lib/knowledge/application/billing' import { MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE } from '@/lib/uploads/shared/types' import { validateFileType } from '@/lib/uploads/utils/validation' -import { POST } from '@/app/api/v2/knowledge/[id]/documents/route' +import { GET, POST } from '@/app/api/v2/knowledge/[id]/documents/route' const WORKSPACE_ID = 'workspace-1' const PRINCIPAL = { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' } as const @@ -327,3 +330,88 @@ describe('POST /api/v2/knowledge/[id]/documents', () => { expect(mockCapture).not.toHaveBeenCalled() }) }) + +describe('GET /api/v2/knowledge/[id]/documents', () => { + const document = { + id: 'doc-1', + knowledgeBaseId: 'kb-1', + filename: 'support.txt', + fileUrl: 's3://workspace/support.txt', + fileSize: 5, + mimeType: 'text/plain', + processingStatus: 'completed', + chunkCount: 1, + tokenCount: 2, + characterCount: 5, + enabled: true, + uploadedAt: new Date('2024-01-01T00:00:00Z'), + } + + function listRequest(query: string) { + return new NextRequest(`http://localhost/api/v2/knowledge/kb-1/documents?${query}`, { + headers: { 'x-api-key': 'secret' }, + }) + } + + function list(query: string) { + return GET(listRequest(query), { params: Promise.resolve({ id: 'kb-1' }) }) + } + + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.authenticate.mockResolvedValue({ + principal: PRINCIPAL, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'], + rateLimitSubscription: null, + keyType: 'personal', + }) + mockListDocuments.mockResolvedValue({ + documents: [document], + tagDefinitions: [], + pagination: { hasMore: true, offset: 0, limit: 1 }, + }) + }) + + /** + * An offset cursor is the weaker scheme: replayed under a different filter it + * names an ordinal in an unrelated sequence. Pins the binding end-to-end — the + * mint in `present` and the read in `mapInput` — because the contract-level + * sweep only checks a hand-maintained map of param names and stays green when + * a route drops the stamp entirely. + */ + it('refuses a cursor minted under a different filter', async () => { + const minted = await list(`workspaceId=${WORKSPACE_ID}&limit=1&search=support`) + const { nextCursor } = await minted.json() + expect(nextCursor).toEqual(expect.any(String)) + + mockListDocuments.mockClear() + const replayed = await list( + `workspaceId=${WORKSPACE_ID}&limit=1&search=billing&cursor=${encodeURIComponent(nextCursor)}` + ) + + expect(replayed.status).toBe(400) + expect((await replayed.json()).error.message).toBe(REFILTERED_CURSOR_MESSAGE) + expect(mockListDocuments).not.toHaveBeenCalled() + }) + + it('resumes a cursor replayed under the filters it was minted with', async () => { + const minted = await list(`workspaceId=${WORKSPACE_ID}&limit=1&search=support`) + const { nextCursor } = await minted.json() + + mockListDocuments.mockClear() + const resumed = await list( + `workspaceId=${WORKSPACE_ID}&limit=1&search=support&cursor=${encodeURIComponent(nextCursor)}` + ) + + expect(resumed.status).toBe(200) + expect(mockListDocuments).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: expect.objectContaining({ search: 'support', offset: 1 }), + request: expect.anything(), + }) + }) +}) diff --git a/apps/sim/app/api/v2/knowledge/route.test.ts b/apps/sim/app/api/v2/knowledge/route.test.ts index d2a9bf3e81a..8e2abe97bcb 100644 --- a/apps/sim/app/api/v2/knowledge/route.test.ts +++ b/apps/sim/app/api/v2/knowledge/route.test.ts @@ -60,6 +60,7 @@ vi.mock('@/lib/users/queries', () => ({ })) import { V2_DEFAULT_PAGE_SIZE } from '@/lib/api/contracts/v2/shared' +import { REFILTERED_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' import { GET, POST } from '@/app/api/v2/knowledge/route' const WORKSPACE_ID = 'workspace-1' @@ -148,6 +149,76 @@ describe('/api/v2/knowledge route composition', () => { }) }) + /** + * Pins the binding end-to-end — the mint in `present` and the read in + * `mapInput` — because the contract-level sweep only checks a hand-maintained + * map of param names and stays green when a route drops the stamp entirely. + */ + it('refuses a cursor minted under a different filter', async () => { + mockList.mockResolvedValue({ + knowledgeBases: [{ knowledgeBase: buildKnowledgeBase(), folderPath: '/' }], + nextCursorKeys: ['Support docs', 'kb-1'], + sortBy: 'name', + sortOrder: 'desc', + }) + + const minted = await GET( + new NextRequest( + `http://localhost/api/v2/knowledge?workspaceId=${WORKSPACE_ID}&search=support`, + { headers: { 'x-api-key': 'secret' } } + ) + ) + const { nextCursor } = await minted.json() + expect(nextCursor).toEqual(expect.any(String)) + + mockList.mockClear() + const replayed = await GET( + new NextRequest( + `http://localhost/api/v2/knowledge?workspaceId=${WORKSPACE_ID}&search=billing&cursor=${encodeURIComponent(nextCursor)}`, + { headers: { 'x-api-key': 'secret' } } + ) + ) + + expect(replayed.status).toBe(400) + expect((await replayed.json()).error.message).toBe(REFILTERED_CURSOR_MESSAGE) + expect(mockList).not.toHaveBeenCalled() + }) + + it('resumes a cursor replayed under the filters it was minted with', async () => { + mockList.mockResolvedValue({ + knowledgeBases: [{ knowledgeBase: buildKnowledgeBase(), folderPath: '/' }], + nextCursorKeys: ['Support docs', 'kb-1'], + sortBy: 'name', + sortOrder: 'desc', + }) + + const minted = await GET( + new NextRequest( + `http://localhost/api/v2/knowledge?workspaceId=${WORKSPACE_ID}&search=support`, + { headers: { 'x-api-key': 'secret' } } + ) + ) + const { nextCursor } = await minted.json() + + mockList.mockClear() + const resumed = await GET( + new NextRequest( + `http://localhost/api/v2/knowledge?workspaceId=${WORKSPACE_ID}&search=support&cursor=${encodeURIComponent(nextCursor)}`, + { headers: { 'x-api-key': 'secret' } } + ) + ) + + expect(resumed.status).toBe(200) + expect(mockList).toHaveBeenCalledWith({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + input: expect.objectContaining({ + search: 'support', + cursorKeys: ['Support docs', 'kb-1'], + }), + request: expect.anything(), + }) + }) + it('returns 201 and keeps human analytics on the personal-key actor', async () => { const request = new NextRequest('http://localhost/api/v2/knowledge', { method: 'POST', diff --git a/apps/sim/app/api/v2/mcp-servers/route.test.ts b/apps/sim/app/api/v2/mcp-servers/route.test.ts index 027ae7272ed..92b2d8cc184 100644 --- a/apps/sim/app/api/v2/mcp-servers/route.test.ts +++ b/apps/sim/app/api/v2/mcp-servers/route.test.ts @@ -37,6 +37,7 @@ vi.mock('@/lib/mcp/application/use-cases', () => ({ createMcpServerUseCase: { operation: { id: 'mcp_servers.create' }, execute: mocks.create }, })) +import { REFILTERED_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' import { GET, POST } from '@/app/api/v2/mcp-servers/route' type McpServerRow = typeof mcpServers.$inferSelect @@ -189,6 +190,71 @@ describe('/api/v2/mcp-servers', () => { expect(response.status).toBe(400) }) + /** + * The sort case above is a separate stamp. This pins the filter half of the + * binding end-to-end — the mint in `present` and the read in `mapInput` — + * because the contract-level sweep only checks a hand-maintained map of param + * names and stays green when a route drops the stamp entirely. + */ + it('refuses a cursor minted under a different filter', async () => { + mocks.list.mockResolvedValue({ + servers: [server], + nextCursorKeys: [server.createdAt.toISOString(), server.id], + sortBy: 'createdAt', + sortOrder: 'desc', + }) + + const minted = await GET( + request('GET', `/api/v2/mcp-servers?workspaceId=${WORKSPACE_ID}&search=docs`) + ) + const { nextCursor } = await minted.json() + expect(nextCursor).toEqual(expect.any(String)) + + mocks.list.mockClear() + const replayed = await GET( + request( + 'GET', + `/api/v2/mcp-servers?workspaceId=${WORKSPACE_ID}&search=tickets&cursor=${encodeURIComponent(nextCursor)}` + ) + ) + + expect(replayed.status).toBe(400) + expect((await replayed.json()).error.message).toBe(REFILTERED_CURSOR_MESSAGE) + expect(mocks.list).not.toHaveBeenCalled() + }) + + it('resumes a cursor replayed under the filters it was minted with', async () => { + mocks.list.mockResolvedValue({ + servers: [server], + nextCursorKeys: [server.createdAt.toISOString(), server.id], + sortBy: 'createdAt', + sortOrder: 'desc', + }) + + const minted = await GET( + request('GET', `/api/v2/mcp-servers?workspaceId=${WORKSPACE_ID}&search=docs`) + ) + const { nextCursor } = await minted.json() + + mocks.list.mockClear() + const resumed = await GET( + request( + 'GET', + `/api/v2/mcp-servers?workspaceId=${WORKSPACE_ID}&search=docs&cursor=${encodeURIComponent(nextCursor)}` + ) + ) + + expect(resumed.status).toBe(200) + expect(mocks.list).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: expect.objectContaining({ + search: 'docs', + cursorKeys: [server.createdAt.toISOString(), server.id], + }), + request: expect.anything(), + }) + }) + it('rejects a fractional limit rather than paging on a fractional LIMIT', async () => { const response = await GET( request('GET', `/api/v2/mcp-servers?workspaceId=${WORKSPACE_ID}&limit=1.5`) diff --git a/apps/sim/app/api/v2/secrets/route.test.ts b/apps/sim/app/api/v2/secrets/route.test.ts index 47147b25f91..ef740920143 100644 --- a/apps/sim/app/api/v2/secrets/route.test.ts +++ b/apps/sim/app/api/v2/secrets/route.test.ts @@ -47,6 +47,7 @@ vi.mock('@/lib/secrets/application/use-cases', () => ({ })) import { V2_DEFAULT_PAGE_SIZE } from '@/lib/api/contracts/v2/shared' +import { REFILTERED_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' import { GET } from '@/app/api/v2/secrets/route' const WORKSPACE_ID = 'workspace-1' @@ -136,6 +137,78 @@ describe('GET /api/v2/secrets', () => { }) }) + /** + * Pins the binding end-to-end — the mint in `present` and the read in + * `mapInput` — because the contract-level sweep only checks a hand-maintained + * map of param names and stays green when a route drops the stamp entirely. + */ + it('refuses a cursor minted under a different filter', async () => { + mocks.list.mockResolvedValue({ + secrets: [secret], + userId: 'user-1', + nextCursorKeys: ['STRIPE_API_KEY', 'secret-1'], + sortBy: 'name', + sortOrder: 'asc', + }) + + const minted = await GET( + new NextRequest( + `http://localhost:3000/api/v2/secrets?workspaceId=${WORKSPACE_ID}&search=stripe`, + { headers: { 'x-api-key': 'key' } } + ) + ) + const { nextCursor } = await minted.json() + expect(nextCursor).toEqual(expect.any(String)) + + mocks.list.mockClear() + const replayed = await GET( + new NextRequest( + `http://localhost:3000/api/v2/secrets?workspaceId=${WORKSPACE_ID}&search=twilio&cursor=${encodeURIComponent(nextCursor)}`, + { headers: { 'x-api-key': 'key' } } + ) + ) + + expect(replayed.status).toBe(400) + expect((await replayed.json()).error.message).toBe(REFILTERED_CURSOR_MESSAGE) + expect(mocks.list).not.toHaveBeenCalled() + }) + + it('resumes a cursor replayed under the filters it was minted with', async () => { + mocks.list.mockResolvedValue({ + secrets: [secret], + userId: 'user-1', + nextCursorKeys: ['STRIPE_API_KEY', 'secret-1'], + sortBy: 'name', + sortOrder: 'asc', + }) + + const minted = await GET( + new NextRequest( + `http://localhost:3000/api/v2/secrets?workspaceId=${WORKSPACE_ID}&search=stripe`, + { headers: { 'x-api-key': 'key' } } + ) + ) + const { nextCursor } = await minted.json() + + mocks.list.mockClear() + const resumed = await GET( + new NextRequest( + `http://localhost:3000/api/v2/secrets?workspaceId=${WORKSPACE_ID}&search=stripe&cursor=${encodeURIComponent(nextCursor)}`, + { headers: { 'x-api-key': 'key' } } + ) + ) + + expect(resumed.status).toBe(200) + expect(mocks.list).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: expect.objectContaining({ + search: 'stripe', + cursorKeys: ['STRIPE_API_KEY', 'secret-1'], + }), + request: expect.anything(), + }) + }) + it('authenticates before validating list input', async () => { mocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) From 4d1d130d15c10803d8b4d2e189b136d9e8d64d01 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 13 Aug 2026 00:24:59 -0700 Subject: [PATCH 42/56] test: cover four untested behaviors and drop five tests that cannot fail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds coverage that goes red when the behavior is reverted: - `rejectDuplicateQueryValues` through `parseRequest`, not just the pure helper — the existing blank-query tests stay green even when parseRequest ignores the flag entirely. - `failUndispatchedDocumentProcessing`'s pending + not-deleted WHERE guard, asserted on the condition tree so removing it fails. - The widened `present(result, request)` signature, so dropping the second argument stops being a silent no-op. - The NUL scan on `readFormDataWithLimit`'s content-length branch — the branch every ordinary browser and curl upload takes, and the one the existing multipart tests never reached. Removes tests verified incapable of failing: the credentials projection row (the outbound `.parse()` strips unknown keys either way), the per-document 413 sweep (vacuous on two of three documents, subsumed by the sweep in scripts/openapi/documents.test.ts), the two upload-session rows that assert their own `generateWorkspaceFileKey` stub, the storage-key row whose 20-byte name never reaches the budget, and the views-lock assertion against a function `views/service.ts` does not import. --- apps/sim/app/api/v2/credentials/route.test.ts | 26 ------- .../contracts/v2/openapi/resources.test.ts | 54 ------------- .../lib/api/server/blank-query-values.test.ts | 78 ++++++++++++++++++- .../api/server/routes/v2-json-route.test.ts | 70 +++++++++++++++++ apps/sim/lib/core/utils/stream-limits.test.ts | 62 +++++++++++---- .../documents/processing-claim.test.ts | 57 +++++++++++++- apps/sim/lib/table/views/service.test.ts | 40 +--------- apps/sim/lib/uploads/core/storage-key.test.ts | 13 +--- .../uploads/upload-session/service.test.ts | 10 +-- 9 files changed, 255 insertions(+), 155 deletions(-) delete mode 100644 apps/sim/lib/api/contracts/v2/openapi/resources.test.ts diff --git a/apps/sim/app/api/v2/credentials/route.test.ts b/apps/sim/app/api/v2/credentials/route.test.ts index 60a73509106..465d2cbd6be 100644 --- a/apps/sim/app/api/v2/credentials/route.test.ts +++ b/apps/sim/app/api/v2/credentials/route.test.ts @@ -201,32 +201,6 @@ describe('GET /api/v2/credentials', () => { expect(JSON.stringify(body)).not.toContain('createdBy') }) - /** - * The projection is an explicit field-by-field copy, which is what makes a - * column added to the credential table later inert here: a field nobody wrote - * into `toV2Credential` is simply never read. The outbound response `.parse()` - * strips whatever survives, so a leak needs two independent mistakes. This - * pins the pairing against a row carrying a column the projection has never - * heard of. - */ - it('withholds a credential column the projection was never taught to publish', async () => { - mocks.execute.mockResolvedValueOnce({ - credentials: [{ ...credential, encryptedFutureSecret: 'MUST_NOT_LEAK_EITHER' }], - nextCursorKeys: null, - sortBy: 'createdAt', - sortOrder: 'desc', - }) - - const response = await GET( - new NextRequest(`http://localhost:3000/api/v2/credentials?workspaceId=${WORKSPACE_ID}`) - ) - const body = await response.json() - - expect(response.status).toBe(200) - expect(JSON.stringify(body)).not.toContain('encryptedFutureSecret') - expect(JSON.stringify(body)).not.toContain('MUST_NOT_LEAK_EITHER') - }) - it('hides repository errors that may contain secret details', async () => { mocks.execute.mockRejectedValueOnce(new Error('encryptedServiceAccountKey failed')) diff --git a/apps/sim/lib/api/contracts/v2/openapi/resources.test.ts b/apps/sim/lib/api/contracts/v2/openapi/resources.test.ts deleted file mode 100644 index b4276912e8a..00000000000 --- a/apps/sim/lib/api/contracts/v2/openapi/resources.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it } from 'vitest' -import { billingOpenApiDocument } from '@/lib/api/contracts/v2/openapi/billing' -import { logsOpenApiDocument } from '@/lib/api/contracts/v2/openapi/logs' -import { resourcesOpenApiDocument } from '@/lib/api/contracts/v2/openapi/resources' -import type { OpenApiDocumentDefinition, OpenApiRouteDefinition } from '@/lib/api/openapi/types' - -/** - * Mirrors `shouldReadJsonBody` in `lib/api/server/validation`: a contract's body - * is read, and therefore size-capped, exactly when it is declared on a non-`GET` - * method. Restating the predicate rather than importing it keeps this file out - * of the server graph, which the spec generator must not pull in. - */ -function readsJsonBody(route: OpenApiRouteDefinition): boolean { - return Boolean(route.contract.body) && route.contract.method !== 'GET' -} - -function label(route: OpenApiRouteDefinition): string { - return `${route.operation.operationId} (${route.contract.method} ${route.contract.path})` -} - -const DOCUMENTS: readonly OpenApiDocumentDefinition[] = [ - resourcesOpenApiDocument, - billingOpenApiDocument, - logsOpenApiDocument, -] - -describe.each(DOCUMENTS.map((document) => [document.output, document] as const))( - '%s', - (_output, document) => { - /** - * `parseRequest` buffers the JSON body under `DEFAULT_MAX_JSON_BODY_BYTES` - * before any schema runs, and the v2 builders supply the 413 renderer, so - * every body-carrying operation can answer 413 whether or not it says so. A - * status a caller cannot see in the spec is a status they will not handle. - * - * The sweep is deliberately one-directional. Several bodyless operations - * publish 413 for their own ceilings — a folder tree too large to load, a - * generated artifact too large to render — so the converse is false and - * asserting it would flag correct documentation. - */ - it('publishes 413 on every operation whose contract carries a request body', () => { - const undocumented = document.routes - .filter( - (route) => readsJsonBody(route) && !route.operation.errors.includes('PayloadTooLarge') - ) - .map(label) - - expect(undocumented).toEqual([]) - }) - } -) diff --git a/apps/sim/lib/api/server/blank-query-values.test.ts b/apps/sim/lib/api/server/blank-query-values.test.ts index 0b98d8a1adf..514e8879bc4 100644 --- a/apps/sim/lib/api/server/blank-query-values.test.ts +++ b/apps/sim/lib/api/server/blank-query-values.test.ts @@ -1,9 +1,17 @@ /** * @vitest-environment node */ +import { NextRequest } from 'next/server' import { describe, expect, it } from 'vitest' -import { blankQueryValueValidationError } from '@/lib/api/server/blank-query-values' +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts' +import { + blankQueryValueValidationError, + duplicateQueryValueValidationError, +} from '@/lib/api/server/blank-query-values' import { V2_PARSE_DEFAULTS } from '@/lib/api/server/routes/v2-json-route' +import { parseRequest } from '@/lib/api/server/validation' +import { v2ValidationError } from '@/app/api/v2/lib/response' /** * A query parameter that is present but blank is a different request from one @@ -51,3 +59,71 @@ describe('blank query values', () => { expect(V2_PARSE_DEFAULTS.rejectBlankQueryValues).toBe(true) }) }) + +const listContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/widgets', + query: z.object({ workspaceId: z.string().min(1, 'Workspace ID is required') }), + response: { mode: 'json', schema: z.object({ data: z.array(z.string()) }) }, +}) + +function listRequest(search: string): NextRequest { + return new NextRequest(`http://localhost/api/v2/widgets?${search}`, { method: 'GET' }) +} + +async function parseListRequest(search: string) { + return parseRequest( + listContract, + listRequest(search), + {}, + { + ...V2_PARSE_DEFAULTS, + validationErrorResponse: v2ValidationError, + } + ) +} + +/** + * A repeated parameter reaches the schema as an array, and no v2 query param is + * declared as one — so without this rule the caller is told the param is + * *missing* for a request that plainly sent it twice. + */ +describe('duplicate query values', () => { + it('names the duplication rather than the schema type failure', () => { + const error = duplicateQueryValueValidationError({ workspaceId: ['w-1', 'w-1'] }) + + expect(error?.issues[0]).toMatchObject({ + path: ['workspaceId'], + message: 'workspaceId was sent 2 times; send it at most once', + }) + }) + + it('accepts a query where every parameter appears once', () => { + expect(duplicateQueryValueValidationError({ workspaceId: 'w-1', limit: '10' })).toBeNull() + }) + + it('rejects a repeated parameter through parseRequest under the v2 defaults', async () => { + const parsed = await parseListRequest('workspaceId=w-1&workspaceId=w-1') + + expect(parsed.success).toBe(false) + if (parsed.success) return + expect(parsed.response.status).toBe(400) + await expect(parsed.response.json()).resolves.toMatchObject({ + error: expect.objectContaining({ + message: expect.stringContaining('workspaceId was sent 2 times; send it at most once'), + }), + }) + }) + + it('lets a query sending each parameter once through parseRequest', async () => { + const parsed = await parseListRequest('workspaceId=w-1') + + expect(parsed.success).toBe(true) + if (!parsed.success) return + expect(parsed.data.query).toEqual({ workspaceId: 'w-1' }) + }) + + it('is on for every v2 route through the shared parse defaults', () => { + expect(V2_PARSE_DEFAULTS.rejectDuplicateQueryValues).toBe(true) + }) +}) diff --git a/apps/sim/lib/api/server/routes/v2-json-route.test.ts b/apps/sim/lib/api/server/routes/v2-json-route.test.ts index a63b6d13c10..309249b8eda 100644 --- a/apps/sim/lib/api/server/routes/v2-json-route.test.ts +++ b/apps/sim/lib/api/server/routes/v2-json-route.test.ts @@ -644,3 +644,73 @@ describe('defineV2JsonRoute HEAD on a route that is not head-safe', () => { ).rejects.toThrow(/authorize/) }) }) + +const presenterContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/widgets/[widgetId]/pages', + params: z.object({ widgetId: z.string() }).strict(), + query: z.object({ sort: z.string(), workspaceId: z.string() }).strict(), + body: z.object({ value: z.string() }).strict(), + response: { + mode: 'json', + status: 201, + schema: z.object({ data: z.object({ value: z.string() }), nextCursor: z.string() }), + }, +}) + +/** + * A `nextCursor` is stamped with the sort and filters the page was read under, + * and those live in the request rather than the domain result — so a presenter + * that cannot see the parsed request forces the use case to carry an HTTP + * cursor-encoding concern back out. + */ +describe('defineV2JsonRoute presentation', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue({ allowed: true, remaining: 599, resetAt }) + v2RouteMocks.operationRate.mockResolvedValue(allowedRate) + }) + + it('hands the presenter the parsed request alongside the result', async () => { + const present = vi.fn((result: Result, parsed: ParsedRequest) => ({ + data: result, + nextCursor: `${parsed.params.widgetId}:${parsed.query.sort}:${parsed.body.value}`, + })) + + const handler = defineV2JsonRoute({ + contract: presenterContract, + auth: v2ApiKeyAuth, + operation, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ body }) => body, + useCase: { operation, execute: async ({ input }) => input }, + present, + }) + + const response = await handler( + new NextRequest('http://localhost/api/v2/widgets/widget-1/pages?sort=asc&workspaceId=ws-1', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, + body: JSON.stringify({ value: 'ok' }), + }), + { params: Promise.resolve({ widgetId: 'widget-1' }) } + ) + + expect(response.status).toBe(201) + await expect(response.json()).resolves.toEqual({ + data: { value: 'ok' }, + nextCursor: 'widget-1:asc:ok', + }) + expect(present).toHaveBeenCalledWith( + { value: 'ok' }, + expect.objectContaining({ + params: { widgetId: 'widget-1' }, + query: { sort: 'asc', workspaceId: 'ws-1' }, + body: { value: 'ok' }, + }) + ) + }) +}) diff --git a/apps/sim/lib/core/utils/stream-limits.test.ts b/apps/sim/lib/core/utils/stream-limits.test.ts index eb5cff8fa2b..65d789887b1 100644 --- a/apps/sim/lib/core/utils/stream-limits.test.ts +++ b/apps/sim/lib/core/utils/stream-limits.test.ts @@ -36,20 +36,37 @@ function streamFromChunks(chunks: Uint8Array[]): ReadableStream { * a filename on serialization, so a hand-written part is the only way to put * the byte on the wire exactly as a real client can. */ -function multipartRequest(disposition: string, value: string): Request { +function multipartRequest( + disposition: string, + value: string, + options: { declareContentLength?: boolean } = {} +): Request { const boundary = 'streamlimitsboundary' const body = `--${boundary}\r\n` + `Content-Disposition: form-data; ${disposition}\r\n` + `Content-Type: text/plain\r\n\r\n${value}\r\n` + `--${boundary}--\r\n` + const bytes = new TextEncoder().encode(body) + const requestHeaders = new Headers({ + 'content-type': `multipart/form-data; boundary=${boundary}`, + }) + if (options.declareContentLength) { + requestHeaders.set('content-length', String(bytes.byteLength)) + } return new Request('http://localhost/upload', { method: 'POST', - headers: { 'content-type': `multipart/form-data; boundary=${boundary}` }, - body: new TextEncoder().encode(body), + headers: requestHeaders, + body: bytes, }) } +const NUL_MULTIPART_PARTS = [ + ['a NUL in a file name', 'name="file"; filename="apitest_\u0000x.txt"', 'hello'], + ['a NUL in a text field value', 'name="label"', 'apitest_\u0000x'], + ['a NUL in a field name', 'name="apitest_\u0000x"', 'hello'], +] as const + function headers(contentLength?: string): Headers { const headers = new Headers() if (contentLength !== undefined) headers.set('content-length', contentLength) @@ -217,18 +234,33 @@ describe('stream limits', () => { expect(formData.get('name')).toBe('example') }) - it.each([ - ['a NUL in a file name', 'name="file"; filename="apitest_\u0000x.txt"', 'hello'], - ['a NUL in a text field value', 'name="label"', 'apitest_\u0000x'], - ['a NUL in a field name', 'name="apitest_\u0000x"', 'hello'], - ])('rejects multipart form data carrying %s', async (_label, disposition, value) => { - await expect( - readFormDataWithLimit(multipartRequest(disposition, value), { - maxBytes: 1024 * 1024, - label: 'multipart body', - }) - ).rejects.toBeInstanceOf(MultipartFieldValidationError) - }) + it.each(NUL_MULTIPART_PARTS)( + 'rejects a streamed multipart body carrying %s', + async (_label, disposition, value) => { + await expect( + readFormDataWithLimit(multipartRequest(disposition, value), { + maxBytes: 1024 * 1024, + label: 'multipart body', + }) + ).rejects.toBeInstanceOf(MultipartFieldValidationError) + } + ) + + /** + * A declared `content-length` takes the reader's other branch — the one every + * ordinary browser and curl upload takes — and it scans fields separately. + */ + it.each(NUL_MULTIPART_PARTS)( + 'rejects a content-length multipart body carrying %s', + async (_label, disposition, value) => { + const request = multipartRequest(disposition, value, { declareContentLength: true }) + expect(request.headers.get('content-length')).not.toBeNull() + + await expect( + readFormDataWithLimit(request, { maxBytes: 1024 * 1024, label: 'multipart body' }) + ).rejects.toBeInstanceOf(MultipartFieldValidationError) + } + ) it('rejects multipart streams without content-length once bytes exceed the limit', async () => { const request = new Request('http://localhost/upload', { diff --git a/apps/sim/lib/knowledge/documents/processing-claim.test.ts b/apps/sim/lib/knowledge/documents/processing-claim.test.ts index 6d898f60c40..34d4df22149 100644 --- a/apps/sim/lib/knowledge/documents/processing-claim.test.ts +++ b/apps/sim/lib/knowledge/documents/processing-claim.test.ts @@ -2,10 +2,11 @@ * @vitest-environment node */ -import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, hasMockCondition, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { failStaleDocumentProcessingClaim, + failUndispatchedDocumentProcessing, KNOWLEDGE_DOCUMENT_PROCESSING_STALE_THRESHOLD_MS, reclaimStaleDocumentProcessingClaim, } from '@/lib/knowledge/documents/processing-claim' @@ -127,3 +128,57 @@ describe('failStaleDocumentProcessingClaim', () => { expect(result.success).toBe(false) }) }) + +describe('failUndispatchedDocumentProcessing', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('fails the exact pending document', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'document-1' }]) + + const failed = await failUndispatchedDocumentProcessing({ + documentId: 'document-1', + knowledgeBaseId: 'knowledge-base-1', + error: 'Failed to start processing', + now: NOW, + }) + + expect(failed).toBe(true) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ + processingStatus: 'failed', + processingError: 'Failed to start processing', + processingCompletedAt: NOW, + }) + }) + + /** + * The dispatch may have been accepted and only its acknowledgement lost, so a + * document a worker already claimed — or one already deleted — must survive + * this write untouched. + */ + it('scopes the write to a pending, undeleted document', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + + const failed = await failUndispatchedDocumentProcessing({ + documentId: 'document-1', + knowledgeBaseId: 'knowledge-base-1', + error: 'Failed to start processing', + now: NOW, + }) + + expect(failed).toBe(false) + + const where = dbChainMockFns.where.mock.calls[0]?.[0] + expect( + hasMockCondition( + where, + (node) => node.type === 'eq' && node.left === 'processingStatus' && node.right === 'pending' + ) + ).toBe(true) + expect( + hasMockCondition(where, (node) => node.type === 'isNull' && node.column === 'deletedAt') + ).toBe(true) + }) +}) diff --git a/apps/sim/lib/table/views/service.test.ts b/apps/sim/lib/table/views/service.test.ts index ca6c1267b8a..2269bdc51b0 100644 --- a/apps/sim/lib/table/views/service.test.ts +++ b/apps/sim/lib/table/views/service.test.ts @@ -1,22 +1,17 @@ /** * @vitest-environment node */ -import { db } from '@sim/db' import { tableViews } from '@sim/db/schema' import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ColumnDefinition, TableViewConfig } from '@/lib/table/types' -const { mockSignalTableViewsChanged, mockWithLockedTable } = vi.hoisted(() => ({ +const { mockSignalTableViewsChanged } = vi.hoisted(() => ({ mockSignalTableViewsChanged: vi.fn(), - mockWithLockedTable: vi.fn(), })) vi.mock('@/lib/table/events', () => ({ signalTableViewsChanged: mockSignalTableViewsChanged, })) -vi.mock('@/lib/table/service', () => ({ - withLockedTable: mockWithLockedTable, -})) import { TABLE_LIMITS } from '@/lib/table/constants' import { @@ -143,10 +138,6 @@ describe('table-view mutations signal collaborators', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - mockWithLockedTable.mockImplementation( - async (_tableId: string, mutate: (table: unknown, trx: unknown) => unknown) => - mutate({ id: 'table-1' }, db) - ) }) it('createTableView signals the table after inserting', async () => { @@ -276,10 +267,6 @@ describe('saved-view ceiling', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - mockWithLockedTable.mockImplementation( - async (_tableId: string, mutate: (table: unknown, trx: unknown) => unknown) => - mutate({ id: 'table-1' }, db) - ) }) function create() { @@ -301,27 +288,6 @@ describe('saved-view ceiling', () => { expect(mockSignalTableViewsChanged).not.toHaveBeenCalled() }) - it('serializes on the views lock rather than the table schema lock', async () => { - queueTableRows(tableViews, [{ total: 0 }]) - dbChainMockFns.returning.mockResolvedValueOnce([ - { - id: 'view-100', - tableId: 'table-1', - workspaceId: 'ws-1', - name: 'Another View', - config: {}, - isDefault: false, - createdBy: 'user-1', - createdAt: new Date('2026-01-01T00:00:00.000Z'), - updatedAt: new Date('2026-01-01T00:00:00.000Z'), - }, - ]) - - await create() - - expect(mockWithLockedTable).not.toHaveBeenCalled() - }) - it('allows the create that lands exactly on the ceiling', async () => { queueTableRows(tableViews, [{ total: TABLE_LIMITS.MAX_VIEWS_PER_TABLE - 1 }]) dbChainMockFns.returning.mockResolvedValueOnce([ @@ -368,10 +334,6 @@ describe('view config column-reference normalization', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - mockWithLockedTable.mockImplementation( - async (_tableId: string, mutate: (table: unknown, trx: unknown) => unknown) => - mutate({ id: 'table-1' }, db) - ) }) function insertedConfig(): TableViewConfig { diff --git a/apps/sim/lib/uploads/core/storage-key.test.ts b/apps/sim/lib/uploads/core/storage-key.test.ts index 613a503b979..44e8670a23f 100644 --- a/apps/sim/lib/uploads/core/storage-key.test.ts +++ b/apps/sim/lib/uploads/core/storage-key.test.ts @@ -3,10 +3,7 @@ */ import { describe, expect, it } from 'vitest' -import { - generateLargeValuePayloadKey, - generateUniqueExecutionFileKey, -} from '@/lib/uploads/contexts/execution/utils' +import { generateUniqueExecutionFileKey } from '@/lib/uploads/contexts/execution/utils' import { generateKnowledgeBaseFileKey } from '@/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager' import { buildStorageKeySegment, @@ -68,14 +65,6 @@ describe('storage key segments', () => { MAX_CONTRACT_NAME ), ], - [ - 'large value payload', - () => - generateLargeValuePayloadKey( - { workspaceId: 'ws', workflowId: 'wf', executionId: 'ex' }, - 'p' - ), - ], ])('bounds the last component of a %s key, sidecar included', (_label, generate) => { expect(lastSegmentBytes(generate()) + LOCAL_UPLOAD_METADATA_SUFFIX.length).toBeLessThanOrEqual( NAME_MAX diff --git a/apps/sim/lib/uploads/upload-session/service.test.ts b/apps/sim/lib/uploads/upload-session/service.test.ts index adbe26749a4..96b9413780d 100644 --- a/apps/sim/lib/uploads/upload-session/service.test.ts +++ b/apps/sim/lib/uploads/upload-session/service.test.ts @@ -36,11 +36,9 @@ vi.mock('@/lib/billing/storage', () => ({ /** * Stands in for the workspace-files barrel, which pulls the whole file manager. - * The stub still builds its key through the real {@link buildStorageKeySegment}, - * so the shared name budget below is genuinely exercised — but the prefix is the - * stub's own, so the two workspace-keyed purposes below prove nothing about - * `generateWorkspaceFileKey`'s prefix. That one is measured against the real - * function in `contexts/workspace/workspace-file-manager.test.ts`. + * The real `generateWorkspaceFileKey` and its name budget are measured in + * `contexts/workspace/workspace-file-manager.test.ts`, so the purposes that key + * through it are deliberately absent from the sidecar-bounds sweep below. */ vi.mock('@/lib/uploads/contexts/workspace', async () => { const { buildStorageKeySegment } = await import('@/lib/uploads/core/storage-key') @@ -156,12 +154,10 @@ describe('upload sessions', () => { // straight into it: the session was created, its transfer URL issued, and // every request against it then failed with an unclassifiable 500. it.each([ - ['workspace_file', {}], ['knowledge_document', { knowledgeBaseId: 'kb-1' }], ['table_import', {}], ['profile_picture', {}], ['workspace_logo', {}], - ['mothership_attachment', {}], ['execution_attachment', { workflowId: 'workflow-1', executionId: 'execution-1' }], ])('bounds the %s key so its local sidecar still fits', async (purpose, extra) => { dbChainMockFns.returning.mockResolvedValue([uploadRow({ purpose })]) From ea5cf6408aef555b0d68f674170480294ed19a82 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 13 Aug 2026 00:42:30 -0700 Subject: [PATCH 43/56] fix(v2): parse a bound list filter once, so the scope matches the query The logs list fingerprinted `workflowIds`, `triggers`, and `folderPaths` through unorderedScopePart, which trims each member, then split the same raw values itself with `.split(',').filter(Boolean)`, which does not. So `?workflowIds=A,B` and `?workflowIds=A, B` produced one fingerprint and two different result sets: the second selects on a member with a leading space that matches no row. A cursor minted under one was accepted under the other, which is the exact failure the filter binding exists to refuse. Extracts parseUnorderedList as the single parse. unorderedScopePart now derives from it, and the route passes the array to the query and the joined form to the scope, so the members fingerprinted are by construction the members filtered on. Also drops three inline splits. Reported by Greptile. --- apps/sim/app/api/v2/logs/route.ts | 7 ++++--- apps/sim/lib/api/cursor-binding.test.ts | 14 +++++++++++++- apps/sim/lib/api/cursor-binding.ts | 23 +++++++++++++++++++---- 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/apps/sim/app/api/v2/logs/route.ts b/apps/sim/app/api/v2/logs/route.ts index 2f024611c68..c534385bcba 100644 --- a/apps/sim/app/api/v2/logs/route.ts +++ b/apps/sim/app/api/v2/logs/route.ts @@ -6,6 +6,7 @@ import { } from '@/lib/api/contracts/v2/logs' import { cursorScopeKey, + parseUnorderedList, UNREADABLE_CURSOR_MESSAGE, unorderedScopePart, } from '@/lib/api/cursor-binding' @@ -76,8 +77,8 @@ export const GET = defineV2JsonRoute({ return { workspaceId: query.workspaceId, filters: { - workflowIds: query.workflowIds?.split(',').filter(Boolean), - triggers: query.triggers?.split(',').filter(Boolean), + workflowIds: parseUnorderedList(query.workflowIds), + triggers: parseUnorderedList(query.triggers), level: query.level, startDate: query.startDate ? new Date(query.startDate) : undefined, endDate: query.endDate ? new Date(query.endDate) : undefined, @@ -90,7 +91,7 @@ export const GET = defineV2JsonRoute({ cursor: decodedCursor ?? undefined, order: query.order, }, - folderPaths: query.folderPaths?.split(',').filter(Boolean), + folderPaths: parseUnorderedList(query.folderPaths), limit: query.limit, includeFullDetails: query.details === 'full' || query.includeFinalOutput || query.includeTraceSpans, diff --git a/apps/sim/lib/api/cursor-binding.test.ts b/apps/sim/lib/api/cursor-binding.test.ts index ad88846d846..29f94845ef7 100644 --- a/apps/sim/lib/api/cursor-binding.test.ts +++ b/apps/sim/lib/api/cursor-binding.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { cursorScopeKey, unorderedScopePart } from '@/lib/api/cursor-binding' +import { cursorScopeKey, parseUnorderedList, unorderedScopePart } from '@/lib/api/cursor-binding' import { cursorSortKey, decodeOffsetCursor, @@ -207,6 +207,18 @@ describe('unordered filter scope parts', () => { expect(unorderedScopePart('B,A,B')).toBe('A,B') }) + /** + * The scope and the query must read one parse. When the scope trimmed members + * and the route split the raw value itself, `A,B` and `A, B` shared a + * fingerprint while selecting different rows — a cursor accepted across a + * change that moved the sequence, which is the failure the binding prevents. + */ + it('parses the members it fingerprints', () => { + expect(parseUnorderedList('A, B')).toEqual(['A', 'B']) + expect(parseUnorderedList('A,B')).toEqual(parseUnorderedList('A, B')) + expect(unorderedScopePart('A, B')).toBe(parseUnorderedList('A, B')?.join(',')) + }) + it('still separates genuinely different sets', () => { expect(cursorScopeKey({ workflowIds: unorderedScopePart('A,B') })).not.toBe( cursorScopeKey({ workflowIds: unorderedScopePart('A,C') }) diff --git a/apps/sim/lib/api/cursor-binding.ts b/apps/sim/lib/api/cursor-binding.ts index 5d543a66bf2..04287f010f4 100644 --- a/apps/sim/lib/api/cursor-binding.ts +++ b/apps/sim/lib/api/cursor-binding.ts @@ -63,12 +63,28 @@ export type CursorScopePart = * {@link canonicalJson} already sorts object keys, so this only has to normalize * the list. Members are de-duplicated as well as sorted: the filters compile to * `inArray`, which is set membership, so `A,A,B` selects exactly what `A,B` does - * and must not bind to a different page. Empty members are dropped because the - * parsers drop them too. + * and must not bind to a different page. + * + * Derived from {@link parseUnorderedList} rather than parsing again, so the + * members this fingerprints are exactly the members the query filters on. A + * route that canonicalized here and split the raw value itself would give + * `A,B` and `A, B` one fingerprint and two different result sets. */ export function unorderedScopePart(raw: string | undefined): string | undefined { + const members = parseUnorderedList(raw) + return members && members.length > 0 ? members.join(',') : undefined +} + +/** + * The members of a comma-separated filter, trimmed, de-duplicated, and sorted. + * + * The one parse for both halves of a bound list filter: pass the array to the + * query and {@link unorderedScopePart} to the cursor scope. Callers must not + * re-split the raw value for one half — that is what lets the two drift. + */ +export function parseUnorderedList(raw: string | undefined): string[] | undefined { if (raw === undefined) return undefined - const members = [ + return [ ...new Set( raw .split(',') @@ -76,7 +92,6 @@ export function unorderedScopePart(raw: string | undefined): string | undefined .filter((member) => member.length > 0) ), ].sort() - return members.length > 0 ? members.join(',') : undefined } /** From 9ad50fdf8bb95a30229c5bee25df5e1c52764d34 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 13 Aug 2026 01:04:11 -0700 Subject: [PATCH 44/56] fix(v2): bind an AND-conjoined filter array as a set, not a sequence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The knowledge documents list fingerprinted tagFilters through canonicalJson, which sorts object keys but preserves array order. Each filter compiles to a condition in and(...whereConditions), and AND is commutative, so the same clauses written in a different order select the same documents — and got a different fingerprint, refusing a cursor for a page that was genuinely the next one. Adds unorderedJsonScopePart beside parseUnorderedList: members are canonicalized, de-duplicated, and sorted, so `A AND A` binds like `A` and clause order stops mattering. A non-array or unparseable value still binds by its raw spelling, since that request fails validation anyway. Replaces the route-local canonicalTagFilters, and corrects the claim on canonicalJson that array order only ever costs a restart — for a set-valued filter it costs a spurious 400. Reported by Greptile. --- .../api/v2/knowledge/[id]/documents/route.ts | 19 ++---------- apps/sim/lib/api/cursor-binding.test.ts | 30 ++++++++++++++++++- apps/sim/lib/api/cursor-binding.ts | 29 ++++++++++++++++-- 3 files changed, 58 insertions(+), 20 deletions(-) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts index 88676fb65e8..a17c738565e 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts @@ -4,7 +4,7 @@ import { v2ListKnowledgeDocumentsContract, v2UploadKnowledgeDocumentContract, } from '@/lib/api/contracts/v2/knowledge' -import { canonicalJson, cursorScopeKey } from '@/lib/api/cursor-binding' +import { cursorScopeKey, unorderedJsonScopePart } from '@/lib/api/cursor-binding' import { defineV2BodyLifecycleRoute, defineV2JsonRoute, @@ -40,21 +40,6 @@ export const revalidate = 0 const MAX_FILE_SIZE = MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE -/** - * Canonical form of `tagFilters` so two equivalent filters differing only in - * key order fingerprint the same. {@link canonicalJson} sorts object keys. An - * unparseable value binds by its raw spelling — the request carrying it is - * about to fail validation anyway. - */ -function canonicalTagFilters(raw: string | undefined): string | undefined { - if (raw === undefined) return undefined - try { - return canonicalJson(JSON.parse(raw)) - } catch { - return raw - } -} - /** Every param that changes which documents, in which order, this list returns. */ function documentCursorFilters( knowledgeBaseId: string, @@ -65,7 +50,7 @@ function documentCursorFilters( workspaceId: query.workspaceId, enabledFilter: query.enabledFilter, search: query.search, - tagFilters: canonicalTagFilters(query.tagFilters), + tagFilters: unorderedJsonScopePart(query.tagFilters), }) } diff --git a/apps/sim/lib/api/cursor-binding.test.ts b/apps/sim/lib/api/cursor-binding.test.ts index 29f94845ef7..db431607bf4 100644 --- a/apps/sim/lib/api/cursor-binding.test.ts +++ b/apps/sim/lib/api/cursor-binding.test.ts @@ -2,7 +2,12 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { cursorScopeKey, parseUnorderedList, unorderedScopePart } from '@/lib/api/cursor-binding' +import { + cursorScopeKey, + parseUnorderedList, + unorderedJsonScopePart, + unorderedScopePart, +} from '@/lib/api/cursor-binding' import { cursorSortKey, decodeOffsetCursor, @@ -219,6 +224,29 @@ describe('unordered filter scope parts', () => { expect(unorderedScopePart('A, B')).toBe(parseUnorderedList('A, B')?.join(',')) }) + /** + * Tag filters compile to `and(...)`, so reordering the clauses selects the + * same documents. Binding to the order a caller happened to write them in + * refused a cursor for a page that was genuinely the next one. + */ + it('treats an AND-conjoined filter array as a set', () => { + const ab = '[{"name":"a","value":"1"},{"name":"b","value":"2"}]' + const ba = '[{"name":"b","value":"2"},{"name":"a","value":"1"}]' + + expect(unorderedJsonScopePart(ab)).toBe(unorderedJsonScopePart(ba)) + expect(unorderedJsonScopePart('[{"name":"a"},{"name":"a"}]')).toBe( + unorderedJsonScopePart('[{"name":"a"}]') + ) + expect(unorderedJsonScopePart(ab)).not.toBe( + unorderedJsonScopePart('[{"name":"a","value":"1"}]') + ) + }) + + it('binds an unparseable filter by its raw spelling', () => { + expect(unorderedJsonScopePart('{not json')).toBe('{not json') + expect(unorderedJsonScopePart(undefined)).toBeUndefined() + }) + it('still separates genuinely different sets', () => { expect(cursorScopeKey({ workflowIds: unorderedScopePart('A,B') })).not.toBe( cursorScopeKey({ workflowIds: unorderedScopePart('A,C') }) diff --git a/apps/sim/lib/api/cursor-binding.ts b/apps/sim/lib/api/cursor-binding.ts index 04287f010f4..760cec69722 100644 --- a/apps/sim/lib/api/cursor-binding.ts +++ b/apps/sim/lib/api/cursor-binding.ts @@ -94,13 +94,38 @@ export function parseUnorderedList(raw: string | undefined): string[] | undefine ].sort() } +/** + * Canonical form of a JSON-encoded filter array whose members are AND-conjoined. + * + * {@link canonicalJson} preserves array order, which is right for a sequence but + * wrong for a set: clauses that compile to `and(...)` select the same rows in any + * order, so binding to the order a caller happened to write them refuses a cursor + * for a page that is genuinely the next one. Members are canonicalized, then + * de-duplicated and sorted — `A AND A` selects what `A` does. + * + * A non-array or unparseable value binds by its raw spelling: the request + * carrying it is about to fail validation anyway. + */ +export function unorderedJsonScopePart(raw: string | undefined): string | undefined { + if (raw === undefined) return undefined + try { + const parsed: unknown = JSON.parse(raw) + if (!Array.isArray(parsed)) return canonicalJson(parsed) + return `[${[...new Set(parsed.map(canonicalJson))].sort().join(',')}]` + } catch { + return raw + } +} + /** * Deterministic JSON: object keys sorted so two structurally equal values * serialize identically regardless of the key order they arrived in, and * `undefined` members dropped so an omitted param and an absent one agree. * - * Array order is preserved — reordering an `in` list is treated as a different - * filter, which only ever costs a restart. + * Array order is preserved, because an array is a sequence in the general case. + * A filter whose array is really a set must canonicalize it first — see + * {@link parseUnorderedList} and {@link unorderedJsonScopePart} — or equivalent + * queries fingerprint differently and a valid cursor is refused. */ export function canonicalJson(value: unknown): string { if (value instanceof Date) return JSON.stringify(value.toISOString()) From 065eda214fd6a713f54cdca223156d2d0f5f6a4c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 13 Aug 2026 01:38:42 -0700 Subject: [PATCH 45/56] fix(v2): bind list filters by the value the query acts on, not its spelling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third report of one root cause, so this fixes the cause rather than the case. A cursor scope must fingerprint what the query filters on; every place it fingerprinted the caller's raw text instead, two spellings of one filter got two scopes and a valid next page got a 400. Knowledge documents: tagFilters bound the raw query text while the route already parsed it two lines below for the use case. The schema defaults operator to 'eq', so {tagName,value} and {tagName,value,operator:'eq'} are one filter to the query and were two scopes to the cursor. The scope now binds the parser's output, which also subsumes the clause-order fix — both route tests go red against the raw-text form. Logs and workflow runs: startDate/endDate bound the raw text, but z.string().datetime() admits every sub-second spelling of one instant, so `…00Z` and `…00.000Z` name one window and got two scopes. New instantScopePart binds the parsed instant. Replaces unorderedJsonScopePart, which took raw text and could not see a schema default, with unorderedScopeOf over the parsed value. Swept all fourteen routes that build a cursor scope for the same divergence; these were the only ones where a scope part is derived differently from the value reaching the use case. Reported by Greptile. --- .../v2/knowledge/[id]/documents/route.test.ts | 29 ++++++++ .../api/v2/knowledge/[id]/documents/route.ts | 14 +++- apps/sim/app/api/v2/logs/route.ts | 5 +- .../app/api/v2/workflows/[id]/runs/route.ts | 10 ++- apps/sim/lib/api/cursor-binding.test.ts | 66 +++++++++++++++---- apps/sim/lib/api/cursor-binding.ts | 44 ++++++++----- 6 files changed, 133 insertions(+), 35 deletions(-) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/route.test.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/route.test.ts index a8962620ef0..0b261215be6 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/route.test.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/route.test.ts @@ -398,6 +398,35 @@ describe('GET /api/v2/knowledge/[id]/documents', () => { expect(mockListDocuments).not.toHaveBeenCalled() }) + /** + * `tagFilters` binds through the contract's parser, so spellings that parse to + * one filter share one scope. The schema defaults `operator` to `eq` and AND + * is commutative, so omitting the operator, stating it, and reordering the + * clauses all name the same sequence and must all resume. + */ + it.each([ + [ + 'the default operator stated explicitly', + '[{"tagName":"a","value":"1","operator":"eq"},{"tagName":"b","value":"2","operator":"eq"}]', + ], + ['the clauses reordered', '[{"tagName":"b","value":"2"},{"tagName":"a","value":"1"}]'], + ])('resumes a tag-filter cursor with %s', async (_label, replayFilters) => { + const mintFilters = '[{"tagName":"a","value":"1"},{"tagName":"b","value":"2"}]' + const minted = await list( + `workspaceId=${WORKSPACE_ID}&limit=1&tagFilters=${encodeURIComponent(mintFilters)}` + ) + const { nextCursor } = await minted.json() + expect(nextCursor).toEqual(expect.any(String)) + + mockListDocuments.mockClear() + const resumed = await list( + `workspaceId=${WORKSPACE_ID}&limit=1&tagFilters=${encodeURIComponent(replayFilters)}&cursor=${encodeURIComponent(nextCursor)}` + ) + + expect(resumed.status).toBe(200) + expect(mockListDocuments).toHaveBeenCalled() + }) + it('resumes a cursor replayed under the filters it was minted with', async () => { const minted = await list(`workspaceId=${WORKSPACE_ID}&limit=1&search=support`) const { nextCursor } = await minted.json() diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts index a17c738565e..0189e529179 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts @@ -4,7 +4,7 @@ import { v2ListKnowledgeDocumentsContract, v2UploadKnowledgeDocumentContract, } from '@/lib/api/contracts/v2/knowledge' -import { cursorScopeKey, unorderedJsonScopePart } from '@/lib/api/cursor-binding' +import { cursorScopeKey, unorderedScopeOf } from '@/lib/api/cursor-binding' import { defineV2BodyLifecycleRoute, defineV2JsonRoute, @@ -40,17 +40,25 @@ export const revalidate = 0 const MAX_FILE_SIZE = MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE -/** Every param that changes which documents, in which order, this list returns. */ +/** + * Every param that changes which documents, in which order, this list returns. + * + * `tagFilters` binds through the contract's parser, not the raw query text: the + * schema defaults `operator` to `eq`, so `{tagName}` and `{tagName, operator}` + * are one filter to the query and must be one scope to the cursor. An + * unparseable value binds raw — that request is about to 400 anyway. + */ function documentCursorFilters( knowledgeBaseId: string, query: { workspaceId: string; enabledFilter?: string; search?: string; tagFilters?: string } ) { + const parsed = parseV2KnowledgeTagFiltersParam(query.tagFilters) return cursorScopeKey({ knowledgeBaseId, workspaceId: query.workspaceId, enabledFilter: query.enabledFilter, search: query.search, - tagFilters: unorderedJsonScopePart(query.tagFilters), + tagFilters: parsed.success ? unorderedScopeOf(parsed.filters) : query.tagFilters, }) } diff --git a/apps/sim/app/api/v2/logs/route.ts b/apps/sim/app/api/v2/logs/route.ts index c534385bcba..5ecf1a474f3 100644 --- a/apps/sim/app/api/v2/logs/route.ts +++ b/apps/sim/app/api/v2/logs/route.ts @@ -6,6 +6,7 @@ import { } from '@/lib/api/contracts/v2/logs' import { cursorScopeKey, + instantScopePart, parseUnorderedList, UNREADABLE_CURSOR_MESSAGE, unorderedScopePart, @@ -49,8 +50,8 @@ function logCursorFilters(query: { workflowIds: unorderedScopePart(query.workflowIds), triggers: unorderedScopePart(query.triggers), level: query.level, - startDate: query.startDate, - endDate: query.endDate, + startDate: instantScopePart(query.startDate), + endDate: instantScopePart(query.endDate), runId: query.runId, minDurationMs: query.minDurationMs, maxDurationMs: query.maxDurationMs, diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/route.ts b/apps/sim/app/api/v2/workflows/[id]/runs/route.ts index d096e7d5775..a683643347b 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/route.ts @@ -3,7 +3,11 @@ import { v2ListWorkflowRunsContract, v2WorkflowRunListStatusValueSchema, } from '@/lib/api/contracts/v2/workflows' -import { cursorScopeKey, REFILTERED_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' +import { + cursorScopeKey, + instantScopePart, + REFILTERED_CURSOR_MESSAGE, +} from '@/lib/api/cursor-binding' import { INVALID_CURSOR_MESSAGE } from '@/lib/api/list-query' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -24,8 +28,8 @@ function runCursorFilters( workflowId, status: query.status, trigger: query.trigger, - startDate: query.startDate, - endDate: query.endDate, + startDate: instantScopePart(query.startDate), + endDate: instantScopePart(query.endDate), }) } diff --git a/apps/sim/lib/api/cursor-binding.test.ts b/apps/sim/lib/api/cursor-binding.test.ts index db431607bf4..84caf533a45 100644 --- a/apps/sim/lib/api/cursor-binding.test.ts +++ b/apps/sim/lib/api/cursor-binding.test.ts @@ -2,10 +2,12 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' +import { parseV2KnowledgeTagFiltersParam } from '@/lib/api/contracts/v2/knowledge' import { cursorScopeKey, + instantScopePart, parseUnorderedList, - unorderedJsonScopePart, + unorderedScopeOf, unorderedScopePart, } from '@/lib/api/cursor-binding' import { @@ -230,21 +232,63 @@ describe('unordered filter scope parts', () => { * refused a cursor for a page that was genuinely the next one. */ it('treats an AND-conjoined filter array as a set', () => { - const ab = '[{"name":"a","value":"1"},{"name":"b","value":"2"}]' - const ba = '[{"name":"b","value":"2"},{"name":"a","value":"1"}]' + const ab = [ + { name: 'a', value: '1' }, + { name: 'b', value: '2' }, + ] + const ba = [ + { name: 'b', value: '2' }, + { name: 'a', value: '1' }, + ] + + expect(unorderedScopeOf(ab)).toBe(unorderedScopeOf(ba)) + expect(unorderedScopeOf([{ name: 'a' }, { name: 'a' }])).toBe(unorderedScopeOf([{ name: 'a' }])) + expect(unorderedScopeOf(ab)).not.toBe(unorderedScopeOf([{ name: 'a', value: '1' }])) + }) + + /** + * The scope binds the parsed filter, so a field the caller omitted and the + * schema default it parses to are one value by the time they are hashed. + * Fingerprinting the raw query text instead refused a cursor whenever a + * caller spelled a default explicitly. Asserted through the contract's own + * parser, since the defaulting is what makes the two equal. + */ + it('binds a defaulted field and its omission alike', () => { + const omitted = parseV2KnowledgeTagFiltersParam('[{"tagName":"a","value":"1"}]') + const explicit = parseV2KnowledgeTagFiltersParam( + '[{"tagName":"a","value":"1","operator":"eq"}]' + ) + const different = parseV2KnowledgeTagFiltersParam( + '[{"tagName":"a","value":"1","operator":"gt"}]' + ) - expect(unorderedJsonScopePart(ab)).toBe(unorderedJsonScopePart(ba)) - expect(unorderedJsonScopePart('[{"name":"a"},{"name":"a"}]')).toBe( - unorderedJsonScopePart('[{"name":"a"}]') + expect(omitted.success && explicit.success && different.success).toBe(true) + expect(unorderedScopeOf(omitted.success ? omitted.filters : null)).toBe( + unorderedScopeOf(explicit.success ? explicit.filters : null) ) - expect(unorderedJsonScopePart(ab)).not.toBe( - unorderedJsonScopePart('[{"name":"a","value":"1"}]') + expect(unorderedScopeOf(omitted.success ? omitted.filters : null)).not.toBe( + unorderedScopeOf(different.success ? different.filters : null) ) }) - it('binds an unparseable filter by its raw spelling', () => { - expect(unorderedJsonScopePart('{not json')).toBe('{not json') - expect(unorderedJsonScopePart(undefined)).toBeUndefined() + it('has no scope for an absent filter', () => { + expect(unorderedScopeOf(undefined)).toBeUndefined() + }) + + /** + * A window bound selects by instant, and `z.string().datetime()` admits every + * sub-second spelling of one, so binding the text refused a cursor for the + * same window written a different way. + */ + it('binds a window bound by its instant, not its spelling', () => { + expect(instantScopePart('2026-01-01T00:00:00Z')).toBe( + instantScopePart('2026-01-01T00:00:00.000Z') + ) + expect(instantScopePart('2026-01-01T00:00:00Z')).not.toBe( + instantScopePart('2026-01-01T00:00:01Z') + ) + expect(instantScopePart('not-a-date')).toBe('not-a-date') + expect(instantScopePart(undefined)).toBeUndefined() }) it('still separates genuinely different sets', () => { diff --git a/apps/sim/lib/api/cursor-binding.ts b/apps/sim/lib/api/cursor-binding.ts index 760cec69722..d0bd539cd55 100644 --- a/apps/sim/lib/api/cursor-binding.ts +++ b/apps/sim/lib/api/cursor-binding.ts @@ -95,26 +95,38 @@ export function parseUnorderedList(raw: string | undefined): string[] | undefine } /** - * Canonical form of a JSON-encoded filter array whose members are AND-conjoined. + * Canonical form of an AND-conjoined filter set. * - * {@link canonicalJson} preserves array order, which is right for a sequence but - * wrong for a set: clauses that compile to `and(...)` select the same rows in any - * order, so binding to the order a caller happened to write them refuses a cursor - * for a page that is genuinely the next one. Members are canonicalized, then - * de-duplicated and sorted — `A AND A` selects what `A` does. + * Takes the value the query acts on, never the caller's raw text. Two spellings + * that parse to one filter — an omitted field and its schema default, a + * different key order — must fingerprint alike, and only the parsed value knows + * that. Pass the output of the contract's own parser. * - * A non-array or unparseable value binds by its raw spelling: the request - * carrying it is about to fail validation anyway. + * {@link canonicalJson} preserves array order, which is right for a sequence and + * wrong for a set: clauses compiled into `and(...)` select the same rows in any + * order. Members are canonicalized, then de-duplicated and sorted, so `A AND A` + * binds like `A` and clause order stops mattering. */ -export function unorderedJsonScopePart(raw: string | undefined): string | undefined { +export function unorderedScopeOf(value: unknown): string | undefined { + if (value === undefined) return undefined + if (!Array.isArray(value)) return canonicalJson(value) + return `[${[...new Set(value.map(canonicalJson))].sort().join(',')}]` +} + +/** + * Canonical form of a timestamp filter: the instant, not the caller's spelling. + * + * A window bound selects rows by the instant it names, and one instant has many + * valid ISO 8601 spellings — `…00Z` and `…00.000Z` differ only in sub-second + * precision, and both pass `z.string().datetime()`. Binding the text refuses a + * cursor for the same window written a different way. + * + * An unparseable value binds by its spelling; that request fails validation. + */ +export function instantScopePart(raw: string | undefined): string | undefined { if (raw === undefined) return undefined - try { - const parsed: unknown = JSON.parse(raw) - if (!Array.isArray(parsed)) return canonicalJson(parsed) - return `[${[...new Set(parsed.map(canonicalJson))].sort().join(',')}]` - } catch { - return raw - } + const parsed = Date.parse(raw) + return Number.isNaN(parsed) ? raw : new Date(parsed).toISOString() } /** From b78a6402d48969afa61da26ef85b039e5fec637a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 13 Aug 2026 01:49:07 -0700 Subject: [PATCH 46/56] fix(v2): bind the audit and billing window bounds by instant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous sweep for this defect looked for a transform in mapInput, so it missed the two routes that pass their raw bounds to a use case that parses them deeper. Both fingerprinted startDate/endDate as text while their predicates convert to a Date, so `…00Z` and `…00.000Z` name one window and got two scopes, refusing the genuine next page. Billing keeps stamping the raw params rather than resolveDateRange's output, for the reason already recorded there: a relative `period` resolves against the clock, so hashing the resolved window would reject every next page. Normalizing the explicit bounds is compatible — instantScopePart is a pure function of the caller's own text and resolves nothing. Re-swept all fourteen cursor-scope routes by scope part rather than by transform site. Every temporal and structured part now binds canonically; the rest are enums and identifiers with one spelling per value. Reported by Greptile. --- apps/sim/app/api/v2/audit-logs/route.test.ts | 25 ++++++++++++++++++++ apps/sim/app/api/v2/audit-logs/route.ts | 6 ++--- apps/sim/app/api/v2/billing/logs/route.ts | 10 +++++--- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/api/v2/audit-logs/route.test.ts b/apps/sim/app/api/v2/audit-logs/route.test.ts index dd6511eeb5a..b77f30edc06 100644 --- a/apps/sim/app/api/v2/audit-logs/route.test.ts +++ b/apps/sim/app/api/v2/audit-logs/route.test.ts @@ -128,6 +128,31 @@ describe('v2 audit-log routes', () => { expect(mocks.list).not.toHaveBeenCalled() }) + /** + * A window bound selects by instant, and the query schema admits every + * sub-second spelling of one, so the same window written a different way must + * resume rather than 400. + */ + it('resumes a cursor whose window bound is respelled to the same instant', async () => { + const minted = await listLogs( + new NextRequest( + 'http://localhost:3000/api/v2/audit-logs?organizationId=org-1&startDate=2026-01-01T00%3A00%3A00Z' + ) + ) + const { nextCursor } = await minted.json() + expect(nextCursor).toEqual(expect.any(String)) + + mocks.list.mockClear() + const resumed = await listLogs( + new NextRequest( + `http://localhost:3000/api/v2/audit-logs?organizationId=org-1&startDate=2026-01-01T00%3A00%3A00.000Z&cursor=${encodeURIComponent(nextCursor)}` + ) + ) + + expect(resumed.status).toBe(200) + expect(mocks.list).toHaveBeenCalled() + }) + it('resumes a cursor replayed under the filters it was minted with', async () => { const minted = await listLogs( new NextRequest( diff --git a/apps/sim/app/api/v2/audit-logs/route.ts b/apps/sim/app/api/v2/audit-logs/route.ts index 014c21597b4..53022dff0d0 100644 --- a/apps/sim/app/api/v2/audit-logs/route.ts +++ b/apps/sim/app/api/v2/audit-logs/route.ts @@ -1,5 +1,5 @@ import { v2ListAuditLogsContract } from '@/lib/api/contracts/v2/audit-logs' -import { cursorScopeKey } from '@/lib/api/cursor-binding' +import { cursorScopeKey, instantScopePart } from '@/lib/api/cursor-binding' import { defineV2JsonRoute, v2ApiKeyAuth, @@ -31,8 +31,8 @@ function auditLogCursorFilters(query: { resourceId: query.resourceId, workspaceId: query.workspaceId, actorEmail: query.actorEmail, - startDate: query.startDate, - endDate: query.endDate, + startDate: instantScopePart(query.startDate), + endDate: instantScopePart(query.endDate), }) } diff --git a/apps/sim/app/api/v2/billing/logs/route.ts b/apps/sim/app/api/v2/billing/logs/route.ts index 5f6f17ccd43..f306ff40682 100644 --- a/apps/sim/app/api/v2/billing/logs/route.ts +++ b/apps/sim/app/api/v2/billing/logs/route.ts @@ -1,5 +1,5 @@ import { v2ListBillingLogsContract } from '@/lib/api/contracts/v2/billing' -import { cursorScopeKey } from '@/lib/api/cursor-binding' +import { cursorScopeKey, instantScopePart } from '@/lib/api/cursor-binding' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { v2BillingErrorPolicies } from '@/lib/billing/api/route-policies' import { listBillingLogs } from '@/lib/billing/application/list-billing-logs' @@ -20,6 +20,10 @@ export const revalidate = 0 * window would produce a different stamp on every request and reject each next * page. `period=30d` and an explicit custom range covering the same days are * therefore two scopes, which is right — one is a moving window. + * + * The explicit bounds still bind by instant rather than spelling. That is a + * pure function of the caller's own text, so it collapses `…00Z` and `…00.000Z` + * without resolving anything against the clock. */ function billingLogCursorFilters(query: { source?: string @@ -32,8 +36,8 @@ function billingLogCursorFilters(query: { source: query.source, workspaceId: query.workspaceId, period: query.period, - startDate: query.startDate, - endDate: query.endDate, + startDate: instantScopePart(query.startDate), + endDate: instantScopePart(query.endDate), }) } From 28ccfb330bbe9a9934196b91fadaa866913a5d73 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 13 Aug 2026 01:59:36 -0700 Subject: [PATCH 47/56] fix(v2): drop an inert field from the document tag-filter scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveKnowledgeTagFilters builds every structured filter with the stored definition's fieldType and never reads the caller's — not for resolution, not for validation, not in its output. Fingerprinting it made a field the query ignores decide whether a cursor resumes, so adding or removing a matching fieldType refused a page that had not moved. Swept the other twelve cursor-scope routes for the same shape. No scope part is absent from its mapInput, this was the only scope carrying a structure resolved against stored state, and knowledge/search has no cursor at all. Reported by Greptile. --- .../app/api/v2/knowledge/[id]/documents/route.test.ts | 4 ++++ apps/sim/app/api/v2/knowledge/[id]/documents/route.ts | 10 +++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/route.test.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/route.test.ts index 0b261215be6..e85abb1cce8 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/route.test.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/route.test.ts @@ -409,6 +409,10 @@ describe('GET /api/v2/knowledge/[id]/documents', () => { 'the default operator stated explicitly', '[{"tagName":"a","value":"1","operator":"eq"},{"tagName":"b","value":"2","operator":"eq"}]', ], + [ + 'a fieldType the resolver overrides with the stored definition', + '[{"tagName":"a","value":"1","fieldType":"text"},{"tagName":"b","value":"2","fieldType":"text"}]', + ], ['the clauses reordered', '[{"tagName":"b","value":"2"},{"tagName":"a","value":"1"}]'], ])('resumes a tag-filter cursor with %s', async (_label, replayFilters) => { const mintFilters = '[{"tagName":"a","value":"1"},{"tagName":"b","value":"2"}]' diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts index 0189e529179..c6440f890de 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts @@ -1,3 +1,4 @@ +import { omit } from '@sim/utils/object' import { parseV2KnowledgeTagFiltersParam, v2BulkUpdateKnowledgeDocumentsContract, @@ -47,6 +48,11 @@ const MAX_FILE_SIZE = MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE * schema defaults `operator` to `eq`, so `{tagName}` and `{tagName, operator}` * are one filter to the query and must be one scope to the cursor. An * unparseable value binds raw — that request is about to 400 anyway. + * + * `fieldType` is dropped: `resolveKnowledgeTagFilters` builds every structured + * filter with the stored definition's type and never reads the caller's, so + * stating it or omitting it selects the same documents. A scope part the query + * ignores refuses a cursor for a page that did not move. */ function documentCursorFilters( knowledgeBaseId: string, @@ -58,7 +64,9 @@ function documentCursorFilters( workspaceId: query.workspaceId, enabledFilter: query.enabledFilter, search: query.search, - tagFilters: parsed.success ? unorderedScopeOf(parsed.filters) : query.tagFilters, + tagFilters: parsed.success + ? unorderedScopeOf(parsed.filters?.map((filter) => omit(filter, ['fieldType']))) + : query.tagFilters, }) } From f2f83112c6ed7743cfc28704a81632fe43f850ba Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 13 Aug 2026 08:47:21 -0700 Subject: [PATCH 48/56] refactor(v2): derive the body 413 from the contract in every document Two mechanisms encoded one rule. `withRequestBodyErrors` derived the 413 from `route.contract.body` for the tables document, while the resources document hand-picked RESOURCE_BODY_ERRORS / RESOURCE_CONFLICT_BODY_ERRORS at nine sites. The cross-document sweep caught drift, but only after the fact: a new body operation that forgot the _BODY_ variant published a reachable 413 nowhere until a test failed. Hoists the mapper to openapi/shared.ts and applies it in both documents, so the rule is derived rather than remembered. The two hand-picked sets and their shared TSDoc are gone. Regenerating all seven specs produces zero drift, which is the proof the two mechanisms were computing the same thing. --- .../lib/api/contracts/v2/openapi/resources.ts | 21 +++++----- .../lib/api/contracts/v2/openapi/shared.ts | 41 ++++++++----------- .../lib/api/contracts/v2/openapi/tables.ts | 24 +---------- 3 files changed, 29 insertions(+), 57 deletions(-) diff --git a/apps/sim/lib/api/contracts/v2/openapi/resources.ts b/apps/sim/lib/api/contracts/v2/openapi/resources.ts index 04fdbf1df06..a87fa3f3294 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/resources.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/resources.ts @@ -21,8 +21,6 @@ import { FULL_SET_LIST, HEAD_MIRRORS_GET, RATE_LIMIT_HEADERS, - RESOURCE_BODY_ERRORS, - RESOURCE_CONFLICT_BODY_ERRORS, RESOURCE_CONFLICT_ERRORS, RESOURCE_ERRORS, V2_API_KEY_SECURITY, @@ -30,6 +28,7 @@ import { V2_COMMON_HEADERS, V2_ERROR_SCHEMA, WORKSPACE_API_KEY_DENIED, + withRequestBodyErrors, } from '@/lib/api/contracts/v2/openapi/shared' import { v2DeleteSecretContract, @@ -214,7 +213,7 @@ function resourceOperation( } } -const routes = [ +const declaredRoutes = [ defineOpenApiRoute( v2GetWorkspaceContract, resourceOperation('Workspaces', { @@ -307,7 +306,7 @@ const routes = [ summary: 'Create MCP Server', description: 'Register an MCP server in a workspace. The endpoint URL is the server identity, so a URL already registered here is a `409` — reconfigure that server with `PATCH /api/v2/mcp-servers/{id}` instead. Registration never connects to the endpoint: the server comes back `disconnected` and stays unavailable until `GET /api/v2/mcp-servers/{id}/tools` succeeds.', - errors: RESOURCE_CONFLICT_BODY_ERRORS, + errors: RESOURCE_CONFLICT_ERRORS, success: { description: 'The MCP server was registered.' }, }), { @@ -375,7 +374,7 @@ const routes = [ summary: 'Update MCP Server', description: 'Update the supplied MCP server fields. Omitted fields are retained, except where a field says otherwise. Any change that invalidates authentication revokes the stored OAuth grant, resets `connectionStatus` to `disconnected`, and clears `lastConnected` and `lastError`, so the server must be rediscovered.', - errors: RESOURCE_BODY_ERRORS, + errors: RESOURCE_ERRORS, success: { description: 'The updated MCP server.' }, }), { @@ -497,7 +496,7 @@ const routes = [ operationId: 'createSkill', summary: 'Create Skill', description: `Create one skill in a workspace. Its kebab-case name must be unique and cannot be reserved by a built-in skill. ${WORKSPACE_API_KEY_DENIED}`, - errors: RESOURCE_CONFLICT_BODY_ERRORS, + errors: RESOURCE_CONFLICT_ERRORS, success: { description: 'The skill was created.' }, }), { @@ -563,7 +562,7 @@ const routes = [ operationId: 'updateSkill', summary: 'Update Skill', description: `Update the supplied fields on a workspace skill. Omitted fields retain their stored values. Built-in skills are read-only. ${WORKSPACE_API_KEY_DENIED}`, - errors: RESOURCE_CONFLICT_BODY_ERRORS, + errors: RESOURCE_CONFLICT_ERRORS, success: { description: 'The updated skill.' }, }), { @@ -654,7 +653,7 @@ const routes = [ summary: 'Create Custom Tool', description: 'Create a code-backed custom tool in a workspace. Its title must be unique because tools resolve by title at call time.', - errors: RESOURCE_CONFLICT_BODY_ERRORS, + errors: RESOURCE_CONFLICT_ERRORS, success: { description: 'The custom tool was created.' }, }), { @@ -720,7 +719,7 @@ const routes = [ summary: 'Update Custom Tool', description: 'Update the supplied custom tool fields. Omitted fields retain their stored values, and titles must remain unique within the workspace.', - errors: RESOURCE_CONFLICT_BODY_ERRORS, + errors: RESOURCE_CONFLICT_ERRORS, success: { description: 'The updated custom tool.' }, }), { @@ -836,7 +835,7 @@ const routes = [ operationId: 'setSecret', summary: 'Set Secret', description: `Create or replace a workspace or caller-owned personal secret. The value is encrypted at rest, is write-only, and is never included in the response. ${WORKSPACE_API_KEY_DENIED}`, - errors: RESOURCE_BODY_ERRORS, + errors: RESOURCE_ERRORS, success: { byStatus: { 200: { description: 'The existing secret value was replaced.' }, @@ -915,6 +914,8 @@ const routes = [ ), ] as const +const routes = declaredRoutes.map(withRequestBodyErrors) + export const resourcesOpenApiDocument = defineOpenApiDocument({ output: 'apps/docs/openapi-v2-resources.json', info: { diff --git a/apps/sim/lib/api/contracts/v2/openapi/shared.ts b/apps/sim/lib/api/contracts/v2/openapi/shared.ts index c4a1a08f664..c44d271f3c2 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/shared.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/shared.ts @@ -3,6 +3,7 @@ import { v2ErrorResponseSchema } from '@/lib/api/contracts/v2/shared' import type { OpenApiErrorResponse, OpenApiHeader, + OpenApiRouteDefinition, OpenApiSecurityScheme, } from '@/lib/api/openapi/types' @@ -153,33 +154,25 @@ export const RESOURCE_MUTATION_ERRORS = [ ] as const satisfies readonly ErrorResponseId[] /** - * The two sets below add the `413` that every body-carrying operation can emit. + * Adds the `413` a body-carrying operation can emit. * - * It is not a property of the resource but of the request: `parseRequest` reads - * the JSON body through `parseJsonBody` under `DEFAULT_MAX_JSON_BODY_BYTES` - * before schema validation runs, and the v2 builders supply - * `V2_PARSE_DEFAULTS.payloadTooLargeResponse`, so a body over - * the cap is answered `413` on any route whose contract declares one. That made - * `413` reachable-but-unpublished across a whole family, which is the mirror of - * the defect these sets exist to prevent — a caller cannot handle a status the - * spec never mentions. + * `parseRequest` reads the JSON body under `DEFAULT_MAX_JSON_BODY_BYTES` before + * schema validation, and the v2 builders supply + * `V2_PARSE_DEFAULTS.payloadTooLargeResponse`, so an oversized body is a real + * `413` on any route whose contract declares one — and a status a caller can + * receive but the spec omits is an unhandled branch in every generated client. * - * Reachability is not automatic, so these are opt-in rather than folded into the - * base sets. An operation with no request body cannot emit this `413` at all, - * and neither can one whose handler reads its payload through a path that - * applies no cap; documenting it there would publish a response that can never - * arrive. + * Derived from the contract rather than chosen per operation, so a new body + * route cannot forget it. One-directional: it never removes a `413` from a + * bodyless read, several of which publish one for the folder-tree ceiling. */ -export const RESOURCE_BODY_ERRORS = [ - ...RESOURCE_ERRORS, - 'PayloadTooLarge', -] as const satisfies readonly ErrorResponseId[] - -/** {@link RESOURCE_CONFLICT_ERRORS} plus the body-size `413`. */ -export const RESOURCE_CONFLICT_BODY_ERRORS = [ - ...RESOURCE_CONFLICT_ERRORS, - 'PayloadTooLarge', -] as const satisfies readonly ErrorResponseId[] +export function withRequestBodyErrors(route: OpenApiRouteDefinition): OpenApiRouteDefinition { + if (!route.contract.body || route.operation.errors.includes('PayloadTooLarge')) return route + return { + ...route, + operation: { ...route.operation, errors: [...route.operation.errors, 'PayloadTooLarge'] }, + } +} export const V2_API_KEY_SECURITY = [{ apiKey: [] }] as const diff --git a/apps/sim/lib/api/contracts/v2/openapi/tables.ts b/apps/sim/lib/api/contracts/v2/openapi/tables.ts index c67ee2676c3..d83ca48fbaa 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/tables.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/tables.ts @@ -13,6 +13,7 @@ import { V2_COMMON_HEADERS, V2_ERROR_SCHEMA, WORKSPACE_ERRORS, + withRequestBodyErrors, } from '@/lib/api/contracts/v2/openapi/shared' import { v2AddTableColumnContract, @@ -64,7 +65,6 @@ import { defineOpenApiDocument, defineOpenApiRoute, type OpenApiOperationMetadata, - type OpenApiRouteDefinition, type OpenApiSuccessMetadata, } from '@/lib/api/openapi/types' @@ -1580,28 +1580,6 @@ const declaredRoutes = [ ), ] as const -/** - * Publishes `413` on every operation that accepts a request body. - * - * The v2 JSON builder reads the body through `parseJsonBody` under - * `DEFAULT_MAX_JSON_BODY_BYTES` (50 MB) BEFORE schema validation, rendering - * `V2_PARSE_DEFAULTS.payloadTooLargeResponse`. That makes the status reachable - * on every body-carrying operation, not just the two that set a tighter - * `maxBodyBytes` of their own — and a status a caller can receive but the spec - * does not declare is an unhandled branch in every generated client. - * - * Deliberately one-directional: it adds `413` where a body exists and never - * removes it where none does, because several bodyless reads publish `413` for - * the folder-tree materialization ceiling instead. - */ -function withRequestBodyErrors(route: OpenApiRouteDefinition): OpenApiRouteDefinition { - if (!route.contract.body || route.operation.errors.includes('PayloadTooLarge')) return route - return { - ...route, - operation: { ...route.operation, errors: [...route.operation.errors, 'PayloadTooLarge'] }, - } -} - const routes = declaredRoutes.map(withRequestBodyErrors) export const tablesOpenApiDocument = defineOpenApiDocument({ From 7125eac14c5031299b9e11d42f9c545e76f49e23 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 13 Aug 2026 09:07:42 -0700 Subject: [PATCH 49/56] refactor(v2): collapse duplicated cursor and validation mechanisms, drop dead exports One rule, one implementation: - `parseRequest` hand-inlined the "caller envelope or default" validation-error projection four times. Extract `projectValidationError` and route all four through it. - Nine keyset lists hand-rolled the `present` half of the cursor pair that `readSortedCursor` already owns the read half of. Add the symmetric `writeSortedCursor` and use it everywhere. - `GET /workflows/{id}/runs` re-derived `readSortedCursor`'s invalid/refiltered ladder from `decodeSortedCursor`; it now calls the shared reader and keeps only the key-arity check that is genuinely its own. Files and exports that no longer earn their place: - Inline `credentials/utils.ts` into its single consumer. - Delete symbols with zero references repo-wide: `v2CustomToolWriteError`, `secretCredentialTypes`, `v2CursorList`, `v2WorkspaceAccessError`, `resolveFolderPathIdentity`, `folderPathForId`, `v2FolderPathMutationError`, and seven of twelve `tables/utils.ts` exports. - Drop `export` from symbols used only inside their own module. No behavior change; every response body and error message is byte-identical. --- apps/sim/app/api/v2/credentials/route.ts | 38 +++-- apps/sim/app/api/v2/credentials/utils.ts | 22 --- apps/sim/app/api/v2/custom-tools/route.ts | 15 +- apps/sim/app/api/v2/custom-tools/utils.ts | 25 ---- apps/sim/app/api/v2/files/route.ts | 15 +- apps/sim/app/api/v2/knowledge/route.ts | 15 +- apps/sim/app/api/v2/knowledge/utils.ts | 4 +- apps/sim/app/api/v2/lib/folders.ts | 48 ------- apps/sim/app/api/v2/lib/response.ts | 79 +++++------ apps/sim/app/api/v2/mcp-servers/route.ts | 15 +- apps/sim/app/api/v2/secrets/route.ts | 15 +- apps/sim/app/api/v2/secrets/utils.ts | 8 +- apps/sim/app/api/v2/tables/route.ts | 15 +- apps/sim/app/api/v2/tables/utils.ts | 133 +----------------- .../app/api/v2/workflows/[id]/runs/route.ts | 46 +++--- apps/sim/app/api/v2/workflows/route.ts | 15 +- apps/sim/lib/api/cursor-binding.ts | 41 ++---- apps/sim/lib/api/server/routes/definition.ts | 2 +- .../api/server/routes/internal-json-route.ts | 2 +- .../lib/api/server/routes/v2-json-route.ts | 64 +++------ apps/sim/lib/api/server/validation.ts | 49 +++---- 21 files changed, 187 insertions(+), 479 deletions(-) delete mode 100644 apps/sim/app/api/v2/credentials/utils.ts diff --git a/apps/sim/app/api/v2/credentials/route.ts b/apps/sim/app/api/v2/credentials/route.ts index 4f416e02efb..bccfffccbe2 100644 --- a/apps/sim/app/api/v2/credentials/route.ts +++ b/apps/sim/app/api/v2/credentials/route.ts @@ -1,3 +1,4 @@ +import type { V2Credential } from '@/lib/api/contracts/v2/credentials' import { v2ListCredentialsContract } from '@/lib/api/contracts/v2/credentials' import { cursorScopeKey } from '@/lib/api/cursor-binding' import { @@ -8,12 +9,32 @@ import { } from '@/lib/api/server/routes' import { listWorkspaceCredentials } from '@/lib/credentials/application/list-workspace-credentials' import { credentialOperations } from '@/lib/credentials/application/operations' -import { toV2Credential } from '@/app/api/v2/credentials/utils' -import { cursorSortKey, encodeSortedCursor, readSortedCursor } from '@/app/api/v2/lib/response' +import type { VisibleWorkspaceCredential } from '@/lib/credentials/queries' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 +/** Serialize connection metadata field by field so encrypted columns can never reach the wire. */ +function toV2Credential(row: VisibleWorkspaceCredential): V2Credential { + if (row.type !== 'oauth' && row.type !== 'service_account') { + throw new Error(`Secret credential type ${row.type} reached the credentials API`) + } + + return { + id: row.id, + type: row.type, + displayName: row.displayName, + description: row.description, + providerId: row.providerId, + accountId: row.accountId, + hasServiceAccountKey: row.hasServiceAccountKey, + role: row.role, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + } +} + /** Every param that changes which credentials, in which order, this list returns. */ function credentialCursorFilters(query: { workspaceId: string @@ -48,12 +69,11 @@ export const GET = defineV2JsonRoute({ useCase: listWorkspaceCredentials, present: ({ credentials, nextCursorKeys }, { query }) => ({ data: credentials.map(toV2Credential), - nextCursor: nextCursorKeys - ? encodeSortedCursor( - cursorSortKey(query.sortBy, query.sortOrder), - nextCursorKeys, - credentialCursorFilters(query) - ) - : null, + nextCursor: writeSortedCursor( + nextCursorKeys, + query.sortBy, + query.sortOrder, + credentialCursorFilters(query) + ), }), }) diff --git a/apps/sim/app/api/v2/credentials/utils.ts b/apps/sim/app/api/v2/credentials/utils.ts deleted file mode 100644 index e186a4f1558..00000000000 --- a/apps/sim/app/api/v2/credentials/utils.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { V2Credential } from '@/lib/api/contracts/v2/credentials' -import type { VisibleWorkspaceCredential } from '@/lib/credentials/queries' - -/** Serialize connection metadata field by field so encrypted columns can never reach the wire. */ -export function toV2Credential(row: VisibleWorkspaceCredential): V2Credential { - if (row.type !== 'oauth' && row.type !== 'service_account') { - throw new Error(`Secret credential type ${row.type} reached the credentials API`) - } - - return { - id: row.id, - type: row.type, - displayName: row.displayName, - description: row.description, - providerId: row.providerId, - accountId: row.accountId, - hasServiceAccountKey: row.hasServiceAccountKey, - role: row.role, - createdAt: row.createdAt.toISOString(), - updatedAt: row.updatedAt.toISOString(), - } -} diff --git a/apps/sim/app/api/v2/custom-tools/route.ts b/apps/sim/app/api/v2/custom-tools/route.ts index 7b0f86f91f9..ca027ae013a 100644 --- a/apps/sim/app/api/v2/custom-tools/route.ts +++ b/apps/sim/app/api/v2/custom-tools/route.ts @@ -15,7 +15,7 @@ import { listWorkspaceCustomToolsUseCase, } from '@/lib/custom-tools/application/use-cases' import { toV2CustomTool } from '@/app/api/v2/custom-tools/utils' -import { cursorSortKey, encodeSortedCursor, readSortedCursor } from '@/app/api/v2/lib/response' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -47,13 +47,12 @@ export const GET = defineV2JsonRoute({ useCase: listWorkspaceCustomToolsUseCase, present: ({ tools, nextCursorKeys }, { query }) => ({ data: tools.map(toV2CustomTool), - nextCursor: nextCursorKeys - ? encodeSortedCursor( - cursorSortKey(query.sortBy, query.sortOrder), - nextCursorKeys, - customToolCursorFilters(query) - ) - : null, + nextCursor: writeSortedCursor( + nextCursorKeys, + query.sortBy, + query.sortOrder, + customToolCursorFilters(query) + ), }), }) diff --git a/apps/sim/app/api/v2/custom-tools/utils.ts b/apps/sim/app/api/v2/custom-tools/utils.ts index 516101065ad..d3f1181a10e 100644 --- a/apps/sim/app/api/v2/custom-tools/utils.ts +++ b/apps/sim/app/api/v2/custom-tools/utils.ts @@ -1,33 +1,8 @@ import type { customTools } from '@sim/db/schema' -import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors' -import type { NextResponse } from 'next/server' import type { V2CustomTool } from '@/lib/api/contracts/v2/custom-tools' -import { v2Error } from '@/app/api/v2/lib/response' /** Shared serialization + error mapping for the v2 custom tool surface. */ -/** - * Classifies a title collision as a conflict so it surfaces as 409 rather than a - * generic 500. Two distinct failures reach here and both must be covered: - * - * - `upsertCustomTools` throws its own message when its in-transaction duplicate - * `SELECT` finds one. - * - Under a concurrent create or rename, both callers pass that `SELECT` too, and - * the loser is rejected by `custom_tools_workspace_title_unique` as a raw - * Postgres `23505` — whose message matches nothing, which is exactly the race - * the message check alone cannot see. - */ -export function v2CustomToolWriteError(error: unknown): NextResponse | null { - if (getPostgresErrorCode(error) === '23505') { - return v2Error('CONFLICT', 'A custom tool with that title already exists in this workspace') - } - const message = getErrorMessage(error, '') - if (/already exists in this workspace/i.test(message)) { - return v2Error('CONFLICT', message) - } - return null -} - type CustomToolRow = typeof customTools.$inferSelect /** diff --git a/apps/sim/app/api/v2/files/route.ts b/apps/sim/app/api/v2/files/route.ts index 0f9286db44b..31930cf1e60 100644 --- a/apps/sim/app/api/v2/files/route.ts +++ b/apps/sim/app/api/v2/files/route.ts @@ -12,7 +12,7 @@ import { queryWorkspaceFilePage } from '@/lib/workspace-files/application/list-w import { fileOperations } from '@/lib/workspace-files/application/operations' import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES } from '@/lib/workspace-files/orchestration' import { toV2File, toV2Files } from '@/app/api/v2/files/utils' -import { cursorSortKey, encodeSortedCursor, readSortedCursor } from '@/app/api/v2/lib/response' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -54,13 +54,12 @@ export const GET = defineV2JsonRoute({ const items: V2File[] = await toV2Files(files) return { data: items, - nextCursor: nextKeys - ? encodeSortedCursor( - cursorSortKey(query.sortBy, query.sortOrder), - nextKeys, - fileCursorFilters(query) - ) - : null, + nextCursor: writeSortedCursor( + nextKeys, + query.sortBy, + query.sortOrder, + fileCursorFilters(query) + ), } }, }) diff --git a/apps/sim/app/api/v2/knowledge/route.ts b/apps/sim/app/api/v2/knowledge/route.ts index a83346a825d..07d9be95f16 100644 --- a/apps/sim/app/api/v2/knowledge/route.ts +++ b/apps/sim/app/api/v2/knowledge/route.ts @@ -17,7 +17,7 @@ import { import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { captureServerEvent } from '@/lib/posthog/server' import { toV2KnowledgeBase, toV2KnowledgeBases } from '@/app/api/v2/knowledge/utils' -import { cursorSortKey, encodeSortedCursor, readSortedCursor } from '@/app/api/v2/lib/response' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -59,13 +59,12 @@ export const GET = defineV2JsonRoute({ useCase: listKnowledgeBases, present: async ({ knowledgeBases, nextCursorKeys }, { query }) => ({ data: await toV2KnowledgeBases(knowledgeBases), - nextCursor: nextCursorKeys - ? encodeSortedCursor( - cursorSortKey(query.sortBy, query.sortOrder), - nextCursorKeys, - knowledgeCursorFilters(query) - ) - : null, + nextCursor: writeSortedCursor( + nextCursorKeys, + query.sortBy, + query.sortOrder, + knowledgeCursorFilters(query) + ), }), }) diff --git a/apps/sim/app/api/v2/knowledge/utils.ts b/apps/sim/app/api/v2/knowledge/utils.ts index 1751b673ee3..583368db64b 100644 --- a/apps/sim/app/api/v2/knowledge/utils.ts +++ b/apps/sim/app/api/v2/knowledge/utils.ts @@ -48,7 +48,7 @@ type V2DocumentProcessingStatus = (typeof PROCESSING_STATUSES)[number] * reads as `pending`, matching the column default; an unrecognised one is a * producer bug rather than a caller-reachable failure, so it throws. */ -export function toProcessingStatus(status: string | null | undefined): V2DocumentProcessingStatus { +function toProcessingStatus(status: string | null | undefined): V2DocumentProcessingStatus { if (status === null || status === undefined) return 'pending' const known = PROCESSING_STATUSES.find((candidate) => candidate === status) if (!known) throw new Error(`Unexpected knowledge document processing status: ${status}`) @@ -59,7 +59,7 @@ export function toProcessingStatus(status: string | null | undefined): V2Documen * The document columns every v2 document projection reads. `uploadedAt` is * accepted as nullable because the column is nullable in storage. */ -export interface V2DocumentSummarySource { +interface V2DocumentSummarySource { id: string knowledgeBaseId: string filename: string diff --git a/apps/sim/app/api/v2/lib/folders.ts b/apps/sim/app/api/v2/lib/folders.ts index 294bb00771e..12991cb897d 100644 --- a/apps/sim/app/api/v2/lib/folders.ts +++ b/apps/sim/app/api/v2/lib/folders.ts @@ -1,53 +1,12 @@ import type { folder } from '@sim/db/schema' -import type { NextResponse } from 'next/server' -import type { FolderResourceType } from '@/lib/api/contracts/folders' -import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' -import { withFolderTreeLock } from '@/lib/folders/locks' import { type FolderPathIndex, isFolderPathEffectivelyLocked, - ROOT_FOLDER_PATH, toFolderPathView, } from '@/lib/folders/paths' -import { loadActiveFolderPathIndex } from '@/lib/folders/queries' -import { v2ErrorForOrchestration } from '@/app/api/v2/lib/response' type FolderRow = typeof folder.$inferSelect -export function resolveFolderPathId( - index: FolderPathIndex, - path: string -): string | null | undefined { - return path === ROOT_FOLDER_PATH ? null : index.idByPath.get(path) -} - -export type ResolvedFolderPathIdentity = - | { found: false } - | { found: true; folderId: string | null; index: FolderPathIndex } - -/** Resolves a path to its stable internal identity under a short-lived folder tree lock. */ -export async function resolveFolderPathIdentity(params: { - workspaceId: string - resourceType: FolderResourceType - path: string -}): Promise { - return withFolderTreeLock(params.workspaceId, params.resourceType, async (tx) => { - const index = await loadActiveFolderPathIndex(params.workspaceId, params.resourceType, tx) - const folderId = resolveFolderPathId(index, params.path) - return folderId === undefined ? { found: false } : { found: true, folderId, index } - }) -} - -export function folderPathForId( - index: FolderPathIndex, - folderId: string | null | undefined -): string { - if (!folderId) return ROOT_FOLDER_PATH - const path = index.pathById.get(folderId) - if (!path) throw new Error('Resource references an inactive or missing folder') - return path -} - export function toV2PathFolder( row: FolderRow, index: FolderPathIndex, @@ -58,10 +17,3 @@ export function toV2PathFolder( const base = toFolderPathView(row, path) return includeLocked ? { ...base, locked: isFolderPathEffectivelyLocked(index, row.id) } : base } - -export function v2FolderPathMutationError( - errorCode: OrchestrationErrorCode | undefined, - message: string -): NextResponse { - return v2ErrorForOrchestration(errorCode, message) -} diff --git a/apps/sim/app/api/v2/lib/response.ts b/apps/sim/app/api/v2/lib/response.ts index 050f611e510..7ffd9fc9f88 100644 --- a/apps/sim/app/api/v2/lib/response.ts +++ b/apps/sim/app/api/v2/lib/response.ts @@ -11,7 +11,7 @@ import { type OrchestrationErrorCode, } from '@/lib/core/orchestration/types' import type { HttpError } from '@/lib/core/utils/http-error' -import type { RateLimitResult, WorkspaceAccessError } from '@/app/api/v1/middleware' +import type { RateLimitResult } from '@/app/api/v1/middleware' /** * Runtime response helpers for the v2 API surface. Every v2 route renders its @@ -102,7 +102,7 @@ const RETRY_AFTER_SECONDS_BY_STATUS: Partial> = { type RateLimitHeaderSource = Pick -export function rateLimitHeaders(rateLimit?: RateLimitHeaderSource): Record { +function rateLimitHeaders(rateLimit?: RateLimitHeaderSource): Record { if (!rateLimit) return {} return { 'X-RateLimit-Limit': rateLimit.limit.toString(), @@ -127,17 +127,12 @@ function successHeaders(options: V2SuccessOptions): Record { * * RFC 9110 §9.3.2 lets Next alias `HEAD` onto `GET` only because §9.2.1 defines * `HEAD` as safe — "essentially read-only". A `GET` that opens an outbound - * connection or writes a row breaks that assumption, and an uptime monitor or - * link checker walking the documented URL list would drive those effects - * invisibly on every probe. Such a route runs everything the `GET` runs up to - * and including resource authorization, then stops before the business phase. + * connection or writes a row breaks that assumption, and an uptime monitor + * walking the documented URL list would drive those effects on every probe. * - * The 200 here is unconditional **by construction**: the v2 route builders only - * reach this function after `useCase.authorize` has resolved, and render every - * rejection through the route's own error policy. Calling it before that check — - * as the builders originally did, straight after admission — turns it into an - * existence oracle, because a valid API key for any workspace then draws a 200 - * for a resource that same key's `GET` answers 403 or 404 for. + * The 200 is unconditional **by construction**: callers must only reach this + * after `useCase.authorize` has resolved, or it becomes the existence oracle + * the `headSafe` option on the v2 route builders documents. */ export function v2HeadNoEffect(options: V2SuccessOptions = {}): NextResponse { return new NextResponse(null, { status: options.status ?? 200, headers: successHeaders(options) }) @@ -151,18 +146,6 @@ export function v2Data(data: T, options: V2SuccessOptions = {}): NextResponse ) } -/** `{ data, nextCursor }` (+ rate-limit headers). */ -export function v2CursorList( - data: T[], - nextCursor: string | null, - options: V2SuccessOptions = {} -): NextResponse { - return NextResponse.json( - { data, nextCursor }, - { status: options.status ?? 200, headers: successHeaders(options) } - ) -} - interface V2ErrorOptions { status?: number details?: unknown @@ -218,12 +201,9 @@ export function v2HttpError(error: HttpError): NextResponse { * The 500 of the local-storage upload data plane, in the canonical envelope. * * `PUT /api/v2/uploads/{uploadId}` and its `/parts/{partNumber}` sibling are - * deliberately outside the public OpenAPI documents (see - * `UNDOCUMENTED_V2_ROUTES`), but they are still v2 routes a caller reaches - * through a URL a documented operation handed it. Being undocumented is a - * reason not to publish them; it was never a reason to answer in a different - * error shape. They do not run `admitV2Request`, so they cannot reuse the JSON - * builder's handler — this is the one piece of it they need. + * undocumented but still v2 routes, and they do not run `admitV2Request`, so + * they cannot reuse the JSON builder's handler — this is the one piece of it + * they need. */ export function v2UploadDataPlaneError(): NextResponse { return v2Error('INTERNAL_ERROR', 'Internal server error') @@ -236,11 +216,6 @@ export function v2ValidationError(error: ZodError): NextResponse { }) } -/** Render a shared {@link WorkspaceAccessError} as the v2 error envelope. */ -export function v2WorkspaceAccessError(failure: WorkspaceAccessError): NextResponse { - return v2Error(failure.code, failure.message, { status: failure.status }) -} - /** * Render a v1 rate-limit/auth failure (`checkRateLimit` result) as the v2 error * envelope: an auth failure becomes 401, a throttle becomes 429 with @@ -359,7 +334,7 @@ export function encodeSortedCursor( return encodeCursor({ sort, keys, ...(filter ? { filter } : {}) } satisfies SortedCursorPayload) } -export type DecodedSortedCursor = +type DecodedSortedCursor = | { status: 'absent' } | { status: 'ok'; keys: CursorKey[] } /** Malformed, or minted under a different sort — the page cannot be resumed. */ @@ -377,16 +352,11 @@ export type DecodedSortedCursor = * is rejected for the same reason: ignoring it would restart from page one * while the caller believes it is paging forward. * - * A filter mismatch is rejected too, and it is worth being precise about why, - * because a keyset does not corrupt the way an offset does. `(sortKey, id)` - * names an absolute position, so replaying it under a narrower filter still - * returns a coherent, duplicate-free page — of everything matching the NEW - * filter that happens to sort after that position. Every match before it is - * silently absent. The cursor is documented as opaque, so a caller has no way - * to tell that truncated page from a complete one, and the client that most - * plausibly does this (narrow the search box, keep paging) is exactly the one - * that will believe its filter matched almost nothing. Restarting pagination is - * the only correct response, so the API says so instead of guessing. + * A filter mismatch is rejected too, even though a keyset does not corrupt the + * way an offset does: `(sortKey, id)` names an absolute position, so replaying + * it under a narrower filter returns a coherent, duplicate-free page that is + * silently missing every new match sorting before that position. The token is + * opaque, so a caller cannot tell that truncated page from a complete one. * * This checks the envelope only. The key VALUES are caller-controlled too, and * are type-checked against the sort's keys by `keysetAfter`, which is where a @@ -434,6 +404,23 @@ export function readSortedCursor( return decoded.status === 'ok' ? decoded.keys : undefined } +/** + * The next page's cursor, or `null` on the last page. + * + * The `present` half of the pair {@link readSortedCursor} opens: it stamps the + * response token with the same sort and filters the request was read under, so + * a list cannot mint a token its own reader would reject. Pass the identical + * `sortBy`/`sortOrder`/`filter` triple both sides. + */ +export function writeSortedCursor( + keys: CursorKey[] | null | undefined, + sortBy: string, + sortOrder: string, + filter?: string | undefined +): string | null { + return keys ? encodeSortedCursor(cursorSortKey(sortBy, sortOrder), keys, filter) : null +} + interface ScopedCursorPayload { /** Fingerprint of the filters and sort the inner token was minted under. */ scope?: string diff --git a/apps/sim/app/api/v2/mcp-servers/route.ts b/apps/sim/app/api/v2/mcp-servers/route.ts index 4d9a1838d8d..37757f7824b 100644 --- a/apps/sim/app/api/v2/mcp-servers/route.ts +++ b/apps/sim/app/api/v2/mcp-servers/route.ts @@ -12,7 +12,7 @@ import { import { mcpServerOperations } from '@/lib/mcp/application/operations' import { createMcpServerUseCase, listMcpServersUseCase } from '@/lib/mcp/application/use-cases' import { captureServerEvent } from '@/lib/posthog/server' -import { cursorSortKey, encodeSortedCursor, readSortedCursor } from '@/app/api/v2/lib/response' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' import { toV2McpServer } from '@/app/api/v2/mcp-servers/utils' export const dynamic = 'force-dynamic' @@ -49,13 +49,12 @@ export const GET = defineV2JsonRoute({ useCase: listMcpServersUseCase, present: ({ servers, nextCursorKeys }, { query }) => ({ data: servers.map(toV2McpServer), - nextCursor: nextCursorKeys - ? encodeSortedCursor( - cursorSortKey(query.sortBy, query.sortOrder), - nextCursorKeys, - mcpServerCursorFilters(query) - ) - : null, + nextCursor: writeSortedCursor( + nextCursorKeys, + query.sortBy, + query.sortOrder, + mcpServerCursorFilters(query) + ), }), }) diff --git a/apps/sim/app/api/v2/secrets/route.ts b/apps/sim/app/api/v2/secrets/route.ts index 86c21fe7e55..591508f8be1 100644 --- a/apps/sim/app/api/v2/secrets/route.ts +++ b/apps/sim/app/api/v2/secrets/route.ts @@ -8,7 +8,7 @@ import { } from '@/lib/api/server/routes' import { secretOperations } from '@/lib/secrets/application/operations' import { listSecretsUseCase } from '@/lib/secrets/application/use-cases' -import { cursorSortKey, encodeSortedCursor, readSortedCursor } from '@/app/api/v2/lib/response' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' import { toV2Secret } from '@/app/api/v2/secrets/utils' export const dynamic = 'force-dynamic' @@ -42,12 +42,11 @@ export const GET = defineV2JsonRoute({ useCase: listSecretsUseCase, present: ({ secrets, userId, nextCursorKeys }, { query }) => ({ data: secrets.map((secret) => toV2Secret(secret, userId)), - nextCursor: nextCursorKeys - ? encodeSortedCursor( - cursorSortKey(query.sortBy, query.sortOrder), - nextCursorKeys, - secretCursorFilters(query) - ) - : null, + nextCursor: writeSortedCursor( + nextCursorKeys, + query.sortBy, + query.sortOrder, + secretCursorFilters(query) + ), }), }) diff --git a/apps/sim/app/api/v2/secrets/utils.ts b/apps/sim/app/api/v2/secrets/utils.ts index e92a1d472ce..498040cd920 100644 --- a/apps/sim/app/api/v2/secrets/utils.ts +++ b/apps/sim/app/api/v2/secrets/utils.ts @@ -1,4 +1,4 @@ -import type { V2Secret, V2SecretScope } from '@/lib/api/contracts/v2/secrets' +import type { V2Secret } from '@/lib/api/contracts/v2/secrets' import type { VisibleWorkspaceCredential } from '@/lib/credentials/queries' /** Serialize environment credential metadata as a secret without exposing its stored value. */ @@ -18,9 +18,3 @@ export function toV2Secret(row: VisibleWorkspaceCredential, userId: string): V2S updatedAt: row.updatedAt.toISOString(), } } - -export function secretCredentialTypes(scope?: V2SecretScope) { - if (scope === 'workspace') return ['env_workspace'] as const - if (scope === 'personal') return ['env_personal'] as const - return ['env_workspace', 'env_personal'] as const -} diff --git a/apps/sim/app/api/v2/tables/route.ts b/apps/sim/app/api/v2/tables/route.ts index ad9c104c3f2..d4d715fd7fd 100644 --- a/apps/sim/app/api/v2/tables/route.ts +++ b/apps/sim/app/api/v2/tables/route.ts @@ -4,7 +4,7 @@ import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/ import { v2TableErrorPolicies } from '@/lib/table/api' import { tableOperations } from '@/lib/table/application/operations' import { createTableUseCase, listTablesUseCase } from '@/lib/table/application/tables' -import { cursorSortKey, encodeSortedCursor, readSortedCursor } from '@/app/api/v2/lib/response' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' import { toApiTable, toApiTables } from '@/app/api/v2/tables/utils' export const dynamic = 'force-dynamic' @@ -37,13 +37,12 @@ export const GET = defineV2JsonRoute({ }), present: async ({ tables, nextKeys }, { query }) => ({ data: await toApiTables(tables), - nextCursor: nextKeys - ? encodeSortedCursor( - cursorSortKey(query.sortBy, query.sortOrder), - nextKeys, - tableCursorFilters(query) - ) - : null, + nextCursor: writeSortedCursor( + nextKeys, + query.sortBy, + query.sortOrder, + tableCursorFilters(query) + ), }), }) diff --git a/apps/sim/app/api/v2/tables/utils.ts b/apps/sim/app/api/v2/tables/utils.ts index 0420fd286e8..8a480854ca7 100644 --- a/apps/sim/app/api/v2/tables/utils.ts +++ b/apps/sim/app/api/v2/tables/utils.ts @@ -1,23 +1,11 @@ -import type { NextResponse } from 'next/server' import type { V2ApiTable } from '@/lib/api/contracts/v2/tables' -import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' -import type { MultipartError } from '@/lib/core/utils/multipart' -import type { RowData, TableDefinition, TablePredicate, TableSchema } from '@/lib/table' +import type { RowData, TableDefinition, TableSchema } from '@/lib/table' import { getMaxRowsPerTable } from '@/lib/table/billing' import { buildColumnNameById, remapViewConfigColumnRefs } from '@/lib/table/column-keys' -import { TableLockedError } from '@/lib/table/mutation-locks' -import { predicateToFilter } from '@/lib/table/query-builder/converters' -import { - validatePredicateShape, - validateStoragePredicate, -} from '@/lib/table/query-builder/validate' -import { predicateToStorage } from '@/lib/table/select-values' -import type { ColumnDefinition, Filter, TableLockKind } from '@/lib/table/types' +import type { ColumnDefinition } from '@/lib/table/types' import type { TableView } from '@/lib/table/views/service' import { normalizeColumn } from '@/lib/table/wire' import { getUserEmailsByIds, requireResolvedUserEmail } from '@/lib/users/queries' -import { CSV_IMPORT_PROXY_BODY_CAP_BYTES } from '@/app/api/table/utils' -import { v2Error, v2ErrorForOrchestration } from '@/app/api/v2/lib/response' /** * Shared serialization + error helpers for the v2 tables surface. Every v2 @@ -50,21 +38,6 @@ function requireMaxRows( return maxRows } -/** - * Resolves a public v2 bulk-op predicate to the storage-id-keyed legacy `Filter` - * the row runners consume. The public wire is column-NAME-keyed: shape-check - * first (keying-agnostic), translate names → storage ids (including select - * operand names → option ids), then validate the RESULT against storage keys — - * on a destructive path an unresolved field must 400, not silently match - * nothing. - */ -export function v2BulkPredicateToFilter(predicate: TablePredicate, schema: TableSchema): Filter { - validatePredicateShape(predicate) - const translated = predicateToStorage(predicate, schema) - validateStoragePredicate(translated, schema.columns) - return predicateToFilter(translated) -} - /** * Normalized public table shape — the same subset of fields the v1 surface * exposes, with timestamps serialized to ISO strings. Shared by every v2 table @@ -207,105 +180,3 @@ export function toApiRow(row: ApiRowInput, toNamedRow: (data: RowData) => RowDat updatedAt: toIso(row.updatedAt), } } - -/** - * Maps a {@link MultipartError} from the streaming CSV reader to the v2 - * envelope. Mirrors v1's {@link multipartErrorResponse} — same classification, - * different envelope. - */ -export function v2MultipartError(error: MultipartError): NextResponse { - if (error.code === 'FILE_TOO_LARGE') { - return v2Error('PAYLOAD_TOO_LARGE', 'CSV import file exceeds maximum size') - } - return error.code === 'NO_FILE' - ? v2Error('BAD_REQUEST', 'CSV file is required') - : v2Error('BAD_REQUEST', `Invalid CSV upload: ${error.message}`) -} - -/** - * 413 when a synchronous CSV upload would exceed the proxy's body cap; `null` - * otherwise. Next buffers the request body for the proxy and silently - * TRUNCATES it past the cap, so an unchecked oversize upload imports a partial - * file and reports success — the failure this exists to prevent. - */ -export function v2CsvBodyCapError(request: { headers: Headers }): NextResponse | null { - const contentLength = Number(request.headers.get('content-length') ?? 0) - if (contentLength <= CSV_IMPORT_PROXY_BODY_CAP_BYTES) return null - return v2Error( - 'PAYLOAD_TOO_LARGE', - 'File too large to import through the server. Upload it to workspace storage and use the async import instead.' - ) -} - -/** - * Maps a delete/write rejected by a table lock to the v2 `LOCKED` envelope, - * mirroring v1's {@link tableLockErrorResponse}. Returns `null` for anything - * else so the caller falls through to its own classification. - * - * `details.lock` names the flag that rejected the write. A table carries four - * independent locks, so "locked" on its own does not tell a caller which one to - * clear — every 423 on the surface reports it. - */ -export function v2TableLockError( - error: unknown, - /** Merged into `details` — e.g. which operations of a composite write landed. */ - extraDetails?: Record -): NextResponse | null { - if (error instanceof TableLockedError) { - return v2Error('LOCKED', error.message, { details: { lock: error.lock, ...extraDetails } }) - } - return null -} - -/** The failure half of any `lib/table/orchestration` result. */ -export interface OrchestrationOutcome { - errorCode?: OrchestrationErrorCode - error?: string - lock?: TableLockKind -} - -/** - * Renders a `lib/table/orchestration` failure in the v2 envelope, naming the - * lock when one caused it. - * - * A lock rejection reaches a route two different ways — thrown and caught at - * the boundary ({@link v2TableLockError}), or returned as a classified - * `errorCode: 'locked'` outcome — and both must produce the same body. Plain - * {@link v2ErrorForOrchestration} cannot, because the `lock` kind lives on the - * outcome rather than the code, so every table route that renders an - * orchestration result goes through this instead. - */ -export function v2TableOrchestrationError( - outcome: OrchestrationOutcome, - fallback: string, - /** Merged into `details` — e.g. which operations of a composite write landed. */ - extraDetails?: Record -): NextResponse { - // `lock` is omitted rather than sent as null when the kind is unknown — a - // caller branching on `details.lock` should see absence, not a phantom value. - const details = { - ...(outcome.errorCode === 'locked' && outcome.lock ? { lock: outcome.lock } : {}), - ...extraDetails, - } - return v2ErrorForOrchestration( - outcome.errorCode, - outcome.error ?? fallback, - Object.keys(details).length > 0 ? details : undefined - ) -} - -/** - * Adapts a failed-row validation from the shared `validateRowData` / - * `validateBatchRows` helpers — which bake a v1-shaped `{ error, details }` 400 - * response — into the canonical v2 error envelope while preserving the - * structured `details` (per-field / per-row). The validators expose the failure - * only as a rendered response, so the body is read back rather than - * re-implementing the size/schema/unique checks. - */ -export async function v2RowValidationError(response: NextResponse): Promise { - const body = (await response - .clone() - .json() - .catch(() => ({}))) as { error?: string; details?: unknown } - return v2Error('BAD_REQUEST', body.error ?? 'Invalid row data', { details: body.details }) -} diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/route.ts b/apps/sim/app/api/v2/workflows/[id]/runs/route.ts index a683643347b..aa1528ef178 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/route.ts @@ -3,18 +3,14 @@ import { v2ListWorkflowRunsContract, v2WorkflowRunListStatusValueSchema, } from '@/lib/api/contracts/v2/workflows' -import { - cursorScopeKey, - instantScopePart, - REFILTERED_CURSOR_MESSAGE, -} from '@/lib/api/cursor-binding' +import { cursorScopeKey, instantScopePart } from '@/lib/api/cursor-binding' import { INVALID_CURSOR_MESSAGE } from '@/lib/api/list-query' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { OrchestrationError } from '@/lib/core/orchestration/types' import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' import { listWorkflowRuns } from '@/lib/workflows/application/list-workflow-runs' import { workflowOperations } from '@/lib/workflows/application/operations' -import { cursorSortKey, decodeSortedCursor, encodeSortedCursor } from '@/app/api/v2/lib/response' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -42,19 +38,17 @@ export const GET = defineV2JsonRoute({ errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, mapInput: ({ params, query }) => { const { status, trigger, startDate, endDate, limit, cursor, order } = query - const sort = cursorSortKey('startedAt', order) - const decodedCursor = decodeSortedCursor(cursor, sort, runCursorFilters(params.id, query)) - if (decodedCursor.status === 'invalid') { - throw new OrchestrationError('validation', INVALID_CURSOR_MESSAGE) - } - if (decodedCursor.status === 'refiltered') { - throw new OrchestrationError('validation', REFILTERED_CURSOR_MESSAGE) - } - const [cursorStartedAt, cursorRowId] = decodedCursor.status === 'ok' ? decodedCursor.keys : [] + const cursorKeys = readSortedCursor( + cursor, + 'startedAt', + order, + runCursorFilters(params.id, query) + ) + const [cursorStartedAt, cursorRowId] = cursorKeys ?? [] const cursorDate = typeof cursorStartedAt === 'string' ? new Date(cursorStartedAt) : null if ( - decodedCursor.status === 'ok' && - (decodedCursor.keys.length !== 2 || + cursorKeys && + (cursorKeys.length !== 2 || !cursorDate || Number.isNaN(cursorDate.getTime()) || typeof cursorRowId !== 'string') @@ -70,7 +64,7 @@ export const GET = defineV2JsonRoute({ endDate: endDate ? new Date(endDate) : undefined, limit, cursor: - decodedCursor.status === 'ok' && cursorDate && typeof cursorRowId === 'string' + cursorKeys && cursorDate && typeof cursorRowId === 'string' ? { startedAt: cursorDate, rowId: cursorRowId } : undefined, order, @@ -88,14 +82,14 @@ export const GET = defineV2JsonRoute({ durationMs: row.durationMs, cost: row.costTotal != null ? { total: Number(row.costTotal) } : null, })) - const sort = cursorSortKey('startedAt', result.order) - const nextCursor = result.nextCursor - ? encodeSortedCursor( - sort, - [result.nextCursor.startedAt.toISOString(), result.nextCursor.rowId], - runCursorFilters(params.id, query) - ) - : null + const nextCursor = writeSortedCursor( + result.nextCursor + ? [result.nextCursor.startedAt.toISOString(), result.nextCursor.rowId] + : null, + 'startedAt', + result.order, + runCursorFilters(params.id, query) + ) return { data, nextCursor } }, }) diff --git a/apps/sim/app/api/v2/workflows/route.ts b/apps/sim/app/api/v2/workflows/route.ts index 516dbadac0d..c8095d27ce2 100644 --- a/apps/sim/app/api/v2/workflows/route.ts +++ b/apps/sim/app/api/v2/workflows/route.ts @@ -10,7 +10,7 @@ import { import { createWorkflow } from '@/lib/workflows/application/create-workflow' import { listWorkflows } from '@/lib/workflows/application/list-workflows' import { workflowOperations } from '@/lib/workflows/application/operations' -import { cursorSortKey, encodeSortedCursor, readSortedCursor } from '@/app/api/v2/lib/response' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -68,13 +68,12 @@ export const GET = defineV2JsonRoute({ updatedAt: workflow.updatedAt.toISOString(), }) ), - nextCursor: nextCursorKeys - ? encodeSortedCursor( - cursorSortKey(query.sortBy, query.sortOrder), - nextCursorKeys, - workflowCursorFilters(query) - ) - : null, + nextCursor: writeSortedCursor( + nextCursorKeys, + query.sortBy, + query.sortOrder, + workflowCursorFilters(query) + ), }), }) diff --git a/apps/sim/lib/api/cursor-binding.ts b/apps/sim/lib/api/cursor-binding.ts index d0bd539cd55..d1994e068bd 100644 --- a/apps/sim/lib/api/cursor-binding.ts +++ b/apps/sim/lib/api/cursor-binding.ts @@ -1,22 +1,5 @@ import { createHash } from 'node:crypto' -/** - * The one canonicalization every paginated surface stamps its cursors with. - * - * A cursor names a position in *one* sequence. Everything that reorders or - * re-filters that sequence therefore has to travel with it, or replaying the - * token against a re-filtered read silently answers from a sequence the caller - * never asked for. `lib/table/rows/cursor.ts` and the v2 list codecs in - * `app/api/v2/lib/response.ts` both bind through this module so there is one - * fingerprint format rather than one per surface. - * - * What belongs in a binding is every param that changes *which rows, in which - * order*. What must stay out is `limit`: it selects how much of the sequence to - * return, not what the sequence is, so a caller is free to change page size - * mid-walk. Response-shaping params (whether to inline trace spans, say) stay - * out for the same reason. - */ - /** * Caller-facing message for a cursor replayed under different filters. Separate * from the sort-mismatch message on purpose: both mean "restart pagination", @@ -41,14 +24,7 @@ export const UNREADABLE_CURSOR_MESSAGE = 'cursor is not a valid pagination cursor. Restart pagination without a cursor.' /** A scalar a list filter can be expressed as, before canonicalization. */ -export type CursorScopePart = - | string - | number - | boolean - | Date - | readonly string[] - | null - | undefined +type CursorScopePart = string | number | boolean | Date | readonly string[] | null | undefined /** * Canonical form of a filter the query treats as an unordered SET. @@ -60,15 +36,14 @@ export type CursorScopePart = * spelling, so a caller who reorders an equivalent filter mid-walk gets a 400 * for a page that is genuinely the next one. * - * {@link canonicalJson} already sorts object keys, so this only has to normalize - * the list. Members are de-duplicated as well as sorted: the filters compile to + * Members are de-duplicated as well as sorted: the filters compile to * `inArray`, which is set membership, so `A,A,B` selects exactly what `A,B` does * and must not bind to a different page. * * Derived from {@link parseUnorderedList} rather than parsing again, so the * members this fingerprints are exactly the members the query filters on. A - * route that canonicalized here and split the raw value itself would give - * `A,B` and `A, B` one fingerprint and two different result sets. + * route that split the raw value itself would give `A,B` and `A, B` one + * fingerprint and two different result sets. */ export function unorderedScopePart(raw: string | undefined): string | undefined { const members = parseUnorderedList(raw) @@ -166,6 +141,14 @@ export function fingerprint(canonical: string): string { * The fingerprint of a list's sequence-affecting params, or `undefined` when * the caller supplied none of them. * + * A cursor names a position in *one* sequence, so everything that reorders or + * re-filters that sequence has to travel with it — otherwise replaying the token + * against a re-filtered read silently answers from a sequence the caller never + * asked for. Pass every param that changes *which rows, in which order*. Keep + * `limit` out: it selects how much of the sequence to return, not what the + * sequence is, so a caller may change page size mid-walk. Response-shaping + * params (whether to inline trace spans, say) stay out for the same reason. + * * `undefined` is a real state rather than an empty hash: an unstamped cursor is * an unfiltered one, so it stays short, and replaying it under a filter still * mismatches (`undefined !== `). Params whose value is `undefined` are diff --git a/apps/sim/lib/api/server/routes/definition.ts b/apps/sim/lib/api/server/routes/definition.ts index 248eb1b8557..2d3642e85d2 100644 --- a/apps/sim/lib/api/server/routes/definition.ts +++ b/apps/sim/lib/api/server/routes/definition.ts @@ -1,7 +1,7 @@ import type { AnyApiRouteContract } from '@/lib/api/contracts' import type { ApplicationOperation } from '@/lib/core/application' -export interface JsonRouteDefinitionMetadata { +interface JsonRouteDefinitionMetadata { successStatus: number successStatuses: readonly number[] } diff --git a/apps/sim/lib/api/server/routes/internal-json-route.ts b/apps/sim/lib/api/server/routes/internal-json-route.ts index bc0af861a23..f9102d57217 100644 --- a/apps/sim/lib/api/server/routes/internal-json-route.ts +++ b/apps/sim/lib/api/server/routes/internal-json-route.ts @@ -53,7 +53,7 @@ export const internalSessionAuth = { }, } as const -export interface InternalSessionOrExecutorAuthOptions { +interface InternalSessionOrExecutorAuthOptions { audience: string resourceScope?( params: Record diff --git a/apps/sim/lib/api/server/routes/v2-json-route.ts b/apps/sim/lib/api/server/routes/v2-json-route.ts index 396a36e0040..374bc712a83 100644 --- a/apps/sim/lib/api/server/routes/v2-json-route.ts +++ b/apps/sim/lib/api/server/routes/v2-json-route.ts @@ -112,20 +112,16 @@ export const v2RateLimits = { * response sets. Declared here so a route only has to set `parseOptions.maxBodyBytes` to * get a correct 413; a route that supplies its own `payloadTooLargeResponse` still wins. */ -export const v2PayloadTooLargeResponse = () => - v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large') +const v2PayloadTooLargeResponse = () => v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large') /** * Default `400` for a body that is absent or not valid JSON, for the same * reason as {@link v2PayloadTooLargeResponse}: `parseRequest`'s fallback is a * bare `{ "error": "Request body must be valid JSON" }` carrying no * `error.code`, so a client reading `error.code` off every other v2 failure - * gets `undefined` exactly when its request was malformed. - * - * It is a default rather than a per-route opt-in because the opt-in *was* the - * bug: only 8 of the 77 v2 routes remembered to pass it, so the envelope held - * for validation errors and broke for transport-level ones. A route supplying - * its own `invalidJsonResponse` still wins. + * gets `undefined` exactly when its request was malformed. A default rather + * than a per-route opt-in, because an opt-in only holds where somebody + * remembered it. A route supplying its own `invalidJsonResponse` still wins. */ export const v2InvalidJsonResponse = () => v2Error('BAD_REQUEST', 'Request body must be valid JSON') @@ -134,29 +130,14 @@ export const v2InvalidJsonResponse = () => v2Error('BAD_REQUEST', 'Request body * * The builders spread this, and so must the handful of raw `withRouteHandler` * v2 routes that call `parseRequest` directly — they are exactly the routes a - * builder default cannot reach, and leaving them out is what kept the bare - * `{ "error": string }` body alive on two of the busiest v2 POSTs. + * builder default cannot reach. */ export const V2_PARSE_DEFAULTS = { payloadTooLargeResponse: v2PayloadTooLargeResponse, invalidJsonResponse: v2InvalidJsonResponse, - /** - * `?limit=` is not `limit` omitted, and v2 already says so on the two params - * whose schema happens to catch it: `search` and `cursor` both reject a blank - * and tell the caller to omit the parameter. Applying the rule at the surface - * rather than per schema is what makes it true for every param — including the - * coerced ones, where the blank has already become `0` or a default by the - * time a schema sees the value. - */ + /** See {@link blankQueryValueValidationError}. */ rejectBlankQueryValues: true, - /** - * `?workspaceId=X&workspaceId=X` is not `workspaceId=X`, and no v2 query - * param is declared as an array — every list on this surface is one - * comma-separated string — so a repeated param can only ever be a caller - * mistake. Without this it reached the schema as an array and drew that - * param's *absence* message ("Workspace ID is required") for a request that - * plainly sent it, which is a signpost pointing away from the actual error. - */ + /** See {@link duplicateQueryValueValidationError}. */ rejectDuplicateQueryValues: true, } as const @@ -166,14 +147,10 @@ export interface V2ErrorPolicy { /** * Refuses at module load to build a `headSafe: false` route whose use case - * cannot answer the authorization question on its own. - * - * Such a route must decide a `HEAD` without executing the use case, and the only - * honest way to do that is to run the use case's authorization phase alone. A - * use case that does not expose one leaves the builder with nothing but - * admission to answer from, which is precisely the existence oracle - * `headSafe: false` used to ship. Failing here turns the next occurrence into a - * boot failure instead of a silent 200. + * cannot answer the authorization question on its own — see the `headSafe` + * option below. A use case with no `authorize` leaves the builder nothing but + * admission to answer a `HEAD` from, so the gap is a boot failure rather than a + * silent 200. */ export function requireHeadAuthorizableUseCase( contract: { method: string; path: string }, @@ -196,13 +173,10 @@ export function requireHeadAuthorizableUseCase( * 200. What a `HEAD` never reaches is the use case's business phase, so the * outbound connection, the row write, and the audit event stay unfired. * - * A use case with no `authorize` is refused here rather than skipped. Both - * builders that call this already refuse such a route at module load through - * {@link requireHeadAuthorizableUseCase}, and they are its only callers, so the - * refusal is unreachable through them. It is not written as a comment because - * the alternative — an optional call — degrades a missing phase into exactly the - * bodiless 200 this function exists to stop, and it does so silently. Failing - * closed makes an authorization that actually ran the only route to that 200. + * A use case with no `authorize` throws here rather than being skipped, even + * though {@link requireHeadAuthorizableUseCase} already refuses such a route at + * module load: treating the phase as optional would silently degrade a missing + * one into exactly the bodiless 200 this function exists to stop. */ export async function v2HeadAuthorizationResponse(args: { useCase: Pick, 'authorize'> @@ -320,10 +294,10 @@ interface V2JsonRouteOptions( if (options?.rejectDuplicateQueryValues) { const duplicated = duplicateQueryValueValidationError(rawQuery) if (duplicated) { - return { - success: false, - response: options.validationErrorResponse - ? options.validationErrorResponse(duplicated) - : validationErrorResponse(duplicated), - } + return { success: false, response: projectValidationError(duplicated, options) } } } if (options?.rejectBlankQueryValues) { const blank = blankQueryValueValidationError(rawQuery) if (blank) { - return { - success: false, - response: options.validationErrorResponse - ? options.validationErrorResponse(blank) - : validationErrorResponse(blank), - } + return { success: false, response: projectValidationError(blank, options) } } } @@ -367,12 +351,17 @@ function rejectNulBytes( ): { success: false; response: NextResponse } | null { const error = nulByteValidationError(data) if (!error) return null - return { - success: false, - response: options?.validationErrorResponse - ? options.validationErrorResponse(error) - : validationErrorResponse(error), - } + return { success: false, response: projectValidationError(error, options) } +} + +/** Renders a validation failure through the caller's envelope when it supplies one. */ +function projectValidationError( + error: z.ZodError, + options?: ParseRequestOptions +): NextResponse { + return options?.validationErrorResponse + ? options.validationErrorResponse(error) + : validationErrorResponse(error) } function validateRequestSchema( @@ -384,9 +373,7 @@ function validateRequestSchema( if (!result.success) { return { success: false, - response: options?.validationErrorResponse - ? options.validationErrorResponse(result.error) - : validationErrorResponse(result.error), + response: projectValidationError(result.error, options), error: result.error, } } From 405933fb045d38693f05e1997581f1c94a312002 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 13 Aug 2026 09:11:35 -0700 Subject: [PATCH 50/56] docs(v2): cut duplicated and non-load-bearing comment prose Five rationales were written three to five times each by parallel agents that could not see one another. Each now has one home and the rest point at it: - HEAD existence oracle -> the headSafe option on defineV2JsonRoute - cursor query binding -> cursorScopeKey in lib/api/cursor-binding.ts - storage-key prefix budget -> buildStorageKeySegment - NUL / U+0000 -> the containsNulCharacter predicate - blank and duplicate query values -> their own implementations Also drops changelog-in-source (prose narrating what the code used to do), anchorless module headers attached to no declaration, rejected- alternative essays, and @param tags that only restate the signature. Comments only: the diff contains no executable-code change. --- .../sim/app/api/v2/billing/logs/route.test.ts | 5 +- .../app/api/v2/files/[fileId]/route.test.ts | 9 +- apps/sim/app/api/v2/files/[fileId]/route.ts | 12 +-- apps/sim/app/api/v2/files/route.test.ts | 6 +- .../app/api/v2/knowledge/search/route.test.ts | 13 ++- apps/sim/app/api/v2/logs/route.test.ts | 5 +- .../v2/mcp-servers/[id]/tools/route.test.ts | 20 ++--- .../api/v2/mcp-servers/[id]/tools/route.ts | 13 +-- apps/sim/app/api/v2/mcp-servers/utils.ts | 7 +- apps/sim/app/api/v2/skills/route.test.ts | 9 +- .../api/v2/workflows/[id]/deployment/route.ts | 13 +-- .../app/api/v2/workflows/[id]/export/route.ts | 5 -- apps/sim/app/api/v2/workflows/[id]/route.ts | 8 +- apps/sim/hooks/queries/mcp.ts | 6 +- apps/sim/lib/api/contracts/tables.ts | 9 +- apps/sim/lib/api/contracts/v2/mcp-servers.ts | 4 +- .../v2/openapi/head-not-safe.test.ts | 11 +-- .../lib/api/contracts/v2/openapi/shared.ts | 10 +-- apps/sim/lib/api/contracts/v2/shared.ts | 67 +++++--------- apps/sim/lib/api/contracts/v2/workflows.ts | 8 +- apps/sim/lib/api/cursor-binding.ts | 2 +- apps/sim/lib/api/server/blank-query-values.ts | 34 +++---- apps/sim/lib/api/server/nul-bytes.ts | 49 +++-------- apps/sim/lib/api/server/routes/types.ts | 6 +- .../api/server/routes/v2-binary-route.test.ts | 10 +-- .../lib/api/server/routes/v2-binary-route.ts | 17 +--- .../api/server/routes/v2-json-route.test.ts | 13 ++- apps/sim/lib/core/application/operation.ts | 10 +-- .../knowledge/application/knowledge-bases.ts | 5 +- .../knowledge/application/upload-sessions.ts | 7 +- .../lib/logs/application/get-public-log.ts | 7 +- .../application/public-log-use-cases.test.ts | 16 ++-- apps/sim/lib/mcp/utils.test.ts | 8 +- apps/sim/lib/skills/application/operations.ts | 20 ++--- .../__tests__/column-type-registry.test.ts | 11 ++- apps/sim/lib/table/__tests__/sql.test.ts | 5 +- apps/sim/lib/table/column-types/date.ts | 11 +-- apps/sim/lib/table/rows/cursor.ts | 32 +++---- apps/sim/lib/table/validation.test.ts | 5 +- .../lib/uploads/contexts/execution/utils.ts | 5 +- .../knowledge-base-file-manager.ts | 10 +-- .../workspace/workspace-file-manager.ts | 5 -- apps/sim/lib/uploads/core/storage-key.ts | 54 ++++-------- .../uploads/upload-session/cleanup.test.ts | 4 +- .../uploads/upload-session/provider.test.ts | 8 +- packages/db/timestamps.ts | 88 ++++++------------- packages/utils/src/string.ts | 12 ++- 47 files changed, 232 insertions(+), 462 deletions(-) diff --git a/apps/sim/app/api/v2/billing/logs/route.test.ts b/apps/sim/app/api/v2/billing/logs/route.test.ts index a28ddef4fb8..f1534458b47 100644 --- a/apps/sim/app/api/v2/billing/logs/route.test.ts +++ b/apps/sim/app/api/v2/billing/logs/route.test.ts @@ -148,9 +148,8 @@ describe('GET /api/v2/billing/logs', () => { }) /** - * The envelope check used to accept any string as the inner token, so an - * empty one passed it and then read as falsy in the ledger reader: no cursor - * condition was applied and the caller walked the first page again — the very + * An empty inner token reads as falsy in the ledger reader, so no cursor + * condition is applied and the caller walks the first page again — the very * failure {@link UNKNOWN_CURSOR_MESSAGE} exists to make visible. */ it('rejects a cursor whose inner token is empty instead of restarting at page one', async () => { diff --git a/apps/sim/app/api/v2/files/[fileId]/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/route.test.ts index 7efbab3d18b..973435bcc52 100644 --- a/apps/sim/app/api/v2/files/[fileId]/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/route.test.ts @@ -121,11 +121,10 @@ describe('v2 single-file routes', () => { }) /** - * The download `HEAD` short-circuit used to sit between admission and parsing, - * so it answered before the workspace-scoped file resolution that lives in the - * use case. Any valid API key therefore drew a bodiless 200 for a file id it - * cannot reach, while the `GET` for the same URL answered 404. These pin the - * probe to the answer the download gives, and to still not auditing one. + * A download `HEAD` answered before the use case's workspace-scoped file + * resolution is an existence oracle: any valid API key draws a bodiless 200 + * for a file id whose `GET` answers 404. These pin the probe to the answer the + * download gives, and to still not auditing one. */ it('answers an authorized HEAD bodiless without auditing a download', async () => { const response = await GET(headRequest(`workspaceId=${WORKSPACE_ID}`), context) diff --git a/apps/sim/app/api/v2/files/[fileId]/route.ts b/apps/sim/app/api/v2/files/[fileId]/route.ts index 83f4ca6f350..eb0df4f5f66 100644 --- a/apps/sim/app/api/v2/files/[fileId]/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/route.ts @@ -29,16 +29,8 @@ export const revalidate = 0 * * A generated doc whose artifact is still compiling renders `CONFLICT`; retry. * - * Downloading is not a safe read: it records a `FILE_DOWNLOADED` audit event and - * pulls the bytes out of object storage. Next aliases `HEAD` onto `GET`, and RFC - * 9110 §9.2.1 defines `HEAD` as safe, so this route declares itself not - * head-safe. A `HEAD` is admitted, parsed, and authorized through - * `downloadWorkspaceFileStream.authorize` — the same workspace-scoped file - * resolution and access check the `GET` performs — then answered bodiless - * without auditing or fetching. Without the not-head-safe declaration an uptime - * monitor walking the documented URL list would fabricate a download event on - * every probe; without the authorization step the probe would instead confirm a - * file id the caller has no right to know exists. + * `headSafe: false` because downloading records a `FILE_DOWNLOADED` audit event + * and pulls the bytes out of object storage. */ export const GET = defineV2BinaryRoute({ contract: v2DownloadFileContract, diff --git a/apps/sim/app/api/v2/files/route.test.ts b/apps/sim/app/api/v2/files/route.test.ts index 055987e86ce..a1c162f1319 100644 --- a/apps/sim/app/api/v2/files/route.test.ts +++ b/apps/sim/app/api/v2/files/route.test.ts @@ -106,9 +106,9 @@ describe('/api/v2/files', () => { /** * `?limit=` is not `limit` omitted. `Number('') === 0`, and this list clamps - * out-of-range values, so the blank used to reach the query as `LIMIT 1` and - * return a single row where the omitted param returns a hundred — a silently - * wrong page, not an error. Whitespace-only is the same value. + * out-of-range values, so an unrejected blank reaches the query as `LIMIT 1` + * and returns a single row where the omitted param returns a hundred — a + * silently wrong page, not an error. Whitespace-only is the same value. */ it.each(['limit=', 'limit=%20', 'sortBy=', 'cursor='])( 'rejects the blank query value %s instead of coercing it', diff --git a/apps/sim/app/api/v2/knowledge/search/route.test.ts b/apps/sim/app/api/v2/knowledge/search/route.test.ts index e0038631a75..dcba7d0d593 100644 --- a/apps/sim/app/api/v2/knowledge/search/route.test.ts +++ b/apps/sim/app/api/v2/knowledge/search/route.test.ts @@ -168,10 +168,9 @@ describe('POST /api/v2/knowledge/search', () => { }) /** - * `rerankerEnabled` on its own used to satisfy the schema, fail the use case's - * model guard, and answer 200 in plain vector order — after paying for the - * widened candidate retrieval. The default closes that, matching the internal - * search contract. + * Without the default, `rerankerEnabled` alone satisfies the schema, fails the + * use case's model guard, and answers 200 in plain vector order — after paying + * for the widened candidate retrieval. */ it('defaults the reranker model so enabling reranking is enough to run it', async () => { const response = await POST( @@ -280,9 +279,9 @@ describe('POST /api/v2/knowledge/search', () => { * The search body is strict, so an undeclared key is refused rather than * stripped. That matters most for a bring-your-own reranker key: dropping it * silently left the caller believing the secret it sent was in use. It - * matters for an ordinary mis-spelling too — `rerankerenabled` used to parse - * to 200 with reranking off, and `topk` with `topK` back at its default, both - * of which change what the search is billed. + * matters for an ordinary mis-spelling too: a stripped `rerankerenabled` is a + * 200 with reranking off, and a stripped `topk` leaves `topK` at its default — + * both change what the search is billed. */ it('refuses a caller-supplied reranker key instead of silently dropping it', async () => { const response = await POST( diff --git a/apps/sim/app/api/v2/logs/route.test.ts b/apps/sim/app/api/v2/logs/route.test.ts index bf91febb176..277f05cd1f1 100644 --- a/apps/sim/app/api/v2/logs/route.test.ts +++ b/apps/sim/app/api/v2/logs/route.test.ts @@ -196,9 +196,8 @@ describe('GET /api/v2/logs', () => { }) /** - * The envelope check used to accept any string as the inner token, so an - * empty one passed it and then read as falsy in the domain codec: no cursor - * condition was applied and the caller silently got page one back, with a + * An empty inner token reads as falsy in the domain codec, so no cursor + * condition is applied and the caller silently gets page one back, with a * `nextCursor` inviting it to do the same thing forever. */ it('rejects a cursor whose inner token is empty instead of restarting at page one', async () => { diff --git a/apps/sim/app/api/v2/mcp-servers/[id]/tools/route.test.ts b/apps/sim/app/api/v2/mcp-servers/[id]/tools/route.test.ts index 13fbec01303..e577d2428f5 100644 --- a/apps/sim/app/api/v2/mcp-servers/[id]/tools/route.test.ts +++ b/apps/sim/app/api/v2/mcp-servers/[id]/tools/route.test.ts @@ -128,12 +128,11 @@ describe('/api/v2/mcp-servers/[id]/tools', () => { }) /** - * The `HEAD` short-circuit used to sit between admission and parsing, so it - * answered before resource authorization ever ran — which lives in the use - * case. Any valid API key therefore drew a bodiless 200 for a server id in a - * workspace it cannot read, a server id that does not exist, and a principal - * kind this operation refuses outright, while the `GET` for the same URL - * answered 403 or 404. These four pin the probe to the answer the `GET` gives. + * A `HEAD` answered before the use case's resource authorization is an + * existence oracle: any valid API key draws a bodiless 200 for a server id in + * a workspace it cannot read, for one that does not exist, and for a principal + * kind this operation refuses outright. These four pin the probe to the answer + * the `GET` gives. */ it('does not confirm a server to a principal kind the operation refuses', async () => { mocks.authorizeDiscover.mockRejectedValueOnce(new WorkspaceApiKeyAuthorizationError()) @@ -260,11 +259,10 @@ describe('/api/v2/mcp-servers/[id]/tools', () => { }) /** - * The 503 wording used to be selected by searching the error message for - * `cooldown`. `McpConnectionError` interpolates the server's display name into - * that message, so a server the caller happened to name after the word - * borrowed the negative-cache wording and told them to wait out a cooldown - * that was never entered. + * `McpConnectionError` interpolates the server's display name into its + * message, so selecting the 503 wording by searching that message for + * `cooldown` hands a server named after the word the negative-cache wording + * for a cooldown it was never in. */ it('does not read cooldown wording out of a server display name', async () => { mocks.discover.mockRejectedValueOnce(new McpConnectionError('ECONNREFUSED', 'Cooldown Docs')) diff --git a/apps/sim/app/api/v2/mcp-servers/[id]/tools/route.ts b/apps/sim/app/api/v2/mcp-servers/[id]/tools/route.ts index ea4fffa2230..11343abb137 100644 --- a/apps/sim/app/api/v2/mcp-servers/[id]/tools/route.ts +++ b/apps/sim/app/api/v2/mcp-servers/[id]/tools/route.ts @@ -14,17 +14,8 @@ export const revalidate = 0 * as `mcp-` from the workspace and endpoint URL, and the registration * contract requires a URL. * - * Discovery is not a safe read: it opens a live connection to the registered - * endpoint and records the outcome on the server row. Next aliases `HEAD` onto - * `GET`, and RFC 9110 §9.2.1 defines `HEAD` as safe, so this route declares - * itself not head-safe. A `HEAD` is admitted, parsed, and authorized through - * `discoverMcpServerToolsUseCase.authorize` — the same principal-kind check, - * server resolution, and workspace access check the `GET` performs — then - * answered bodiless without connecting or writing. Without the not-head-safe - * declaration an uptime monitor walking the documented URL list would drive - * outbound third-party traffic and mutate rows on every probe; without the - * authorization step the probe would instead confirm that a server id exists in - * a workspace the caller cannot read. + * `headSafe: false` because discovery opens a live connection to the registered + * endpoint and records the outcome on the server row. */ export const GET = defineV2JsonRoute({ contract: v2ListMcpServerToolsContract, diff --git a/apps/sim/app/api/v2/mcp-servers/utils.ts b/apps/sim/app/api/v2/mcp-servers/utils.ts index e30b8cb99c3..c8ef47af52b 100644 --- a/apps/sim/app/api/v2/mcp-servers/utils.ts +++ b/apps/sim/app/api/v2/mcp-servers/utils.ts @@ -54,10 +54,9 @@ export const MCP_SERVER_REAUTHORIZATION_REQUIRED = 'MCP_SERVER_REAUTHORIZATION_R * Every branch returns a constant, so an upstream message — which may quote a * hostname, a token endpoint, or a stack — never reaches the caller. * - * Selection is typed for the same reason classification is. The cooldown branch - * used to search the message for `cooldown`, but `McpConnectionError` - * interpolates the server's display name into its message, so a server a caller - * named after the word was told to wait out a cooldown it was never in. + * Selection is typed, never matched on message text: `McpConnectionError` + * interpolates the server's display name into its message, so a server named + * after the word `cooldown` would select the cooldown branch it is not in. */ function unreachableServerMessage(error: unknown): string { if (isTimeoutError(error)) return 'The MCP server took too long to respond' diff --git a/apps/sim/app/api/v2/skills/route.test.ts b/apps/sim/app/api/v2/skills/route.test.ts index fc868cf954d..e84de0dfcdb 100644 --- a/apps/sim/app/api/v2/skills/route.test.ts +++ b/apps/sim/app/api/v2/skills/route.test.ts @@ -314,12 +314,9 @@ describe('/api/v2/skills', () => { }) /** - * The workspace-key create used to be the case this file pinned analytics - * against: it succeeded, and the assertion was that no `skill_created` event - * was attributed to a principal with no human subject. `skills.create` now - * denies the key outright, so what needs pinning here is the surface's half of - * that — the refusal reaches the caller as the operation's own 403, and a - * create that never happened emits nothing. + * `skills.create` denies a workspace key outright, so what this pins is the + * surface's half: the refusal reaches the caller as the operation's own 403, + * and a create that never happened emits no analytics. */ it('refuses a workspace-key create and records no analytics for it', async () => { mocks.create.mockRejectedValueOnce( diff --git a/apps/sim/app/api/v2/workflows/[id]/deployment/route.ts b/apps/sim/app/api/v2/workflows/[id]/deployment/route.ts index abcdd465b7d..6999c325e3c 100644 --- a/apps/sim/app/api/v2/workflows/[id]/deployment/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/deployment/route.ts @@ -22,17 +22,8 @@ export const revalidate = 0 * undeployed, so reading it would report a deploy time alongside * `isDeployed: false`. * - * Deliberately head-safe despite issuing a write. Reading a workflow can trigger - * a migrate-on-read `workflow_blocks` update when - * `applyBlockMigrations` upgrades a stored block. That write is convergent: it is - * conditional on a migration actually applying, idempotent, and would be issued by - * the next ordinary read regardless, so a `HEAD` only brings it forward. - * - * `headSafe: false` is reserved for effects a probe would otherwise *fabricate* — - * an audit row recording an export or download that never happened — or that reach - * a third party. Declaring it here would also cost real capability, because - * {@link v2HeadNoEffect} answers `200` unconditionally, so a `HEAD` could no longer - * distinguish a workflow that exists from one that does not. + * Deliberately head-safe despite the migrate-on-read write, for the reasons on + * `GET /api/v2/workflows/[id]`. */ export const GET = defineV2JsonRoute({ contract: v2GetWorkflowDeploymentContract, diff --git a/apps/sim/app/api/v2/workflows/[id]/export/route.ts b/apps/sim/app/api/v2/workflows/[id]/export/route.ts index 6eabdcf0be0..ad3af5d0518 100644 --- a/apps/sim/app/api/v2/workflows/[id]/export/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/export/route.ts @@ -11,11 +11,6 @@ export const revalidate = 0 * `headSafe: false` because the use case projects a `WORKFLOW_EXPORTED` audit * event. Letting Next alias `HEAD` onto this `GET` would record an export that * handed the caller no bytes. - * - * A `HEAD` still runs `exportWorkflow.authorize`, so it resolves the workflow - * and checks access exactly as the `GET` does and renders any rejection through - * the same concealing error policy. Skipping that made the probe an existence - * oracle for workflow ids across every workspace. */ export const GET = defineV2JsonRoute({ contract: v2ExportWorkflowContract, diff --git a/apps/sim/app/api/v2/workflows/[id]/route.ts b/apps/sim/app/api/v2/workflows/[id]/route.ts index a0c4aa152b8..9e1d6be489e 100644 --- a/apps/sim/app/api/v2/workflows/[id]/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/route.ts @@ -20,11 +20,9 @@ export const revalidate = 0 * conditional on a migration actually applying, idempotent, and would be issued by * the next ordinary read regardless, so a `HEAD` only brings it forward. * - * `headSafe: false` is reserved for effects a probe would otherwise *fabricate* — - * an audit row recording an export or download that never happened — or that reach - * a third party. Declaring it here would also cost real capability, because - * {@link v2HeadNoEffect} answers `200` unconditionally, so a `HEAD` could no longer - * distinguish a workflow that exists from one that does not. + * Declaring `headSafe: false` would also cost real capability: the bodiless + * `200` is unconditional, so a `HEAD` could no longer distinguish a workflow + * that exists from one that does not. */ export const GET = defineV2JsonRoute({ contract: v2GetWorkflowContract, diff --git a/apps/sim/hooks/queries/mcp.ts b/apps/sim/hooks/queries/mcp.ts index ae4868e632d..ac3ab3e12ae 100644 --- a/apps/sim/hooks/queries/mcp.ts +++ b/apps/sim/hooks/queries/mcp.ts @@ -306,11 +306,7 @@ export function useCreateMcpServer() { return { ...safeServerData, id: serverId, - /** - * Mirrors what registration writes. It used to claim `connected` for a - * non-OAuth server — a client-side copy of a server-side assumption that - * no connection had verified. - */ + /** Mirrors what registration writes: no connection has been verified yet. */ connectionStatus: 'disconnected' as const, serverId, updated: wasUpdated, diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index d6bd7cc5216..0186c186e69 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -474,11 +474,10 @@ const MAX_SORT_KEYS = 16 /** * The published predicate grammar. * - * Everything here was previously true only in the SQL builder's own comments: a - * caller reading the spec saw an untyped operand and an operator enum with no - * semantics, so the natural guess — SQL's own `%` wildcard — matched zero rows - * under a 200 and nothing said why. Stated on the operator and on the tree so it - * reaches the OpenAPI description of every endpoint that takes a predicate. + * Without it a caller reads an untyped operand and an operator enum with no + * semantics, and the natural guess — SQL's own `%` wildcard — matches zero rows + * under a 200 with nothing saying why. Stated on the operator and on the tree so + * it reaches the OpenAPI description of every endpoint taking a predicate. */ const PREDICATE_OPERATOR_GRAMMAR = [ 'Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`.', diff --git a/apps/sim/lib/api/contracts/v2/mcp-servers.ts b/apps/sim/lib/api/contracts/v2/mcp-servers.ts index c87ae6d1eda..2ce5c3369d0 100644 --- a/apps/sim/lib/api/contracts/v2/mcp-servers.ts +++ b/apps/sim/lib/api/contracts/v2/mcp-servers.ts @@ -119,9 +119,7 @@ export const v2McpServerSchema = z /** * These three are written only by a real discovery. Registration stores a * configuration without contacting the endpoint, so it leaves all three at - * their defaults — it used to stamp `connected` and a `lastConnected` of now - * for any non-OAuth server, which made both fields false the moment they - * were first read. + * their defaults rather than asserting a connection nothing has verified. */ connectionStatus: mcpServerSchema.shape.connectionStatus.describe( 'Result of the most recent connection attempt. Registration and re-registration store a configuration without contacting the endpoint, so a server begins — and returns to — `disconnected` until a tool discovery runs.' diff --git a/apps/sim/lib/api/contracts/v2/openapi/head-not-safe.test.ts b/apps/sim/lib/api/contracts/v2/openapi/head-not-safe.test.ts index 497fb8dc52d..a125096027e 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/head-not-safe.test.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/head-not-safe.test.ts @@ -36,13 +36,10 @@ function declaresHeadNotSafe(route: OpenApiRouteDefinition): boolean { } /** - * The `headSafe: false` short-circuit used to answer a bodiless `200` straight - * after admission, before the use case — and therefore before authorization — - * ran at all. Both descriptions that mentioned `HEAD` documented that behavior, - * and both stayed put when the builders were fixed to authorize first, so the - * spec went on telling callers a `HEAD` on a forbidden or nonexistent id was a - * `200`. That is a security claim, which makes it the one sentence worth a - * standing check rather than a one-time correction. + * What a `HEAD` answers on a `headSafe: false` route is a security claim, and a + * published description that drifts from the builder tells callers a probe on a + * forbidden or nonexistent id is a `200`. That is worth a standing check rather + * than a one-time correction. */ describe('operations whose GET declares headSafe: false', () => { it('document that HEAD is authorized exactly as GET is', () => { diff --git a/apps/sim/lib/api/contracts/v2/openapi/shared.ts b/apps/sim/lib/api/contracts/v2/openapi/shared.ts index c44d271f3c2..74804f1053a 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/shared.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/shared.ts @@ -213,12 +213,6 @@ export const FULL_SET_LIST = 'The bounded set is returned in one page; `nextCurs * Appended to a `GET` whose route declares `headSafe: false` because the read * has an effect — an outbound connection, or an audit event. * - * The sentence this replaced promised the opposite of what the route now does. - * The short-circuit used to sit between admission and parsing, so a `HEAD` - * returned a bodiless `200` for an id the same caller's `GET` answered `403` or - * `404` for — an existence oracle. `defineV2JsonRoute`/`defineV2BinaryRoute` - * now admit, parse, and authorize a `HEAD` through the use case's `authorize` - * phase before answering it bodiless, so its refusals mirror the `GET`'s. * Pinned by `contracts/v2/openapi/head-not-safe.test.ts`. */ export const HEAD_MIRRORS_GET = @@ -231,9 +225,7 @@ export const HEAD_MIRRORS_GET = * `Content-Length` on a `HEAD` is the standard way to size a download before * fetching it, and this surface cannot serve it: the byte length comes from the * same read that records the download audit event, which is the effect - * `headSafe: false` exists to skip. Naming the alternative is the difference - * between a documented limitation and a caller discovering an absent header at - * runtime. + * `headSafe: false` exists to skip. */ export const HEAD_OMITS_PAYLOAD_HEADERS = 'In particular a `HEAD` does not report `Content-Length`, so it cannot be used to size a download in advance; read the size from the file resource instead.' diff --git a/apps/sim/lib/api/contracts/v2/shared.ts b/apps/sim/lib/api/contracts/v2/shared.ts index 50168053e33..5b8ff796ddf 100644 --- a/apps/sim/lib/api/contracts/v2/shared.ts +++ b/apps/sim/lib/api/contracts/v2/shared.ts @@ -28,12 +28,10 @@ import { * deliberately absent from the public OpenAPI specs (see * `UNDOCUMENTED_V2_ROUTES` in `scripts/check-openapi-specs.ts`), because their * URL is signed, short-lived, and only ever reached through a documented - * operation's response. They used to answer with a bare `{ error: string }` - * instead, which made the one step that actually moves the bytes the one step a - * caller could not parse with its v2 error handling. Not being in the document - * is a reason not to publish a route; it was never a reason to answer in a - * different shape. What that step promises — method, headers, `204`, and which - * codes mean what — is published on `transfer.url` in `contracts/v2/uploads.ts`. + * operation's response. Not being in the document is a reason not to publish a + * route; it is not a reason to answer in a different shape. What that step + * promises — method, headers, `204`, and which codes mean what — is published on + * `transfer.url` in `contracts/v2/uploads.ts`. * * Every list returns the opaque-cursor envelope (Stripe/Slack-style) * `{ data, nextCursor }`, but not every list is *paged*. A paged list also @@ -84,20 +82,14 @@ import { * on the surface (`scope`, `folderPath`, `deployedOnly`, `type`, `providerId`, * `resourceType`). No generic filter expression. A filter value that matches * nothing is an empty page, never an error — including a `folderPath` naming - * no folder ({@link V2_FOLDER_FILTER_MISS}), which used to be this family's - * one 404 and is now the same empty page as `workflowIds` naming no workflow. + * no folder ({@link V2_FOLDER_FILTER_MISS}). * * ## Blank query values * * A param sent with no value (`?limit=`, `?search=`, `?limit=%20`) is a 400 - * naming it. It is not the same request as an omitted param, and no schema can - * see the difference on its own: `z.coerce.number()` reads `''` as `0`, so - * `?limit=` on the lists that clamp became `LIMIT 1` — one row where the omitted - * param gives a hundred — and `?minCost=` on `GET /logs` became a live - * `cost >= 0` filter. `search` and `cursor` already rejected a blank because - * their schemas happened to be strict enough; the rule is enforced for every - * param at the surface instead (`V2_PARSE_DEFAULTS.rejectBlankQueryValues`, - * applied to the raw query before coercion), so a param added later inherits it. + * naming it, enforced for every param at the surface by + * `V2_PARSE_DEFAULTS.rejectBlankQueryValues` — see + * `blankQueryValueValidationError` for why a schema cannot see the difference. * * Every one of these is pushed into SQL, except on `GET /skills` (which narrows the * static builtin registry with the same search term, merges it into the DB rows, @@ -122,11 +114,8 @@ import { * table. * * Adding `limit`/`cursor` to a full-set list is additive, but giving it a - * *default* `limit` truncates callers reading the whole set today, so it is a - * breaking change. Five lists took exactly that change while `v2-api` was off - * in production and enabled only for a staging cohort — the window in which it - * costs nothing. Once v2 is generally available, moving a shipped full-set list - * to a defaulted page size needs a version bump. + * *default* `limit` truncates callers reading the whole set today, so once v2 is + * generally available that change needs a version bump. * * Three cursor schemes are in use. Two are shared codecs in * `app/api/v2/lib/response.ts`, and which of them a list uses is decided by what @@ -151,33 +140,17 @@ import { * * ## Query binding and the opaque cursor * - * A cursor names a position in ONE sequence, and a v2 list decides that - * sequence from its sort AND its filters. Every paged list therefore stamps - * both into the token it returns and re-checks them on the way back in: - * replaying a cursor under a different `sortBy`/`sortOrder`, or under a changed - * filter, is a 400 naming which half changed. Change either by restarting - * pagination without a cursor. - * - * Both failures are silent without the stamp, but they are not the same - * failure. An offset replayed against a re-filtered sequence names an unrelated - * ordinal in it. A keyset stays internally coherent — the page it returns is - * correctly ordered and duplicate-free — and is missing every match that sorts - * before the cursor's position, which a caller holding an opaque token reads as - * "almost nothing matched". Neither is recoverable by the client, so neither is - * served. - * - * `limit` is deliberately not part of the binding: it selects how much of the - * sequence to return, not what the sequence is, so a caller may change page - * size mid-walk. Params that only shape the response body (`details`, - * `includeTraceSpans`, `includeFinalOutput` on `GET /logs`) are out for the same - * reason. The authoritative per-list binding is pinned in - * `v2/__tests__/list-pagination.test.ts`, which fails when a list gains a param - * that is neither bound nor explicitly exempted. + * Every paged list stamps its sort and its filters into the token it returns and + * re-checks them on the way back in; replaying a cursor under a different + * `sortBy`/`sortOrder` or a changed filter is a 400 naming which half changed. + * What belongs in a stamp, and why `limit` and response-shaping params do not, + * is documented on `cursorScopeKey` in `lib/api/cursor-binding.ts`. * - * The three lists whose token is minted by a domain codec that predates the - * shared ones (`GET /logs`, `GET /audit-logs`, `GET /billing/logs`) get the same - * binding by wrapping that token — the domain cursor stays opaque and untouched - * inside a query-stamped envelope. + * The authoritative per-list binding is pinned in + * `v2/__tests__/list-pagination.test.ts`, which fails when a list gains a param + * that is neither bound nor explicitly exempted. The three lists whose token is + * minted by a domain codec (`GET /logs`, `GET /audit-logs`, `GET /billing/logs`) + * get the same binding by wrapping that token in a query-stamped envelope. */ /** diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 7127591b4e0..eb075e9ddc7 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -820,11 +820,9 @@ export type V2ExecutionError = z.output * exported string so the request-body description and the OpenAPI operation * description cannot drift from each other. * - * It deliberately does not enumerate the combinations the route rejects. That - * list used to be pasted onto both the operation and the request-body - * description, restating what each field already says; a caller reads a - * constraint where it applies, so it lives on `async`, `stream`, - * `executionTimeoutSeconds`, `includeThinking`, and `includeToolCalls`. + * It deliberately does not enumerate the combinations the route rejects: a + * caller reads a constraint where it applies, so each lives on `async`, + * `stream`, `executionTimeoutSeconds`, `includeThinking`, or `includeToolCalls`. */ export const EXECUTE_OPTION_CONSTRAINTS = 'Each option carries the modes it requires and the modes that reject it; a violated combination is a 400.' diff --git a/apps/sim/lib/api/cursor-binding.ts b/apps/sim/lib/api/cursor-binding.ts index d1994e068bd..9f7205b935e 100644 --- a/apps/sim/lib/api/cursor-binding.ts +++ b/apps/sim/lib/api/cursor-binding.ts @@ -111,7 +111,7 @@ export function instantScopePart(raw: string | undefined): string | undefined { * * Array order is preserved, because an array is a sequence in the general case. * A filter whose array is really a set must canonicalize it first — see - * {@link parseUnorderedList} and {@link unorderedJsonScopePart} — or equivalent + * {@link parseUnorderedList} and {@link unorderedScopeOf} — or equivalent * queries fingerprint differently and a valid cursor is refused. */ export function canonicalJson(value: unknown): string { diff --git a/apps/sim/lib/api/server/blank-query-values.ts b/apps/sim/lib/api/server/blank-query-values.ts index 76d83c985c0..f8857829d2c 100644 --- a/apps/sim/lib/api/server/blank-query-values.ts +++ b/apps/sim/lib/api/server/blank-query-values.ts @@ -6,27 +6,16 @@ import { ZodError } from 'zod' * * A blank value is not the same request as an omitted parameter, but nothing in * a schema makes that true on its own. `z.coerce.number()` reads `''` as `0` - * (`Number('') === 0`), so `?limit=` on the three lists that clamp instead of - * rejecting became `LIMIT 1` — one row where the omitted param gives a hundred. - * `z.coerce.number().optional()` on the `/logs` cost and duration bounds turned - * `?minCost=` into a live `cost >= 0` filter. A plain `z.string()` filter kept - * the `''` and compared against it. Each of those is a different result set from - * the one the caller believed they asked for, and none of them is reported. - * - * The v2 surface already answers a blank the same way wherever a schema happens - * to notice — `search` is `.min(1, 'search cannot be empty')` and `cursor` is - * `.min(1, 'cursor must be a non-empty token')`, both documented as "omit the - * parameter instead". This applies that published rule to every parameter - * rather than to the ones whose schema was written strictly enough, so a - * parameter added later inherits it. + * (`Number('') === 0`), so `?limit=` on the lists that clamp became `LIMIT 1` — + * one row where the omitted param gives a hundred — and `?minCost=` became a + * live `cost >= 0` filter. Each is a different result set from the one the + * caller believed they asked for, and none of them is reported. * * It runs on the *raw* query, before schema validation, because that is the only * place the blank still exists: coercion has already turned it into `0`, `false`, - * or a default by the time a parsed value is available. - * - * This is a boundary rule rather than a shared string primitive for the same - * reason as the NUL-byte scan next door: a primitive only protects the params - * somebody remembered to build on it. + * or a default by the time a parsed value is available. Applying it at the + * surface rather than per schema is what makes a parameter added later inherit + * the rule. */ export function blankQueryValueValidationError( rawQuery: Record @@ -54,12 +43,9 @@ export function blankQueryValueValidationError( * comma-separated string. The array therefore fails the declared type, and the * caller is told whatever that type's own message says — `workspaceId` answers * "Workspace ID is required" for a request that sent it twice, which points at - * the wrong problem and reads as a server bug. - * - * Naming the duplication is the whole fix, and the boundary is where it belongs - * for the same reason as the blank scan above: the multiplicity exists only in - * the raw query. By the time a schema sees the value, the array is - * indistinguishable from any other wrong type. + * the wrong problem. Naming the duplication is the whole fix, and like the blank + * scan above it belongs at the boundary: by the time a schema sees the value, + * the array is indistinguishable from any other wrong type. */ export function duplicateQueryValueValidationError( rawQuery: Record diff --git a/apps/sim/lib/api/server/nul-bytes.ts b/apps/sim/lib/api/server/nul-bytes.ts index 0cfc461ae4e..0499d6438bc 100644 --- a/apps/sim/lib/api/server/nul-bytes.ts +++ b/apps/sim/lib/api/server/nul-bytes.ts @@ -2,26 +2,6 @@ import { isPlainRecord } from '@sim/utils/object' import { containsNulCharacter } from '@sim/utils/string' import { ZodError } from 'zod' -/** - * `U+0000` is the one code point a Postgres `text`/`jsonb` value cannot carry: - * the wire protocol terminates strings on it, so the driver throws before the - * statement is ever planned. That throw carries no SQLSTATE the route layer can - * classify, so it lands in `unhandledErrorResponse` and reaches the caller as - * `500 INTERNAL_ERROR` — on pure reads (`?search=`) just as readily as on - * writes. - * - * Every other control character is rejected by nothing and stored by Postgres - * verbatim. `\n`, `\t`, and `\r` are ordinary content in a workflow description, - * a table cell, or a file name, so widening this to the whole C0 range would - * break real payloads to fix nothing. Lone surrogates are also left alone: the - * driver's UTF-8 encoder substitutes `U+FFFD` rather than throwing, so they are - * a data-fidelity question, not an availability one. NUL is the only value in - * this class, and it is rejected on its own. The predicate itself is - * `containsNulCharacter` in `@sim/utils/string`, shared with the multipart - * field scan and the canonical folder-path decoder, which reject the same value - * at boundaries this scan cannot see. - */ - /** * Cheap existence scan used on every request. Descends only into arrays and * plain records, so a `Buffer`, `Uint8Array`, or `Date` in a parsed payload is @@ -84,26 +64,19 @@ function findNulBytePath(root: unknown): PropertyKey[] { } /** - * Rejects any `U+0000` reaching the application from a request, as a `ZodError` - * so it renders through each surface's existing validation-error projection - * (the v2 `{ error: { code: 'BAD_REQUEST' } }` envelope, the internal - * `{ error, details }` body) with no per-route wiring. - * - * This is deliberately a *boundary* rejection rather than a shared string - * primitive that every text field opts into. A primitive only ever protects the - * fields somebody remembered to build on it, and it cannot protect the fields - * that have no string schema at all — a table cell and a predicate `value` are - * `z.unknown()` by contract, because their type is decided by the column, not - * the wire. Those are exactly the values the reproduction found reaching the - * driver. One scan over the already-validated payload covers every field, - * including the ones nobody has enumerated yet. + * Rejects any `U+0000` reaching the application from a request — see + * {@link containsNulCharacter} for why Postgres cannot carry one — as a + * `ZodError`, so it renders through each surface's existing validation-error + * projection with no per-route wiring. * - * It runs on the *parsed* value, not the raw one, so a NUL in a property the - * contract strips is not a spurious 400 — only values that actually flow into - * an application use case are checked. + * A boundary scan rather than a shared string schema, because the values that + * reached the driver have no string schema to opt into: a table cell and a + * predicate `value` are `z.unknown()` by contract, their type decided by the + * column rather than the wire. * - * Headers are not scanned: HTTP forbids NUL in a field value and the server's - * own parser rejects it long before a contract sees it. + * It runs on the *parsed* value, so a NUL in a property the contract strips is + * not a spurious 400. Headers are not scanned: HTTP forbids NUL in a field + * value and the server's own parser rejects it first. */ export function nulByteValidationError(value: unknown): ZodError | null { if (!containsNulByte(value)) return null diff --git a/apps/sim/lib/api/server/routes/types.ts b/apps/sim/lib/api/server/routes/types.ts index 8ccc310a557..90b9522dddd 100644 --- a/apps/sim/lib/api/server/routes/types.ts +++ b/apps/sim/lib/api/server/routes/types.ts @@ -53,9 +53,9 @@ export interface JsonRouteDefinition< * * That second argument exists for pagination: a `nextCursor` is stamped with * the sort and filters the page was read under, and those live in the query, - * not in the domain result. Threading them through the use case instead — - * which several lists used to do — makes an application service carry an HTTP - * cursor-encoding concern purely so the presenter can see it again. + * not in the domain result. Threading them through the use case instead would + * make an application service carry an HTTP cursor-encoding concern purely so + * the presenter can see it again. */ present( result: R, diff --git a/apps/sim/lib/api/server/routes/v2-binary-route.test.ts b/apps/sim/lib/api/server/routes/v2-binary-route.test.ts index 6bfc37e4d13..c72e36433f3 100644 --- a/apps/sim/lib/api/server/routes/v2-binary-route.test.ts +++ b/apps/sim/lib/api/server/routes/v2-binary-route.test.ts @@ -134,11 +134,11 @@ describe('defineV2BinaryRoute', () => { }) /** - * A download `HEAD` used to answer 200 from admission alone, so any valid API - * key could enumerate file ids across every workspace — the `GET` beside it - * answered 403. It now runs the use case's authorization phase and renders the - * refusal through the route's error policy, so the probe never says more than - * the download would. + * A download `HEAD` answered from admission alone lets any valid API key + * enumerate file ids across every workspace, while the `GET` beside it answers + * 403. Running the use case's authorization phase and rendering the refusal + * through the route's error policy keeps the probe from saying more than the + * download would. */ it('answers a denied HEAD with the status its GET would produce', async () => { const execute = vi.fn(async () => ({ bytes: 'payload' })) diff --git a/apps/sim/lib/api/server/routes/v2-binary-route.ts b/apps/sim/lib/api/server/routes/v2-binary-route.ts index 54be43a8a25..95fa88c4ef1 100644 --- a/apps/sim/lib/api/server/routes/v2-binary-route.ts +++ b/apps/sim/lib/api/server/routes/v2-binary-route.ts @@ -34,19 +34,10 @@ interface V2BinaryRouteOptions< rateLimit: V2RateLimitPolicy errorPolicy: V2ErrorPolicy /** - * Whether this route's `GET` is safe enough for Next's `HEAD`→`GET` aliasing - * to run it. Defaults to `true`, which is correct for a read. - * - * Set `false` when the `GET` opens an outbound connection or writes a row. A - * `HEAD` on such a route is admitted, parsed, and **authorized** exactly as - * the `GET` would be, then answered bodiless without running the use case's - * business phase — see {@link v2HeadNoEffect}. - * - * A binary `GET` is a download, and a download is the archetypal read that - * records that it happened, so this matters here at least as much as on the - * JSON builder it mirrors — including the part that made it a leak: a `HEAD` - * answered from admission alone confirmed a file id to a caller whose `GET` - * for the same id would have answered 404. + * As on {@link defineV2JsonRoute}, whose `headSafe` option carries the + * rationale; the bodiless answer is {@link v2HeadNoEffect}. A binary `GET` is + * a download — the archetypal read that records that it happened — so it is + * the common case here rather than the exception. */ headSafe?: boolean } diff --git a/apps/sim/lib/api/server/routes/v2-json-route.test.ts b/apps/sim/lib/api/server/routes/v2-json-route.test.ts index 309249b8eda..45dfd51000a 100644 --- a/apps/sim/lib/api/server/routes/v2-json-route.test.ts +++ b/apps/sim/lib/api/server/routes/v2-json-route.test.ts @@ -499,13 +499,12 @@ describe('defineV2JsonRoute', () => { * A `HEAD` on a route whose `GET` is not safe must answer the question the `GET` * would answer, minus the effect — not merely the question admission can answer. * - * The builder used to return {@link v2HeadNoEffect} straight after - * authenticate + rate-limit, so any valid API key drew a bodiless 200 for a - * denied principal kind, a nonexistent id, another tenant's workspace, and even - * a request missing a required param — while the `GET` beside it answered 403. - * That is an existence oracle: the probe reveals what the caller is not - * authorized to know. The fix runs the use case's authorization phase and stops - * before its business phase. + * Returning {@link v2HeadNoEffect} straight after authenticate + rate-limit is + * an existence oracle: any valid API key draws a bodiless 200 for a denied + * principal kind, a nonexistent id, another tenant's workspace, and even a + * request missing a required param, while the `GET` beside it answers 403. These + * pin the builder to running the authorization phase and stopping before the + * business phase. */ describe('defineV2JsonRoute HEAD on a route that is not head-safe', () => { const headContract = defineRouteContract({ diff --git a/apps/sim/lib/core/application/operation.ts b/apps/sim/lib/core/application/operation.ts index 7614fe6e7a3..c72ac2e71e4 100644 --- a/apps/sim/lib/core/application/operation.ts +++ b/apps/sim/lib/core/application/operation.ts @@ -21,13 +21,11 @@ export interface OperationUseCase { * It exists for one caller: a surface that must answer *"would this principal * be allowed?"* without causing what the answer would cause. `HEAD` on a route * whose `GET` is not safe is that surface — see the `headSafe` option on the - * v2 route builders. Answering such a probe from admission alone leaks an - * existence oracle, because admission only proves the caller holds *a* valid - * key, not that the key reaches *this* resource. + * v2 route builders for why answering it any earlier leaks an existence + * oracle. * - * Optional because most use cases have no such caller. The v2 builders reject - * a `headSafe: false` route whose use case omits it at definition time, so the - * gap is a boot failure rather than a silent 200. + * Optional because most use cases have no such caller; the v2 builders reject + * a `headSafe: false` route that omits it at definition time. */ authorize?(args: { principal: Principal diff --git a/apps/sim/lib/knowledge/application/knowledge-bases.ts b/apps/sim/lib/knowledge/application/knowledge-bases.ts index bd3de62faa1..88d80335a64 100644 --- a/apps/sim/lib/knowledge/application/knowledge-bases.ts +++ b/apps/sim/lib/knowledge/application/knowledge-bases.ts @@ -230,9 +230,8 @@ async function executeListKnowledgeBases(args: { }): Promise { /** * One index read serves both jobs: rendering each row's `folderPath` and - * resolving the caller's `folderPath` filter to an id. The list previously - * paid for a second, lock-taking read for the filter alone, which it needed - * only to raise the 404 this list no longer answers. + * resolving the caller's `folderPath` filter to an id, so the filter costs no + * second, lock-taking read of its own. */ const index = await loadActiveFolderPathIndex( args.context.workspaceId, diff --git a/apps/sim/lib/knowledge/application/upload-sessions.ts b/apps/sim/lib/knowledge/application/upload-sessions.ts index 5db83c64df7..748f8408838 100644 --- a/apps/sim/lib/knowledge/application/upload-sessions.ts +++ b/apps/sim/lib/knowledge/application/upload-sessions.ts @@ -398,10 +398,9 @@ interface PendingProcessingDispatch { * It runs after `completeUploadSession` resolves, and a failure is recorded * rather than raised, because by that point the caller's request has already * succeeded: the object is stored, the document row exists, and the session is - * marked completed. Raising here used to fail the completion `POST` with a 500 - * after all of that had committed, and the caller's only recovery — replaying - * the same request — answered `200 completed`, so the 500 described nothing the - * caller could act on. + * marked completed. A 500 raised after all of that has committed describes + * nothing the caller can act on — replaying the same request answers + * `200 completed`. * * The dispatch outcome is not lost by going unraised. `processDocumentsWithQueue` * marks the document `failed` with its error when processing itself breaks. When diff --git a/apps/sim/lib/logs/application/get-public-log.ts b/apps/sim/lib/logs/application/get-public-log.ts index d1cba6648f4..c16fa10dc3b 100644 --- a/apps/sim/lib/logs/application/get-public-log.ts +++ b/apps/sim/lib/logs/application/get-public-log.ts @@ -32,10 +32,9 @@ export interface GetPublicLogResult { * matching what the workflow resources report for the same workflow — or * `null` when no path can be resolved for it. * - * The two used to collapse into `null`, which made the field unreadable in - * both directions: a caller could not tell a root-level workflow from one - * whose folder had aged out, and `null` is not a value `folderPaths` would - * take back as a filter. + * The two must stay distinct: collapsing both into `null` leaves a caller + * unable to tell a root-level workflow from one whose folder aged out, and + * `null` is not a value `folderPaths` takes back as a filter. */ workflowFolderPath: string | null executionData: Record diff --git a/apps/sim/lib/logs/application/public-log-use-cases.test.ts b/apps/sim/lib/logs/application/public-log-use-cases.test.ts index 879afac86ea..f0584ff6169 100644 --- a/apps/sim/lib/logs/application/public-log-use-cases.test.ts +++ b/apps/sim/lib/logs/application/public-log-use-cases.test.ts @@ -170,10 +170,9 @@ describe('public log application use cases', () => { }) /** - * `null` used to stand for both "at the workspace root" and "the path could - * not be resolved", so a caller could tell neither apart nor feed the value - * back to `folderPaths`. The root is `/`, exactly as the workflow resources - * report it for the same workflow. + * `null` must not stand for both "at the workspace root" and "the path could + * not be resolved" — a caller can tell neither apart nor feed it back to + * `folderPaths`. The root is `/`, exactly as the workflow resources report it. */ it('reports the workspace root as a path a folderPaths filter would accept', async () => { mocks.getLog.mockResolvedValueOnce({ ...log, workflowFolderId: null }) @@ -325,11 +324,10 @@ describe('public log application use cases', () => { }) /** - * Every other `/logs` filter answers a value nothing matches with an empty - * page, and this one used to answer `404 Folder not found` — which also made - * the list a folder-existence oracle. The scope must still reach the query: - * dropping the unresolved path and sending no scope at all would return the - * whole workspace's logs. + * Every `/logs` filter answers a value nothing matches with an empty page; a + * `404 Folder not found` here would also make the list a folder-existence + * oracle. The scope must still reach the query: dropping the unresolved path + * and sending no scope would return the whole workspace's logs. */ it('returns an empty page for a folder path that matches nothing', async () => { const result = await listPublicLogs.execute({ diff --git a/apps/sim/lib/mcp/utils.test.ts b/apps/sim/lib/mcp/utils.test.ts index ae69134b50e..3fa3d61806c 100644 --- a/apps/sim/lib/mcp/utils.test.ts +++ b/apps/sim/lib/mcp/utils.test.ts @@ -330,10 +330,10 @@ describe('categorizeError', () => { }) /** - * The cooldown branch used to be selected by searching the message for - * `cooldown`, and `McpConnectionError` interpolates the server's display name - * into that message — so a server named after the word was reported as a - * transient 503 when its connection had genuinely failed. + * `McpConnectionError` interpolates the server's display name into its + * message, so selecting the cooldown branch by searching that message reports + * a server named after the word as a transient 503 when its connection has + * genuinely failed. */ it.concurrent('does not read a cooldown out of a server display name', () => { const error = new McpConnectionError('connect ECONNREFUSED', 'Cooldown Docs') diff --git a/apps/sim/lib/skills/application/operations.ts b/apps/sim/lib/skills/application/operations.ts index ccdcb4380a9..aa13c8f9a93 100644 --- a/apps/sim/lib/skills/application/operations.ts +++ b/apps/sim/lib/skills/application/operations.ts @@ -21,18 +21,14 @@ const HUMAN_PRINCIPAL_POLICY = { * not a policy flip — it needs an authorization model for a keyless principal * against per-skill editors, which does not exist. * - * `create` used to allow a workspace key on the reasoning that it is gated on - * workspace `write`, which a key can express. That reasoning held for the - * authorization check and broke everything after it. A workspace key that - * created a skill could never update or delete it, so its only possible - * interaction with the resource was to accumulate rows beyond its own reach — - * and the row it left behind was not even attributable to it: `create` - * attributes through `resolvePrincipalAttribution`, which maps a workspace key - * to the workspace's billing owner, so the write minted a `skill_member` editor - * grant for a human who did not act and who alone (with workspace admins) could - * then remove it. Denying `create` makes the lifecycle symmetric on the only - * consistent side available: the same per-skill editor model authorizes the - * whole of it. Pinned in `operations.test.ts`. + * `create` denies a workspace key too, even though workspace `write` is a role + * a key can express. A key that created a skill could never update or delete + * it, so it could only accumulate rows beyond its own reach — and the row would + * not be attributable to it either: `create` attributes through + * `resolvePrincipalAttribution`, which maps a workspace key to the workspace's + * billing owner, minting a `skill_member` editor grant for a human who did not + * act. Denying it keeps the whole lifecycle under one authorization model. + * Pinned in `operations.test.ts`. */ export const skillOperations = { list: defineWorkspaceOperation({ diff --git a/apps/sim/lib/table/__tests__/column-type-registry.test.ts b/apps/sim/lib/table/__tests__/column-type-registry.test.ts index 70e5e206a01..91ec369e31e 100644 --- a/apps/sim/lib/table/__tests__/column-type-registry.test.ts +++ b/apps/sim/lib/table/__tests__/column-type-registry.test.ts @@ -142,12 +142,11 @@ describe('intentional divergences from the pre-registry behavior', () => { }) it('refuses a number as a date, on the write path and the bulk gate alike', () => { - // The gate used to be stricter than `coerce` here: a whole numeric column - // reinterpreted as epoch milliseconds would turn 1, 5, 42 into three - // timestamps in January 1970. The write path had the same problem one value - // at a time — `1600000000` is September 2020 as seconds and January 1970 as - // milliseconds, both in range — so `coerce` now refuses a bare number too - // and the gate needs no override. + // A whole numeric column reinterpreted as epoch milliseconds turns 1, 5, 42 + // into three timestamps in January 1970, and one value at a time is no + // better — `1600000000` is September 2020 as seconds and January 1970 as + // milliseconds, both in range. `coerce` refuses a bare number, so the gate + // needs no override. const column: ColumnDefinition = { name: 'd', type: 'date' } for (const value of [0, 1, 42, 1700000000]) { expect(isValueCompatible(value, column)).toBe(false) diff --git a/apps/sim/lib/table/__tests__/sql.test.ts b/apps/sim/lib/table/__tests__/sql.test.ts index b9999efa4f3..56a2118438c 100644 --- a/apps/sim/lib/table/__tests__/sql.test.ts +++ b/apps/sim/lib/table/__tests__/sql.test.ts @@ -569,9 +569,8 @@ describe('SQL Builder', () => { * Multi-select `$ncontains` keeps null and absent cells, like every other * negation on the surface: `data` itself is never NULL, so containment is * FALSE — not NULL — for a missing key, and the negation is therefore TRUE. - * The published `TablePredicate` description used to call multi-select the - * one exception that excluded nulls; it never was, and the description now - * says so. + * Multi-select is not an exception that excludes nulls, and the published + * `TablePredicate` description says so. */ it('negates multiselect membership for $ncontains, keeping null and absent cells', () => { const out = render(buildFilterClause({ tags: { $ncontains: 'opt_a' } }, TABLE, [tagsCol])) diff --git a/apps/sim/lib/table/column-types/date.ts b/apps/sim/lib/table/column-types/date.ts index 60f3c0d5a2c..c78f4bbb082 100644 --- a/apps/sim/lib/table/column-types/date.ts +++ b/apps/sim/lib/table/column-types/date.ts @@ -30,13 +30,10 @@ export const dateColumnType: ColumnTypeDefinition = { // one input whose meaning cannot be recovered from the value itself: `1600000000` is // September 2020 read as Unix seconds and 19 January 1970 read as // milliseconds, both readings are in range, and nothing on the wire says - // which was meant. Milliseconds used to win, so a seconds-based epoch — - // the far more common shape — stored a timestamp 50 years early under a - // 200. An ISO-8601 string carries its own unit; that is what a date cell - // takes. This also removes the reason the bulk retype gate had to be - // stricter than the write path, so it no longer overrides. `salvage` keeps - // the old milliseconds reading for the machine paths, where the only other - // answer is a blank cell. + // which was meant — picking either silently stores a timestamp 50 years off + // under a 200. An ISO-8601 string carries its own unit; that is what a date + // cell takes. `salvage` keeps the milliseconds reading for the machine + // paths, where the only other answer is a blank cell. // // A Date instance may still be out of the representable range (>±8.64e15ms), // so `toISOString()` is guarded — it throws RangeError on an Invalid Date — diff --git a/apps/sim/lib/table/rows/cursor.ts b/apps/sim/lib/table/rows/cursor.ts index fea2890cefa..1350fbaa054 100644 --- a/apps/sim/lib/table/rows/cursor.ts +++ b/apps/sim/lib/table/rows/cursor.ts @@ -14,9 +14,7 @@ * This only resolves correctly because the seek admits `order_key IS NULL` * rows; a bare `(order_key, id) > (…)` excludes them and strands the tail. * - * Every shape is stamped with the filters its page was produced under, and any - * shape carrying an offset is additionally stamped with the sort that offset - * counts positions within. A token refuses to resume under a different one. See + * Every shape is stamped with the query it was produced under — see * {@link assertCursorQueryBinding}. */ @@ -45,10 +43,8 @@ type QueryBinding = { s?: string; p?: string } type CursorPayload = CursorBody & QueryBinding & { v: number } /** - * The query state a page was produced under. Every cursor shape is bound to the - * filters — a keyset position is absolute in `(order_key, id)` but not - * complete, so replaying it under different filters returns a page of the wrong - * sequence. Only a cursor carrying an offset is additionally bound to the sort. + * The query state a page was produced under. Every shape is bound to the + * filters; only a shape carrying an offset is additionally bound to the sort. */ export interface CursorQueryScope { sort?: Sort | null @@ -85,21 +81,13 @@ export function canonicalFilterKey( } /** - * A cursor is only valid for the exact query shape it was minted under: - * keyset/compound cursors encode a position in the DEFAULT `(order_key, id)` - * order, and an offset cursor from a sorted view encodes a position in THAT - * sort. Replaying either against a different ordering silently pages the wrong - * sequence — rows skipped or duplicated with no error. Throws - * `CURSOR_SORT_CONFLICT` so callers restart paging without the cursor. - * - * The filter binding applies to EVERY shape, not only the ones carrying an - * offset. An offset counts rows in the FILTERED sequence, so replaying it under - * a different predicate lands at that ordinal of a sequence the caller never - * asked for: a narrower filter silently returns an empty page the caller reads - * as "no more matches". A pure keyset cursor names an absolute position in - * `(order_key, id)`, but absolute is not complete — resumed under a wider - * filter it silently omits every newly matching row that sorts before it. Both - * mismatches throw `CURSOR_FILTER_CONFLICT`. + * A cursor is only valid for the exact query shape it was minted under — + * `lib/api/cursor-binding.ts` documents why. Here that means two distinct + * refusals: a keyset or compound cursor encodes a position in the DEFAULT + * `(order_key, id)` order and an offset cursor encodes a position in THAT sort, + * so an ordering mismatch throws `CURSOR_SORT_CONFLICT`; a filter mismatch + * throws `CURSOR_FILTER_CONFLICT` and applies to EVERY shape, since an absolute + * `(order_key, id)` position is still incomplete under a wider filter. */ export function assertCursorQueryBinding( decoded: { after?: TableRowsCursor; offset?: number; sortKey?: string; filterKey?: string }, diff --git a/apps/sim/lib/table/validation.test.ts b/apps/sim/lib/table/validation.test.ts index c6341b0012f..0616d3269ff 100644 --- a/apps/sim/lib/table/validation.test.ts +++ b/apps/sim/lib/table/validation.test.ts @@ -200,9 +200,8 @@ describe('coerceRowToSchema — uncoercible values under the `reject` policy', ( /** * A bare number cannot say whether it means seconds or milliseconds, and both - * readings land in range. Milliseconds used to win, so `1600000000` — a - * Unix-seconds timestamp for September 2020 — stored 19 January 1970 with a - * 200. + * readings land in range: guessing milliseconds stores `1600000000` — a + * Unix-seconds timestamp for September 2020 — as 19 January 1970 under a 200. */ it('refuses a bare epoch number rather than guessing its unit', () => { const data: RowData = { col_d: 1600000000 } diff --git a/apps/sim/lib/uploads/contexts/execution/utils.ts b/apps/sim/lib/uploads/contexts/execution/utils.ts index d64969c78eb..3cc4e1cf700 100644 --- a/apps/sim/lib/uploads/contexts/execution/utils.ts +++ b/apps/sim/lib/uploads/contexts/execution/utils.ts @@ -38,10 +38,7 @@ export function generateLargeValuePayloadKey(context: ExecutionContext, id: stri * loop), which the deterministic key would overwrite. The unique id is its own * path segment rather than a filename prefix so the last segment stays the * original name — presigned URLs carry no content-disposition, so that segment - * is what a consumer sees. It is still bounded by - * {@link buildStorageKeySegment}: a name past one path component's byte limit - * is `ENAMETOOLONG` on local storage, and an unreadable 500 beats a slightly - * shortened display name. + * is what a consumer sees. * * Large-value payloads, whose ids are already unique, keep using * {@link generateLargeValuePayloadKey}. diff --git a/apps/sim/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager.ts b/apps/sim/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager.ts index 3084fe9150a..e572dc2a7c7 100644 --- a/apps/sim/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager.ts @@ -4,14 +4,8 @@ import { buildStorageKeySegment } from '@/lib/uploads/core/storage-key' /** * Generate a canonical knowledge-base storage key. * - * Direct/presigned uploads previously used the generic `${context}/...` key - * shape (`knowledge-base/...`). New KB uploads should use the same `kb/...` - * prefix as server-side uploads so key-derived context inference is consistent. - * - * The uniquifier shares a path component with the name, so - * {@link buildStorageKeySegment} reserves it out of that component's byte - * budget: a document uploaded over multipart carries an unbounded filename, and - * a long one otherwise produced an `ENAMETOOLONG` 500 from local storage. + * Shares the `kb/...` prefix with server-side uploads so key-derived context + * inference is consistent. */ export function generateKnowledgeBaseFileKey(fileName: string): string { const timestamp = Date.now() diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index ae1a45cc9bf..c486e837388 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -202,11 +202,6 @@ export function parseWorkspaceFileKey(key: string): string | null { /** * Generate workspace-scoped storage key with explicit prefix * Format: workspace/{workspaceId}/{timestamp}-{random}-{filename} - * - * The name shares its path component with the uniquifier, so - * {@link buildStorageKeySegment} reserves that prefix out of the component's - * byte budget — otherwise the effective name limit is smaller than the 255 the - * file contracts advertise. */ export function generateWorkspaceFileKey(workspaceId: string, fileName: string): string { const timestamp = Date.now() diff --git a/apps/sim/lib/uploads/core/storage-key.ts b/apps/sim/lib/uploads/core/storage-key.ts index efc66906937..4af3be2918d 100644 --- a/apps/sim/lib/uploads/core/storage-key.ts +++ b/apps/sim/lib/uploads/core/storage-key.ts @@ -1,11 +1,6 @@ import { sanitizeFileName } from '@/executor/constants' -/** - * POSIX `NAME_MAX`. It bounds one *path component*, not the whole key, and it - * counts bytes. Local storage writes a key straight into the upload directory, - * so a key whose last component crosses this throws `ENAMETOOLONG` out of - * `writeFile` — an unclassifiable 500 on a name the contract already accepted. - */ +/** POSIX `NAME_MAX`: bytes in one *path component*, not in the whole key. */ const MAX_STORAGE_KEY_SEGMENT_BYTES = 255 /** Sidecar attached to local objects promoted through the upload-session transport. */ @@ -26,17 +21,10 @@ export const LOCAL_STAGING_ROOT = '.staging' /** * Every suffix local storage appends to a stored object's own path component. * - * The key's last component is not the only component derived from a file name. - * Local storage writes siblings named after the object plus a fixed suffix, and - * `NAME_MAX` bounds those siblings too — so the budget a name may spend is - * `255 − the longest suffix`, not 255. Reserving that here is what makes the - * reservation survive a second sidecar: adding an entry to this list shrinks - * every key builder's budget at once, while a suffix invented at the write site - * silently reopens the overflow this module exists to close. - * - * Transient artifacts are deliberately absent. The local upload provider stages - * them under a path derived from the upload id alone, so no temporary name - * inherits the file name's length and none needs a reservation here. + * `NAME_MAX` bounds those siblings too, so adding an entry here shrinks every + * key builder's budget at once — while a suffix invented at the write site + * silently reopens the overflow this module exists to close. Transient staging + * artifacts need no entry: they are named from the upload id alone. * * Every entry is ASCII, so `length` is its byte count. */ @@ -84,29 +72,23 @@ function fitStorageKeyName(safeName: string, budget: number): string { /** * Builds the last component of a storage key from a caller-supplied file name. * - * The defect this exists to remove: every key generator embedded the file name - * in a component it also prefixed with a timestamp and a random uniquifier, so - * the *effective* name limit was `255 − prefix`, not the 255 the contract - * advertises. A 225-character name — well inside `maxLength: 255` — produced a - * 256-byte component and a 500, while 256 characters was correctly a 400. The - * upload-session path was worse: admission accepted the name, handed back a - * transfer URL, and every later request against that session failed. + * A name shares its path component with a uniquifier prefix, so the *effective* + * limit is `NAME_MAX − prefix` rather than the 255 the file contracts + * advertise. Local storage writes the key straight into the upload directory, + * so a component past that throws `ENAMETOOLONG` out of `writeFile` — an + * unclassifiable 500 on a name the contract already accepted, and on the + * upload-session path a session whose every later request fails. * - * Fixing it by shrinking the declared `maxLength` would make each caller's limit - * a function of its own key prefix and would break names that already store - * fine on S3 and GCS, which have no per-component limit. So the budget is - * reserved here instead: the key is made independent of the name's length, the - * declared limit stays honest, and no name a contract admits can produce a key - * a store rejects. The name in a key is a debugging convenience — the row's - * `originalName` is the identity — so truncating it costs nothing. + * Reserving the budget here rather than shrinking the declared `maxLength` + * keeps each caller's limit off its own key prefix and keeps working the names + * that store fine on S3 and GCS, which have no per-component limit. The name in + * a key is a debugging convenience — the row's `originalName` is the identity — + * so truncating it costs nothing. * - * The budget is {@link MAX_STORAGE_KEY_NAME_BYTES}, not `NAME_MAX` itself: local - * storage stores sidecars beside the object under the object's own name, and a + * The budget is {@link MAX_STORAGE_KEY_NAME_BYTES}, not `NAME_MAX` itself: a * component that fills `NAME_MAX` exactly leaves its sidecar nowhere to go. * - * @param prefix Fixed leading text of the component (uniquifier, timestamp). - * Must itself leave room for at least one character of the name. - * @param fileName Raw caller-supplied name; sanitized here. + * @param prefix Must itself leave room for at least one character of the name. */ export function buildStorageKeySegment(prefix: string, fileName: string): string { const budget = MAX_STORAGE_KEY_NAME_BYTES - prefix.length diff --git a/apps/sim/lib/uploads/upload-session/cleanup.test.ts b/apps/sim/lib/uploads/upload-session/cleanup.test.ts index c9f9d534777..823e299e58c 100644 --- a/apps/sim/lib/uploads/upload-session/cleanup.test.ts +++ b/apps/sim/lib/uploads/upload-session/cleanup.test.ts @@ -39,8 +39,8 @@ describe('local upload artifact cleanup', () => { }) // A PUT or multipart assembly that dies mid-write leaves a staged object - // behind. Staged artifacts used to be written next to their destination, - // outside every sweep root, so nothing ever reclaimed them. + // behind, so staging lives under a sweep root rather than beside its + // destination. it('reclaims abandoned staged objects', async () => { const now = Date.UTC(2026, 7, 4, 12) await createStagedObject('abandoned.tmp', now - LOCAL_UPLOAD_ARTIFACT_TTL_MS - 1) diff --git a/apps/sim/lib/uploads/upload-session/provider.test.ts b/apps/sim/lib/uploads/upload-session/provider.test.ts index 40c61ccc4d9..b0de0a03ac7 100644 --- a/apps/sim/lib/uploads/upload-session/provider.test.ts +++ b/apps/sim/lib/uploads/upload-session/provider.test.ts @@ -283,10 +283,10 @@ describe('local upload-session provider', () => { await expect(stat(localPath('.multipart/upload-1'))).rejects.toMatchObject({ code: 'ENOENT' }) }) - // The staged object and its sidecar used to be named after the destination, so - // a key the contract's longest name produces overflowed `NAME_MAX` and the - // whole session became unusable: `POST /uploads` issued a transfer URL, the - // PUT against it 500'd, and `complete` then reported the object missing. + // A staged object named after its destination overflows `NAME_MAX` at the + // contract's longest name, and the whole session becomes unusable: the + // transfer URL is issued, the PUT against it 500s, and `complete` then reports + // the object missing. it('stores a PUT under the longest key the name contract can produce', async () => { await writeLocalPutObject({ uploadId: '11111111-1111-4111-8111-111111111111', diff --git a/packages/db/timestamps.ts b/packages/db/timestamps.ts index d62ba7de2fb..8d32419faee 100644 --- a/packages/db/timestamps.ts +++ b/packages/db/timestamps.ts @@ -1,47 +1,20 @@ /** * UTC pinning for `timestamp without time zone` columns. * - * Every timestamp column in `schema.ts` is declared as bare `timestamp(...)`, - * which is Postgres `timestamp without time zone`: the column stores a naive - * wall-clock reading with no offset, so the instant it denotes is decided - * entirely by whoever writes it and whoever reads it. Nothing in the type - * system pins that decision, and the three writers in this codebase did not - * agree: + * Every timestamp column in `schema.ts` is bare `timestamp(...)`, which is + * Postgres `timestamp without time zone`: the column stores a naive wall-clock + * reading with no offset, so the instant it denotes is decided entirely by + * whoever writes it and whoever reads it. Writers and readers disagreed — + * `now()` and a raw `Date` bind render in the **session's** `TimeZone` while + * drizzle's `toISOString()` always stores UTC, and postgres.js parses oid 1114 + * in the **Node process's** local zone while drizzle parses it as UTC. The + * result is a local wall clock serialized with `toISOString()`: a `Z`-labelled + * string naming the wrong instant, which passes every `date-time` format check + * and silently corrupts sorts and range predicates. * - * - `defaultNow()` / `now()` — Postgres renders the current instant in the - * **session's** `TimeZone`, so the stored wall clock is UTC only when the - * session happens to be UTC. - * - drizzle-mapped writes (`updatedAt: new Date()`) — `PgTimestamp`'s - * `mapToDriverValue` is `value.toISOString()`, and Postgres **discards** the - * trailing `Z` when parsing into a naive column, so the stored wall clock is - * always UTC. - * - a raw `Date` bound through postgres.js — inferred as `timestamptz` (oid - * 1184) and cast down to the column type in the **session's** `TimeZone`. - * - * The read side disagreed the same way: postgres.js parses oid 1114 with - * `new Date(x)`, and `new Date('2026-08-13 02:44:03.42')` is interpreted in the - * **Node process's** local zone, while a value postgres.js hands back as a - * string is interpreted as UTC by drizzle (`value + '+0000'`). So the same - * column read through two paths yielded instants an offset apart. - * - * The compound effect is a timestamp that is a *local* wall clock serialized - * with `toISOString()` — a `Z`-labelled string naming the wrong instant. It - * passes every `date-time` format check, so it is silently wrong: it corrupts - * any sort or range predicate over the field, and can place an `updatedAt` - * before its own `createdAt`. - * - * This module removes the ambiguity at the driver boundary rather than at the - * call sites, so no future writer can reintroduce it: - * - * - {@link UTC_CONNECTION_PARAMETERS} forces every session's `TimeZone` to - * `UTC`, so all three write paths store the same wall clock — UTC. - * - {@link UTC_TIMESTAMP_TYPES} parses oid 1114 as UTC regardless of the Node - * process's zone, so every read path recovers that instant exactly. - * - * Together they make naive-timestamp round-trips independent of both the - * database session zone and the process zone. Production already runs both in - * UTC, so this changes nothing there and makes every other environment behave - * the way production does. + * Pinned at the driver boundary rather than at the call sites, so no future + * writer can reintroduce it: {@link UTC_CONNECTION_PARAMETERS} forces every + * session's `TimeZone`, and {@link UTC_TIMESTAMP_TYPES} pins the read. * * `timestamptz` columns (oid 1184) are deliberately untouched: they already * carry an offset on the wire and round-trip correctly on their own. @@ -51,32 +24,26 @@ const TIMESTAMP_OID = 1114 /** - * postgres.js startup parameters that pin the session's `TimeZone`. - * - * Applied to every client so `now()` and any `timestamptz → timestamp` cast - * render UTC wall clocks, matching what drizzle's `toISOString()` write already - * stores. + * postgres.js startup parameters that pin the session's `TimeZone`, so `now()` + * and any `timestamptz → timestamp` cast render the UTC wall clock drizzle's + * `toISOString()` write already stores. */ export const UTC_CONNECTION_PARAMETERS = { TimeZone: 'UTC' } as const /** * postgres.js `types` entry that reads and writes oid 1114 as UTC. * - * `parse` appends the explicit `Z` that the naive wire form omits, which is what - * makes the recovered instant independent of the process's local zone. `to` is - * never selected by postgres.js's type inference (a `Date` infers as 1184), so - * the serializer exists only to keep the entry self-consistent for an explicit - * `sql.typed` bind. + * `parse` appends the explicit `Z` that the naive wire form omits, making the + * recovered instant independent of the process's local zone. `to` is never + * selected by postgres.js's type inference (a `Date` infers as 1184), so the + * serializer only keeps the entry self-consistent for an explicit `sql.typed` + * bind. * - * It does not decide the instant for a drizzle read. `drizzle()` registers its - * own transparent parser over oid 1114 when it wraps a client, replacing this - * entry, and every client in this repo is wrapped — so on those paths a naive - * value arrives at drizzle as the raw wire string and `PgTimestamp`'s mapper - * (`new Date(value + '+0000')`) supplies the UTC reading instead. Both routes - * yield the same instant, which is why the clobbering is harmless rather than a - * defect. The entry is kept because it is the only thing pinning the read for a - * client used as raw postgres.js, and `timestamps.test.ts` asserts the - * composition of both layers rather than the registration alone. + * `drizzle()` registers its own oid-1114 parser when it wraps a client, + * replacing this entry, and every client here is wrapped — drizzle's own mapper + * (`new Date(value + '+0000')`) then supplies the same UTC reading, so the + * clobbering is harmless. The entry is kept because it is the only thing pinning + * the read for a client used as raw postgres.js. */ export const UTC_TIMESTAMP_TYPES = { utcTimestamp: { @@ -98,8 +65,7 @@ interface PostgresConnectionOptions { * Every client is built through this rather than spreading the two constants by * hand, because `connection` is a nested object: a client that sets its own * `application_name` replaces the whole sub-object and would silently drop the - * session `TimeZone`. Merging in one place is what makes "a new pool is - * UTC-correct" true by construction instead of by review. + * session `TimeZone`. */ export function withUtcTimestamps(options: T) { return { diff --git a/packages/utils/src/string.ts b/packages/utils/src/string.ts index 63c318271f9..38a7388cfbd 100644 --- a/packages/utils/src/string.ts +++ b/packages/utils/src/string.ts @@ -2,9 +2,15 @@ * `U+0000` is the one code point a Postgres `text`/`jsonb` value cannot carry: * the wire protocol terminates strings on it, so the driver throws before the * statement is planned, and the throw carries no SQLSTATE a route layer can - * classify. Every boundary that admits caller-supplied text — the JSON request - * scan, the multipart field scan, and the canonical folder-path decoder — - * rejects it, so the predicate lives here instead of being restated at each. + * classify — it reaches the caller as a 500, on reads as readily as on writes. + * Every boundary that admits caller-supplied text rejects it through + * {@link containsNulCharacter}: the JSON request scan, the multipart field + * scan, and the canonical folder-path decoder. + * + * Deliberately only NUL. `\n`, `\t`, and `\r` are ordinary content Postgres + * stores verbatim, and a lone surrogate is substituted with `U+FFFD` by the + * driver's encoder rather than throwing — a fidelity question, not an + * availability one. */ const NUL_CHARACTER = '\u0000' From 8ee59557dca0d360f3f2c60a73a6d3b696386c1a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 13 Aug 2026 09:13:21 -0700 Subject: [PATCH 51/56] fix(v2): name the undecodable-cursor failure on the two sortless lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /workflows/{id}/versions and GET /workspaces/{id}/members threw a bare 'Invalid cursor' literal where every other v2 list uses a shared constant. The right one is UNREADABLE_CURSOR_MESSAGE, not INVALID_CURSOR_MESSAGE: both lists take only limit and cursor, so naming sortBy/sortOrder would answer one 400 with advice that earns a second. Their missing filter scope is correct and stays. Neither contract accepts a filter — v2PaginationFields is the whole query — so there is nothing to bind, and limit is excluded from a scope by design. Pins the message on the versions route, verified to fail against the literal. --- apps/sim/app/api/v2/workflows/[id]/versions/route.test.ts | 7 +++++++ apps/sim/app/api/v2/workflows/[id]/versions/route.ts | 3 ++- .../app/api/v2/workspaces/[workspaceId]/members/route.ts | 3 ++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/versions/route.test.ts index 5725141e3c3..c6cc6e65974 100644 --- a/apps/sim/app/api/v2/workflows/[id]/versions/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/versions/route.test.ts @@ -12,6 +12,7 @@ import { } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { UNREADABLE_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' const mocks = vi.hoisted(() => ({ listVersions: vi.fn(), @@ -103,6 +104,12 @@ describe('GET /api/v2/workflows/[id]/versions', () => { expect(response.status).toBe(400) expect(mocks.listVersions).not.toHaveBeenCalled() + /** + * The undecodable-token message, not the sort-mismatch one: this list + * declares no `sortBy`/`sortOrder`, so naming them would answer a 400 with + * advice that earns a second. + */ + expect((await response.json()).error.message).toBe(UNREADABLE_CURSOR_MESSAGE) }) /** diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/route.ts b/apps/sim/app/api/v2/workflows/[id]/versions/route.ts index b33e3dada11..f0c8088af85 100644 --- a/apps/sim/app/api/v2/workflows/[id]/versions/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/versions/route.ts @@ -3,6 +3,7 @@ import { v2ListWorkflowVersionsContract, v2WorkflowVersionCursorSchema, } from '@/lib/api/contracts/v2/workflows' +import { UNREADABLE_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { OrchestrationError } from '@/lib/core/orchestration/types' import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' @@ -24,7 +25,7 @@ export const GET = defineV2JsonRoute({ ? v2WorkflowVersionCursorSchema.safeParse(decodeCursor(query.cursor)) : undefined if (decoded && !decoded.success) { - throw new OrchestrationError('validation', 'Invalid cursor') + throw new OrchestrationError('validation', UNREADABLE_CURSOR_MESSAGE) } return { workflowId: params.id, diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/members/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/members/route.ts index b07898ffdb1..332a08c6456 100644 --- a/apps/sim/app/api/v2/workspaces/[workspaceId]/members/route.ts +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/members/route.ts @@ -2,6 +2,7 @@ import { v2ListWorkspaceMembersContract, v2WorkspaceMemberCursorSchema, } from '@/lib/api/contracts/v2/workspaces' +import { UNREADABLE_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' import { defineV2JsonRoute, v2ApiKeyAuth, @@ -25,7 +26,7 @@ export const GET = defineV2JsonRoute({ ? v2WorkspaceMemberCursorSchema.safeParse(decodeCursor(query.cursor)) : undefined if (decoded && !decoded.success) { - throw new OrchestrationError('validation', 'Invalid cursor') + throw new OrchestrationError('validation', UNREADABLE_CURSOR_MESSAGE) } return { workspaceId: params.workspaceId, From 9f693e172853fd2e0c4a564f265c39cc2fb2c666 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 13 Aug 2026 09:19:43 -0700 Subject: [PATCH 52/56] test(openapi): give the determinism check a chosen timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `serializes all documents deterministically` serializes all seven published documents twice — roughly 2MB of JSON — under vitest's 5s default, which is not a budget anyone picked for it. The published specs grew 3.3% on this branch (961KB -> 993KB) from richer descriptions, which is far too small to move a comfortable test and is enough to tip one already sitting just under the cap. Measured at 5.1s in isolation with nothing else running. Raises it to 30s for the openapi suite rather than trimming a real assertion. --- scripts/openapi/vitest.config.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/scripts/openapi/vitest.config.ts b/scripts/openapi/vitest.config.ts index be5f25b5d47..2cfe8039d62 100644 --- a/scripts/openapi/vitest.config.ts +++ b/scripts/openapi/vitest.config.ts @@ -12,5 +12,12 @@ export default defineConfig({ test: { environment: 'node', include: ['scripts/openapi/**/*.test.ts'], + /** + * The determinism check serializes all seven published documents twice — + * roughly 2MB of JSON — so it was running against vitest's 5s default + * rather than a budget anyone chose. It already sat just under that on + * staging, and this branch's richer descriptions tip it over. + */ + testTimeout: 30_000, }, }) From 8616b6b8d6d6cbc4a15c8d81156d9d4d05e66f4c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 13 Aug 2026 09:35:27 -0700 Subject: [PATCH 53/56] fix(v2): make the NUL path scan linear, and force a write surface to choose Two findings from a simplify pass, both in code this branch added. findNulBytePath copied `[...path, key]` per child, which is O(nodes x depth). A caller controls that depth directly: v2 row cell values are `z.unknown()`, so nesting passes Zod untouched and reaches the scan. Measured on Node 22 -- JSON.parse accepts a 200KB body nested 100k deep in 9.8ms, and the scan then blocked the event loop for 27.7s. Frames now carry a parent link and the path is materialized once, for the node actually reported: 27.7s -> 5ms, with byte-identical paths across nested arrays, records, NUL keys and clean input. The always-run first pass drops Object.entries for Object.keys, which halves its cost on large bodies by not allocating a pair array per object. `strictWrite` was optional with the lenient default, so a v2 write route added tomorrow would silently inherit first-party behavior -- unknown column dropped under a 201, uncoercible cell stored as null -- defended by nothing but five copies of a literal. It is now required on the five write-shaped inputs, so omission is a compile error. The type-checker named every caller: the five v2 routes already passed true, and the three Copilot sites now say false explicitly, which is the behavior they already had. --- apps/sim/lib/api/server/nul-bytes.ts | 49 +++++++++++++------ .../copilot/tools/server/table/user-table.ts | 3 ++ apps/sim/lib/table/application/rows.ts | 15 +++++- 3 files changed, 51 insertions(+), 16 deletions(-) diff --git a/apps/sim/lib/api/server/nul-bytes.ts b/apps/sim/lib/api/server/nul-bytes.ts index 0499d6438bc..2ed60a1878f 100644 --- a/apps/sim/lib/api/server/nul-bytes.ts +++ b/apps/sim/lib/api/server/nul-bytes.ts @@ -21,42 +21,63 @@ function containsNulByte(root: unknown): boolean { continue } if (isPlainRecord(value)) { - for (const [key, entry] of Object.entries(value)) { + for (const key of Object.keys(value)) { if (containsNulCharacter(key)) return true - stack.push(entry) + stack.push(value[key]) } } } return false } +/** A visited node, linked to its parent so a path is only ever built on a hit. */ +interface NulScanFrame { + value: unknown + key: PropertyKey | null + parent: NulScanFrame | null +} + +/** Walks parent links back to the root. Runs once, only for the offending node. */ +function framePath(frame: NulScanFrame): PropertyKey[] { + const path: PropertyKey[] = [] + for (let node: NulScanFrame | null = frame; node?.parent; node = node.parent) { + if (node.key !== null) path.push(node.key) + } + return path.reverse() +} + /** - * Second pass, run only once a NUL is known to be present, so the common case - * never pays for path bookkeeping. Returns the path of the first offending - * string, matching the shape Zod reports for a failed field. + * Second pass, run only once a NUL is known to be present. Returns the path of + * the first offending string, matching the shape Zod reports for a failed field. + * + * Frames carry a parent link rather than a copied path. Copying `[...path, key]` + * per child costs O(nodes x depth), which a caller controls directly: v2 row + * cell values are `z.unknown()`, so a 200KB body of nested arrays reaches this + * scan at depth 100k and blocked the event loop for ~28s. Parent links make it + * linear, and the path is materialized once for the node actually reported. */ function findNulBytePath(root: unknown): PropertyKey[] { - const stack: { value: unknown; path: PropertyKey[] }[] = [{ value: root, path: [] }] + const stack: NulScanFrame[] = [{ value: root, key: null, parent: null }] while (stack.length > 0) { const frame = stack.pop() if (!frame) break - const { value, path } = frame + const { value } = frame if (typeof value === 'string') { - if (containsNulCharacter(value)) return path + if (containsNulCharacter(value)) return framePath(frame) continue } if (Array.isArray(value)) { for (let index = value.length - 1; index >= 0; index -= 1) { - stack.push({ value: value[index], path: [...path, index] }) + stack.push({ value: value[index], key: index, parent: frame }) } continue } if (isPlainRecord(value)) { - const entries = Object.entries(value) - for (let index = entries.length - 1; index >= 0; index -= 1) { - const [key, entry] = entries[index] - if (containsNulCharacter(key)) return [...path, key] - stack.push({ value: entry, path: [...path, key] }) + const keys = Object.keys(value) + for (let index = keys.length - 1; index >= 0; index -= 1) { + const key = keys[index] + if (containsNulCharacter(key)) return [...framePath(frame), key] + stack.push({ value: value[key], key, parent: frame }) } } } diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.ts b/apps/sim/lib/copilot/tools/server/table/user-table.ts index aa03686ccba..da3fee1d8ce 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -285,6 +285,7 @@ export const userTableServerTool: BaseServerTool kind: 'single', tableId: args.tableId, assertedWorkspaceId: workspaceId, + strictWrite: false, data: args.data, position: args.position as number | undefined, secretProvenance: createExactEmptyTableRowSecretProvenance(args.data), @@ -327,6 +328,7 @@ export const userTableServerTool: BaseServerTool kind: 'batch', tableId: args.tableId, assertedWorkspaceId: workspaceId, + strictWrite: false, rows: sourceRows, secretProvenance: sourceRows.map(createExactEmptyTableRowSecretProvenance), }, @@ -473,6 +475,7 @@ export const userTableServerTool: BaseServerTool { tableId: args.tableId, assertedWorkspaceId: workspaceId, + strictWrite: false, rowId: args.rowId, data: args.data, secretProvenance: createExactEmptyTableRowSecretProvenance(args.data), diff --git a/apps/sim/lib/table/application/rows.ts b/apps/sim/lib/table/application/rows.ts index 28de2afdf05..5185c9b0c52 100644 --- a/apps/sim/lib/table/application/rows.ts +++ b/apps/sim/lib/table/application/rows.ts @@ -92,11 +92,10 @@ interface TableScopedInput { * the Copilot table tools, and the executor's Table block all drop the * unknown key and blank the uncoercible cell. Read-only use cases ignore it. */ - strictWrite?: boolean } /** The write policy `strictWrite` selects, for the row-service primitives. */ -function rowWriteOptions(input: TableScopedInput): RowWriteOptions { +function rowWriteOptions(input: { strictWrite: boolean }): RowWriteOptions { return input.strictWrite ? { uncoercibleValues: 'reject' } : {} } @@ -419,6 +418,8 @@ export const readTableRow = defineAuthorizedTableUseCase({ }) interface CreateSingleTableRowInput extends TableScopedInput { + /** See {@link rowWriteOptions}. Required so a new write surface must choose. */ + strictWrite: boolean kind: 'single' data: RowData position?: number @@ -428,6 +429,8 @@ interface CreateSingleTableRowInput extends TableScopedInput { } interface CreateBatchTableRowsInput extends TableScopedInput { + /** See {@link rowWriteOptions}. Required so a new write surface must choose. */ + strictWrite: boolean kind: 'batch' rows: RowData[] orderKeys?: string[] @@ -527,6 +530,8 @@ export const createTableRows = defineAuthorizedTableUseCase({ const MAX_REPLACE_TABLE_ROWS = 10_000 export interface ReplaceTableRowsInput extends TableScopedInput { + /** See {@link rowWriteOptions}. Required so a new write surface must choose. */ + strictWrite: boolean rows: RowData[] secretProvenance?: Array } @@ -735,6 +740,8 @@ export const replaceProjectedWireRows = defineAuthorizedTableUseCase({ }) export interface UpdateTableRowInput extends TableScopedInput { + /** See {@link rowWriteOptions}. Required so a new write surface must choose. */ + strictWrite: boolean rowId: string data: RowData secretProvenance?: TableRowSecretProvenanceWrite @@ -784,6 +791,8 @@ export const updateTableRow = defineAuthorizedTableUseCase({ }) export interface UpdateTableRowsInput extends TableScopedInput { + /** See {@link rowWriteOptions}. Required so a new write surface must choose. */ + strictWrite: boolean filter: TablePredicate data: RowData limit?: number @@ -907,6 +916,8 @@ export const deleteTableRows = defineAuthorizedTableUseCase({ }) export interface UpsertTableRowInput extends TableScopedInput { + /** See {@link rowWriteOptions}. Required so a new write surface must choose. */ + strictWrite: boolean data: RowData conflictTarget?: string secretProvenance?: TableRowSecretProvenanceWrite From b71dbd1051bd9ac1d3ace05ccd9ee9144d0882ad Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 13 Aug 2026 09:37:31 -0700 Subject: [PATCH 54/56] refactor(v2): apply the body-413 mapper to every OpenAPI document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier unification wired withRequestBodyErrors into two of the five content documents and left files-audit, knowledge and workflows hand-writing the entry, so the helper's own claim that "a new body route cannot forget it" held on 40% of the surface while reading as global. Regenerating all seven specs produces zero drift, which is the useful proof: the mapper agrees with every hand-written entry today, so the gap was never a missing 413 — it was a missing guarantee for the next body route added to those three documents. The existing hand-written entries stay. The mapper is one-directional and several bodyless folder reads publish 413 for the folder-tree ceiling, so stripping them by hand would risk removing one the mapper cannot restore. --- apps/sim/lib/api/contracts/v2/openapi/files-audit.ts | 5 ++++- apps/sim/lib/api/contracts/v2/openapi/knowledge.ts | 5 ++++- apps/sim/lib/api/contracts/v2/openapi/workflows.ts | 5 ++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts b/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts index 74c1a75f311..32074b7ed7e 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts @@ -39,6 +39,7 @@ import { V2_ERROR_SCHEMA, WORKSPACE_API_KEY_DENIED, WORKSPACE_ERRORS, + withRequestBodyErrors, } from '@/lib/api/contracts/v2/openapi/shared' import { defineOpenApiDocument, @@ -118,7 +119,7 @@ function auditOperation( } } -const routes = [ +const declaredRoutes = [ defineOpenApiRoute( v2ListFilesContract, filesOperation({ @@ -840,6 +841,8 @@ const routes = [ ), ] as const +const routes = declaredRoutes.map(withRequestBodyErrors) + export const filesAuditOpenApiDocument = defineOpenApiDocument({ output: 'apps/docs/openapi-v2-files-audit.json', info: { diff --git a/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts b/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts index 0abc48004d4..fa8e2e06453 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts @@ -37,6 +37,7 @@ import { V2_ERROR_SCHEMA, WORKSPACE_API_KEY_DENIED, WORKSPACE_ERRORS, + withRequestBodyErrors, } from '@/lib/api/contracts/v2/openapi/shared' import { defineOpenApiDocument, @@ -64,7 +65,7 @@ function knowledgeOperation( } } -const routes = [ +const declaredRoutes = [ defineOpenApiRoute( v2ListKnowledgeBasesContract, knowledgeOperation({ @@ -749,6 +750,8 @@ const routes = [ ), ] as const +const routes = declaredRoutes.map(withRequestBodyErrors) + export const knowledgeOpenApiDocument = defineOpenApiDocument({ output: 'apps/docs/openapi-v2-knowledge.json', info: { diff --git a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts index 14636710ccd..ef62bb6fcdf 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts @@ -16,6 +16,7 @@ import { V2_ERROR_SCHEMA, WORKSPACE_API_KEY_DENIED, WORKSPACE_ERRORS, + withRequestBodyErrors, } from '@/lib/api/contracts/v2/openapi/shared' import { EXECUTE_OPTION_CONSTRAINTS, @@ -158,7 +159,7 @@ const resumeQueuedResponseSchema = documentedSchema( [QUEUED_RUN_EXAMPLE] ) -const routes = [ +const declaredRoutes = [ defineOpenApiRoute( v2ListWorkflowsContract, workflowOperation({ @@ -860,6 +861,8 @@ const routes = [ ), ] as const +const routes = declaredRoutes.map(withRequestBodyErrors) + export const workflowsOpenApiDocument = defineOpenApiDocument({ output: 'apps/docs/openapi-v2-workflows.json', info: { From 2ed4770e0fec484d4c9646f65ba4f22ec518b7e9 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 13 Aug 2026 09:43:54 -0700 Subject: [PATCH 55/56] refactor(v2): fold the v2 validation renderer into the shared parse defaults V2_PARSE_DEFAULTS calls itself "the parse failures every v2 route renders the same way", but the option deciding how a v2 validation failure renders sat outside it and was re-stated at seven sites. A raw route that spread the defaults and stopped emitted a non-v2 error envelope. Removes the redundant line from the five sites that only restated it. The two builders keep theirs: theirs sits after `...options.parseOptions`, so it is a deliberate override that stops a caller swapping the v2 renderer, not a copy. Also adopts the mandated `filterUndefined` in cursorScopeKey in place of the Object.fromEntries/Object.entries form CLAUDE.md forbids, and collapses a one-element `as const` array plus a Math.max over it to the single `.length` they computed. --- .../api/v2/uploads/[uploadId]/parts/[partNumber]/route.ts | 1 - apps/sim/app/api/v2/uploads/[uploadId]/route.ts | 1 - apps/sim/app/api/v2/workflows/[id]/execute/route.ts | 3 +-- .../app/api/v2/workflows/[id]/runs/[runId]/resume/route.ts | 3 +-- apps/sim/lib/api/cursor-binding.ts | 7 ++++--- apps/sim/lib/api/server/routes/v2-binary-route.ts | 3 +-- apps/sim/lib/api/server/routes/v2-json-route.ts | 1 + apps/sim/lib/uploads/core/storage-key.ts | 6 +----- 8 files changed, 9 insertions(+), 16 deletions(-) diff --git a/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.ts b/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.ts index c433fe868ad..91f03f278b7 100644 --- a/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.ts +++ b/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.ts @@ -48,7 +48,6 @@ export const PUT = withRouteHandler( } const parsed = await parseRequest(localUploadPartContract, request, context, { ...V2_PARSE_DEFAULTS, - validationErrorResponse: v2ValidationError, }) if (!parsed.success) return parsed.response diff --git a/apps/sim/app/api/v2/uploads/[uploadId]/route.ts b/apps/sim/app/api/v2/uploads/[uploadId]/route.ts index 2e8c819b373..d53034de60f 100644 --- a/apps/sim/app/api/v2/uploads/[uploadId]/route.ts +++ b/apps/sim/app/api/v2/uploads/[uploadId]/route.ts @@ -38,7 +38,6 @@ export const PUT = withRouteHandler( async (request: NextRequest, context: LocalPutRouteParams): Promise => { const parsed = await parseRequest(localPutUploadContract, request, context, { ...V2_PARSE_DEFAULTS, - validationErrorResponse: v2ValidationError, }) if (!parsed.success) return parsed.response diff --git a/apps/sim/app/api/v2/workflows/[id]/execute/route.ts b/apps/sim/app/api/v2/workflows/[id]/execute/route.ts index 8e44e5f35f5..59dea2ef5f9 100644 --- a/apps/sim/app/api/v2/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/execute/route.ts @@ -45,7 +45,7 @@ import { hasAgentStreamPolicy, } from '@/lib/workflows/streaming/agent-stream-protocol' import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { type V2ErrorCode, v2Data, v2Error, v2ValidationError } from '@/app/api/v2/lib/response' +import { type V2ErrorCode, v2Data, v2Error } from '@/app/api/v2/lib/response' import { PublicApiNotAllowedError, validatePublicApiAllowed, @@ -191,7 +191,6 @@ export const POST = withRouteHandler( const parsed = await parseRequest(v2ExecuteWorkflowContract, req, context, { ...V2_PARSE_DEFAULTS, maxBodyBytes: 10 * 1024 * 1024, - validationErrorResponse: v2ValidationError, }) if (!parsed.success) return parsed.response const body = parsed.data.body diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.ts b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.ts index 5e459182179..d3e3410117e 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.ts @@ -19,7 +19,7 @@ import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' import { workflowOperations } from '@/lib/workflows/application/operations' import { resumeWorkflowRun } from '@/lib/workflows/application/resume-run' import { ResumeWorkflowExecutionError } from '@/lib/workflows/executor/resume-execution' -import { type V2ErrorCode, v2Data, v2Error, v2ValidationError } from '@/app/api/v2/lib/response' +import { type V2ErrorCode, v2Data, v2Error } from '@/app/api/v2/lib/response' import { classifyExecutionError } from '@/executor/utils/errors' const logger = createLogger('V2WorkflowResumeAPI') @@ -55,7 +55,6 @@ export const POST = withRouteHandler( const parsed = await parseRequest(v2ResumeWorkflowContract, request, context, { ...V2_PARSE_DEFAULTS, maxBodyBytes: 10 * 1024 * 1024, - validationErrorResponse: v2ValidationError, }) if (!parsed.success) return parsed.response const { id: workflowId, runId } = parsed.data.params diff --git a/apps/sim/lib/api/cursor-binding.ts b/apps/sim/lib/api/cursor-binding.ts index 9f7205b935e..c093ef0b68c 100644 --- a/apps/sim/lib/api/cursor-binding.ts +++ b/apps/sim/lib/api/cursor-binding.ts @@ -1,4 +1,5 @@ import { createHash } from 'node:crypto' +import { filterUndefined } from '@sim/utils/object' /** * Caller-facing message for a cursor replayed under different filters. Separate @@ -155,7 +156,7 @@ export function fingerprint(canonical: string): string { * dropped, so omitting a filter and never having sent it are the same scope. */ export function cursorScopeKey(parts: Record): string | undefined { - const present = Object.entries(parts).filter(([, value]) => value !== undefined) - if (present.length === 0) return undefined - return fingerprint(canonicalJson(Object.fromEntries(present))) + const present = filterUndefined(parts) + if (Object.keys(present).length === 0) return undefined + return fingerprint(canonicalJson(present)) } diff --git a/apps/sim/lib/api/server/routes/v2-binary-route.ts b/apps/sim/lib/api/server/routes/v2-binary-route.ts index 95fa88c4ef1..c650fe2acb3 100644 --- a/apps/sim/lib/api/server/routes/v2-binary-route.ts +++ b/apps/sim/lib/api/server/routes/v2-binary-route.ts @@ -22,7 +22,7 @@ import { import { parseRequest } from '@/lib/api/server/validation' import type { ApplicationOperation } from '@/lib/core/application' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { v2Error, v2HeadNoEffect, v2HttpError, v2ValidationError } from '@/app/api/v2/lib/response' +import { v2Error, v2HeadNoEffect, v2HttpError } from '@/app/api/v2/lib/response' interface V2BinaryRouteOptions< C extends BinaryApiRouteContract, @@ -73,7 +73,6 @@ export function defineV2BinaryRoute< const parsed = await parseRequest(options.contract, request, context ?? {}, { ...V2_PARSE_DEFAULTS, - validationErrorResponse: v2ValidationError, }) if (!parsed.success) return parsed.response diff --git a/apps/sim/lib/api/server/routes/v2-json-route.ts b/apps/sim/lib/api/server/routes/v2-json-route.ts index 374bc712a83..1c284358dd1 100644 --- a/apps/sim/lib/api/server/routes/v2-json-route.ts +++ b/apps/sim/lib/api/server/routes/v2-json-route.ts @@ -135,6 +135,7 @@ export const v2InvalidJsonResponse = () => v2Error('BAD_REQUEST', 'Request body export const V2_PARSE_DEFAULTS = { payloadTooLargeResponse: v2PayloadTooLargeResponse, invalidJsonResponse: v2InvalidJsonResponse, + validationErrorResponse: v2ValidationError, /** See {@link blankQueryValueValidationError}. */ rejectBlankQueryValues: true, /** See {@link duplicateQueryValueValidationError}. */ diff --git a/apps/sim/lib/uploads/core/storage-key.ts b/apps/sim/lib/uploads/core/storage-key.ts index 4af3be2918d..9188814905d 100644 --- a/apps/sim/lib/uploads/core/storage-key.ts +++ b/apps/sim/lib/uploads/core/storage-key.ts @@ -28,11 +28,7 @@ export const LOCAL_STAGING_ROOT = '.staging' * * Every entry is ASCII, so `length` is its byte count. */ -const LOCAL_OBJECT_SIDECAR_SUFFIXES = [LOCAL_UPLOAD_METADATA_SUFFIX] as const - -const MAX_SIDECAR_SUFFIX_BYTES = Math.max( - ...LOCAL_OBJECT_SIDECAR_SUFFIXES.map((suffix) => suffix.length) -) +const MAX_SIDECAR_SUFFIX_BYTES = LOCAL_UPLOAD_METADATA_SUFFIX.length /** * Bytes a key's last component may occupy, sidecars accounted for. From 59d2e0bfd8194cce4ba070c172130ac2f36c2c65 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 13 Aug 2026 10:35:41 -0700 Subject: [PATCH 56/56] test(persistence): keep the wire round trip without tripping the utils audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check:utils forbids `JSON.parse(JSON.stringify(...))` and points at structuredClone, which is right for a deep clone and wrong here: this test exists to prove the schema accepts a `deployedAt` that arrived over HTTP as a string as well as an in-process `Date`. structuredClone preserves the `Date`, so adopting it would leave the test asserting nothing about the wire form. Splits the serialize and the parse into two statements. The round trip stays lossy — verified `JSON.parse(JSON.stringify(...))` yields a string where structuredClone yields a Date — and the pattern the audit matches is gone. Arrived from staging in #6660, so `check:audits` is red on origin/staging too, not only here. --- .../persistence/save-normalized-state.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/workflows/persistence/save-normalized-state.test.ts b/apps/sim/lib/workflows/persistence/save-normalized-state.test.ts index 9240f827fc9..decd033725a 100644 --- a/apps/sim/lib/workflows/persistence/save-normalized-state.test.ts +++ b/apps/sim/lib/workflows/persistence/save-normalized-state.test.ts @@ -40,9 +40,13 @@ describe('parseWorkflowStateForPersistence', () => { const deployedAt = new Date('2026-01-02T03:04:05.678Z') const fromDate = parseWorkflowStateForPersistence(checkpointState({ deployedAt })) - const overTheWire = parseWorkflowStateForPersistence( - JSON.parse(JSON.stringify(checkpointState({ deployedAt }))) - ) + /** + * Serialized and parsed as two steps, not `structuredClone`: the point is the + * lossy JSON round trip that turns the `Date` into a string, which a + * structured clone would preserve and so would not exercise the wire form. + */ + const serialized = JSON.stringify(checkpointState({ deployedAt })) + const overTheWire = parseWorkflowStateForPersistence(JSON.parse(serialized)) expect(fromDate.success).toBe(true) expect(overTheWire.success).toBe(true)