Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/docs/openapi-v2-files-audit.json
Original file line number Diff line number Diff line change
Expand Up @@ -1036,9 +1036,9 @@
"name": "resourceType",
"in": "query",
"required": false,
"description": "Filter by exact resource type.",
"description": "Filter by resource type. Accepts a comma-separated set; members are trimmed and deduplicated, and member order affects neither the result nor the cursor.",
"schema": {
"description": "Filter by exact resource type.",
"description": "Filter by resource type. Accepts a comma-separated set; members are trimmed and deduplicated, and member order affects neither the result nor the cursor.",
"type": "string"
}
},
Expand Down
20 changes: 17 additions & 3 deletions apps/docs/openapi-v2-knowledge.json
Original file line number Diff line number Diff line change
Expand Up @@ -2743,9 +2743,22 @@
},
"operator": {
"default": "eq",
"description": "Comparison operator; valid operators depend on the field type.",
"description": "Comparison operator; valid operators depend on the field type. Text tags accept eq, neq, contains, not_contains, starts_with, ends_with; number and date tags accept eq, neq, gt, gte, lt, lte, between; boolean tags accept eq, neq. An operator the tag's field type does not implement is rejected, never ignored.",
"examples": ["eq"],
"type": "string"
"type": "string",
"enum": [
"eq",
"neq",
"contains",
"not_contains",
"starts_with",
"ends_with",
"gt",
"gte",
"lt",
"lte",
"between"
]
},
"value": {
"anyOf": [
Expand All @@ -2763,7 +2776,7 @@
"examples": ["billing"]
},
"valueTo": {
"description": "Upper bound for the `between` operator.",
"description": "Upper bound for the `between` operator, and required whenever that operator is used.",
"anyOf": [
{
"type": "string"
Expand All @@ -2775,6 +2788,7 @@
}
},
"required": ["tagName", "value"],
"additionalProperties": false,
"title": "Knowledge search tag filter",
"description": "A structured tag filter applied to knowledge search."
},
Expand Down
12 changes: 8 additions & 4 deletions apps/docs/openapi-v2-logs.json
Original file line number Diff line number Diff line change
Expand Up @@ -133,20 +133,24 @@
"name": "minCost",
"in": "query",
"required": false,
"description": "Minimum execution cost in USD.",
"description": "Minimum execution cost in USD, from 0 to 1000000. A run is never charged a negative amount, so a negative bound is rejected rather than treated as a filter that matches every run.",
"schema": {
"type": "number",
"description": "Minimum execution cost in USD."
"minimum": 0,
"maximum": 1000000,
"description": "Minimum execution cost in USD, from 0 to 1000000. A run is never charged a negative amount, so a negative bound is rejected rather than treated as a filter that matches every run."
}
},
{
"name": "maxCost",
"in": "query",
"required": false,
"description": "Maximum execution cost in USD.",
"description": "Maximum execution cost in USD, from 0 to 1000000. A run is never charged a negative amount, so a negative bound is rejected rather than treated as a filter that matches every run.",
"schema": {
"type": "number",
"description": "Maximum execution cost in USD."
"minimum": 0,
"maximum": 1000000,
"description": "Maximum execution cost in USD, from 0 to 1000000. A run is never charged a negative amount, so a negative bound is rejected rather than treated as a filter that matches every run."
}
},
{
Expand Down
49 changes: 49 additions & 0 deletions apps/sim/app/api/v2/audit-logs/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,55 @@ describe('v2 audit-log routes', () => {
})
})

/**
* `resourceType` is split into an `inArray` downstream, so its spelling is a
* set the query acts on rather than the exact string the caller sent. The
* cursor must bind the members, not the text.
*/
it.each([
['reordered', 'workflow,file'],
['respaced', 'file,%20workflow'],
['repeated', 'file,workflow,file'],
])('resumes a cursor whose resourceType set is %s', async (_label, respelled) => {
const minted = await listLogs(
new NextRequest(
'http://localhost:3000/api/v2/audit-logs?organizationId=org-1&resourceType=file,workflow'
)
)
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&resourceType=${respelled}&cursor=${encodeURIComponent(nextCursor)}`
)
)

expect(resumed.status).toBe(200)
expect(mocks.list).toHaveBeenCalled()
})

it('still refuses a cursor replayed under a different resourceType set', async () => {
const minted = await listLogs(
new NextRequest(
'http://localhost:3000/api/v2/audit-logs?organizationId=org-1&resourceType=file,workflow'
)
)
const { nextCursor } = await minted.json()

mocks.list.mockClear()
const replayed = await listLogs(
new NextRequest(
`http://localhost:3000/api/v2/audit-logs?organizationId=org-1&resourceType=file,knowledge&cursor=${encodeURIComponent(nextCursor)}`
)
)

expect(replayed.status).toBe(400)
expect((await replayed.json()).error.message).toBe(REFILTERED_CURSOR_MESSAGE)
expect(mocks.list).not.toHaveBeenCalled()
})

it('projects typed admin-policy failures without leaking internals', async () => {
mocks.list.mockRejectedValueOnce(new OrchestrationError('forbidden', 'Admin required'))

Expand Down
4 changes: 2 additions & 2 deletions apps/sim/app/api/v2/audit-logs/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { v2ListAuditLogsContract } from '@/lib/api/contracts/v2/audit-logs'
import { cursorScopeKey, instantScopePart } from '@/lib/api/cursor-binding'
import { cursorScopeKey, instantScopePart, unorderedScopePart } from '@/lib/api/cursor-binding'
import {
defineV2JsonRoute,
v2ApiKeyAuth,
Expand Down Expand Up @@ -27,7 +27,7 @@ function auditLogCursorFilters(query: {
organizationId: query.organizationId,
includeDeparted: query.includeDeparted,
action: query.action,
resourceType: query.resourceType,
resourceType: unorderedScopePart(query.resourceType),
resourceId: query.resourceId,
workspaceId: query.workspaceId,
actorEmail: query.actorEmail,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,7 @@ import {
type UploadSessionRecord,
verifyUploadSessionToken,
} from '@/lib/uploads/upload-session/service'
import {
v2Error,
v2HttpError,
v2UploadDataPlaneError,
v2ValidationError,
} from '@/app/api/v2/lib/response'
import { v2Error, v2HttpError, v2UploadDataPlaneError } from '@/app/api/v2/lib/response'

interface LocalPartRouteParams {
params: Promise<{ uploadId: string; partNumber: string }>
Expand Down
7 changes: 1 addition & 6 deletions apps/sim/app/api/v2/uploads/[uploadId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,7 @@ import {
getOwnedUploadSession,
uploadSessionObjectMetadata,
} from '@/lib/uploads/upload-session/service'
import {
v2Error,
v2HttpError,
v2UploadDataPlaneError,
v2ValidationError,
} from '@/app/api/v2/lib/response'
import { v2Error, v2HttpError, v2UploadDataPlaneError } from '@/app/api/v2/lib/response'

interface LocalPutRouteParams {
params: Promise<{ uploadId: string }>
Expand Down
31 changes: 30 additions & 1 deletion apps/sim/app/api/v2/workflows/[id]/runs/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ vi.mock('@/lib/workflows/application/list-workflow-runs', () => ({
},
}))

import { REFILTERED_CURSOR_MESSAGE } from '@/lib/api/cursor-binding'
import { REFILTERED_CURSOR_MESSAGE, UNREADABLE_CURSOR_MESSAGE } from '@/lib/api/cursor-binding'
import { NoWorkspaceAccessError, PersonalApiKeysDisabledError } from '@/lib/core/application'
import { GET } from '@/app/api/v2/workflows/[id]/runs/route'

Expand Down Expand Up @@ -170,6 +170,35 @@ describe('GET /api/v2/workflows/[id]/runs', () => {
expect(mocks.listRuns).not.toHaveBeenCalled()
})

/**
* This list orders by the single `order` param — its query schema is
* `.strict()` and declares no `sortBy` — so the sort-mismatch wording would
* answer one 400 with advice that earns a second.
*/
it('names a cursor with unusable keys unreadable rather than blaming sortBy', async () => {
mocks.listRuns.mockResolvedValueOnce({
data: EXECUTIONS,
nextCursor: { startedAt: EXECUTIONS[1].startedAt, rowId: 'row-1' },
workflowId: 'workflow-1',
order: 'desc',
})

const { nextCursor } = await (await callGet()).json()
const payload = JSON.parse(Buffer.from(nextCursor, 'base64').toString())
const tampered = Buffer.from(
JSON.stringify({ ...payload, keys: ['not-a-date', 'row-1'] })
).toString('base64')

mocks.listRuns.mockClear()
const response = await callGet(`?cursor=${encodeURIComponent(tampered)}`)

expect(response.status).toBe(400)
const { error } = await response.json()
expect(error.message).toBe(UNREADABLE_CURSOR_MESSAGE)
expect(error.message).not.toMatch(/sortBy/)
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')

Expand Down
9 changes: 6 additions & 3 deletions apps/sim/app/api/v2/workflows/[id]/runs/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ import {
v2ListWorkflowRunsContract,
v2WorkflowRunListStatusValueSchema,
} from '@/lib/api/contracts/v2/workflows'
import { cursorScopeKey, instantScopePart } from '@/lib/api/cursor-binding'
import { INVALID_CURSOR_MESSAGE } from '@/lib/api/list-query'
import {
cursorScopeKey,
instantScopePart,
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'
Expand Down Expand Up @@ -53,7 +56,7 @@ export const GET = defineV2JsonRoute({
Number.isNaN(cursorDate.getTime()) ||
typeof cursorRowId !== 'string')
) {
throw new OrchestrationError('validation', INVALID_CURSOR_MESSAGE)
throw new OrchestrationError('validation', UNREADABLE_CURSOR_MESSAGE)
}

return {
Expand Down
62 changes: 55 additions & 7 deletions apps/sim/lib/api/contracts/v1/knowledge/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,18 +106,66 @@ export const v1ListKnowledgeDocumentsQuerySchema = z.object({
sortOrder: z.enum(['asc', 'desc']).default('desc'),
})

/**
* Comparison operators the tag filter builders implement, per field type.
*
* Both builders — `lib/knowledge/documents/tag-filter.ts` for document lists and
* `lib/knowledge/search/queries.ts` for search — recognize exactly these. An
* operator outside the set used to be admitted by the boundary and then diverge:
* the document list dropped the predicate and returned the whole knowledge base,
* while search fell through to equality. Enumerating them here makes both
* `default:` arms unreachable instead of load-bearing.
*/
export const KNOWLEDGE_TAG_FILTER_OPERATORS_BY_FIELD_TYPE = {
text: ['eq', 'neq', 'contains', 'not_contains', 'starts_with', 'ends_with'],
number: ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'between'],
date: ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'between'],
boolean: ['eq', 'neq'],
} as const satisfies Record<'text' | 'number' | 'date' | 'boolean', readonly string[]>

/** Every operator any field type implements; the field type narrows it further. */
export const KNOWLEDGE_TAG_FILTER_OPERATORS = [
'eq',
'neq',
'contains',
'not_contains',
'starts_with',
'ends_with',
'gt',
'gte',
'lt',
'lte',
'between',
] as const

/**
* POST `/api/v1/knowledge/search` tag filter — uses display `tagName` (not
* slot) and a default operator. Distinct from the in-app
* `documentTagFilterSchema`, which is slot-based and used for list filtering.
*
* Deliberately not strict. v1 has always stripped unrecognized keys, and
* rejecting them now would break shipped callers for no correctness win; v2
* layers `.strict()` on top of this schema instead. The one stripped key that
* changed an answer — a mis-cased `valueto` leaving a `between` filter with no
* upper bound, which the document list then discarded entirely — is caught on
* both versions by the `valueTo` rule below.
*/
export const v1SearchTagFilterSchema = z.object({
tagName: z.string(),
fieldType: z.enum(['text', 'number', 'date', 'boolean']).optional(),
operator: z.string().default('eq'),
value: z.union([z.string(), z.number(), z.boolean()]),
valueTo: z.union([z.string(), z.number()]).optional(),
})
export const v1SearchTagFilterSchema = z
.object({
tagName: z.string(),
fieldType: z.enum(['text', 'number', 'date', 'boolean']).optional(),
operator: z
.enum(KNOWLEDGE_TAG_FILTER_OPERATORS, {
error: `operator must be one of: ${KNOWLEDGE_TAG_FILTER_OPERATORS.join(', ')}`,
})
.default('eq'),
value: z.union([z.string(), z.number(), z.boolean()]),
valueTo: z.union([z.string(), z.number()]).optional(),
})
.refine((filter) => filter.operator !== 'between' || filter.valueTo !== undefined, {
message: 'valueTo is required when operator is "between"',
path: ['valueTo'],
})

/** POST `/api/v1/knowledge/search` body. */
export const v1KnowledgeSearchBodySchema = z
Expand Down
62 changes: 62 additions & 0 deletions apps/sim/lib/api/contracts/v1/knowledge/tag-filter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import { v1SearchTagFilterSchema } from '@/lib/api/contracts/v1/knowledge'

/**
* v1 is a shipped public API, so this schema stays lenient about keys it does
* not declare — that is deliberate, not an oversight, and the v2 element layers
* `.strict()` on top of it instead. What v1 does share with v2 is the operator
* enum and the `between` rule: an unrecognized operator used to reach the search
* query builder and be coerced to equality, silently answering a different
* question than the caller asked.
*/
describe('v1 knowledge search tag filter', () => {
it('still strips an unrecognized key rather than rejecting the request', () => {
const parsed = v1SearchTagFilterSchema.safeParse({
tagName: 'category',
operator: 'eq',
value: 'billing',
unknownKey: 'ignored',
})

expect(parsed.success).toBe(true)
expect(parsed.data).toEqual({ tagName: 'category', operator: 'eq', value: 'billing' })
})

it('rejects an operator no field type implements', () => {
expect(
v1SearchTagFilterSchema.safeParse({
tagName: 'category',
operator: 'nosuchop',
value: 'billing',
}).success
).toBe(false)
})

it('rejects "between" with no valueTo, including the mis-cased key that strips to it', () => {
expect(
v1SearchTagFilterSchema.safeParse({ tagName: 'score', operator: 'between', value: 1 }).success
).toBe(false)
expect(
v1SearchTagFilterSchema.safeParse({
tagName: 'score',
operator: 'between',
value: 1,
valueto: 5,
}).success
).toBe(false)
})

it('accepts a bounded between filter and defaults a missing operator to eq', () => {
expect(
v1SearchTagFilterSchema.safeParse({
tagName: 'score',
operator: 'between',
value: 1,
valueTo: 5,
}).success
).toBe(true)
expect(
v1SearchTagFilterSchema.safeParse({ tagName: 'category', value: 'billing' }).data
).toEqual({ tagName: 'category', operator: 'eq', value: 'billing' })
})
})
Loading
Loading