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
2 changes: 1 addition & 1 deletion apps/docs/openapi-v2-tables.json
Original file line number Diff line number Diff line change
Expand Up @@ -6806,7 +6806,7 @@
"enum": ["live", "deployed"]
},
"type": {
"description": "Replacement workflow-group producer type.",
"description": "Workflow-group producer type. Must match the group's stored type — a group's producer cannot be changed after creation.",
"type": "string",
"enum": ["manual", "enrichment"]
},
Expand Down
95 changes: 79 additions & 16 deletions apps/sim/app/api/v2/workflows/[id]/versions/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +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'
import { REFILTERED_CURSOR_MESSAGE, UNREADABLE_CURSOR_MESSAGE } from '@/lib/api/cursor-binding'

const mocks = vi.hoisted(() => ({
listVersions: vi.fn(),
Expand Down Expand Up @@ -43,6 +43,50 @@ const auth = {
}
const context = { params: Promise.resolve({ id: 'workflow-1' }) }

function contextFor(workflowId: string) {
return { params: Promise.resolve({ id: workflowId }) }
}

function listVersions(workflowId: string, query = '') {
return GET(
new NextRequest(`http://localhost/api/v2/workflows/${workflowId}/versions${query}`),
contextFor(workflowId)
)
}

/**
* A cursor as the route itself mints it, rather than a payload hand-built by
* the test. The binding a cursor carries is the route's to compute, so a test
* that reconstructs it would pass against a route that stopped applying one.
*/
async function mintCursor(workflowId: string): Promise<string> {
mocks.listVersions.mockResolvedValueOnce({
versions: [
{
id: 'version-5',
version: 5,
name: 'Production',
description: null,
isActive: true,
createdAt: new Date('2026-08-01T00:00:00.000Z'),
deployedByName: 'Ada',
latestOperationStatus: 'active',
},
],
hasMore: true,
})
const { nextCursor } = await (await listVersions(workflowId)).json()
expect(typeof nextCursor).toBe('string')
return nextCursor
}

/** A minted cursor's binding, carrying a forged payload inside it. */
async function forgeInsideBinding(workflowId: string, payload: unknown): Promise<string> {
const { scope } = JSON.parse(Buffer.from(await mintCursor(workflowId), 'base64').toString())
const inner = Buffer.from(JSON.stringify(payload)).toString('base64')
return Buffer.from(JSON.stringify({ scope, inner })).toString('base64')
}

describe('GET /api/v2/workflows/[id]/versions', () => {
beforeEach(() => {
vi.clearAllMocks()
Expand Down Expand Up @@ -125,33 +169,52 @@ describe('GET /api/v2/workflows/[id]/versions', () => {
['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
)
const cursor = await forgeInsideBinding('workflow-1', payload)
mocks.listVersions.mockClear()

const response = await listVersions('workflow-1', `?cursor=${encodeURIComponent(cursor)}`)

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
)
/**
* The regression guard for the binding: a list still pages through its OWN
* cursor. A token that no route accepts binds nothing — it just breaks
* pagination.
*/
it('resumes from the cursor it minted', async () => {
const cursor = await mintCursor('workflow-1')
mocks.listVersions.mockClear()

const response = await listVersions('workflow-1', `?cursor=${encodeURIComponent(cursor)}`)

expect(response.status).toBe(200)
expect(mocks.listVersions).toHaveBeenCalledWith(
expect.objectContaining({ input: expect.objectContaining({ afterVersion: 5 }) })
)
})

/**
* A `version` is an ordinal every workflow's history numbers from 1, so an
* unbound token from a sibling workflow decoded cleanly and resumed from a
* position in a history the caller never walked — silently skipping versions.
*/
it('refuses a cursor minted on another workflow', async () => {
const cursor = await mintCursor('workflow-1')
mocks.listVersions.mockClear()

const response = await listVersions('workflow-2', `?cursor=${encodeURIComponent(cursor)}`)

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

it('mints a cursor bound to the workflow that answered', async () => {
expect(await mintCursor('workflow-1')).not.toBe(await mintCursor('workflow-2'))
})

it('rejects an unauthenticated request', async () => {
v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError())

Expand Down
35 changes: 28 additions & 7 deletions apps/sim/app/api/v2/workflows/[id]/versions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,45 @@ import {
v2ListWorkflowVersionsContract,
v2WorkflowVersionCursorSchema,
} from '@/lib/api/contracts/v2/workflows'
import { UNREADABLE_CURSOR_MESSAGE } from '@/lib/api/cursor-binding'
import { cursorRoute, cursorScopeKey, 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'
import { listWorkflowVersions } from '@/lib/workflows/application/list-workflow-versions'
import { workflowOperations } from '@/lib/workflows/application/operations'
import { decodeCursor, encodeCursor } from '@/app/api/v2/lib/response'
import {
decodeCursor,
encodeCursor,
encodeScopedCursor,
readScopedCursor,
} from '@/app/api/v2/lib/response'

export const dynamic = 'force-dynamic'
export const revalidate = 0

/**
* The sequence a version cursor names a position in: this list, on THIS
* workflow.
*
* The payload is a bare `version` ordinal and every workflow numbers its
* history from 1, so an unscoped token minted on one workflow decoded cleanly
* against another and answered 200 from a position the caller never reached.
* The workflow id lives in the path, so the route is the only place that knows
* which history the ordinal counts within.
*/
function versionCursorScope(workflowId: string): string {
return cursorScopeKey(cursorRoute(v2ListWorkflowVersionsContract, { id: workflowId }))
}

export const GET = defineV2JsonRoute({
contract: v2ListWorkflowVersionsContract,
auth: v2ApiKeyAuth,
operation: workflowOperations.listVersions,
rateLimit: v2RateLimits.publicApi,
errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization,
mapInput: ({ params, query }) => {
const decoded = query.cursor
? v2WorkflowVersionCursorSchema.safeParse(decodeCursor(query.cursor))
: undefined
const inner = readScopedCursor(query.cursor, versionCursorScope(params.id))
const decoded = inner ? v2WorkflowVersionCursorSchema.safeParse(decodeCursor(inner)) : undefined
if (decoded && !decoded.success) {
throw new OrchestrationError('validation', UNREADABLE_CURSOR_MESSAGE)
}
Expand All @@ -34,7 +52,7 @@ export const GET = defineV2JsonRoute({
}
},
useCase: listWorkflowVersions,
present: ({ versions, hasMore }) => {
present: ({ versions, hasMore }, { params }) => {
const data: V2WorkflowVersion[] = versions.map((version) => ({
id: version.id,
version: version.version,
Expand All @@ -50,7 +68,10 @@ export const GET = defineV2JsonRoute({
data,
nextCursor:
hasMore && data.length > 0
? encodeCursor({ version: data[data.length - 1].version })
? encodeScopedCursor(
versionCursorScope(params.id),
encodeCursor({ version: data[data.length - 1].version })
)
: null,
}
},
Expand Down
37 changes: 30 additions & 7 deletions apps/sim/app/api/v2/workspaces/[workspaceId]/members/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {
v2ListWorkspaceMembersContract,
v2WorkspaceMemberCursorSchema,
} from '@/lib/api/contracts/v2/workspaces'
import { UNREADABLE_CURSOR_MESSAGE } from '@/lib/api/cursor-binding'
import { cursorRoute, cursorScopeKey, UNREADABLE_CURSOR_MESSAGE } from '@/lib/api/cursor-binding'
import {
defineV2JsonRoute,
v2ApiKeyAuth,
Expand All @@ -12,7 +12,26 @@ import {
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { listPublicWorkspaceMembers } from '@/lib/workspaces/application/list-public-workspace-members'
import { workspaceOperations } from '@/lib/workspaces/application/operations'
import { decodeCursor, encodeCursor } from '@/app/api/v2/lib/response'
import {
decodeCursor,
encodeCursor,
encodeScopedCursor,
readScopedCursor,
} from '@/app/api/v2/lib/response'

/**
* The sequence a member cursor names a position in: this roster, on THIS
* workspace.
*
* The payload is a bare email, and the same person is a member of every
* workspace they belong to, so an unscoped token minted on one roster decoded
* cleanly against another and resumed from an email that names a different
* position in it. The workspace id lives in the path, so the route is the only
* place that knows which roster the email indexes.
*/
function memberCursorScope(workspaceId: string): string {
return cursorScopeKey(cursorRoute(v2ListWorkspaceMembersContract, { workspaceId }))
}

/** GET /api/v2/workspaces/[workspaceId]/members — Effective member roster. */
export const GET = defineV2JsonRoute({
Expand All @@ -22,9 +41,8 @@ export const GET = defineV2JsonRoute({
rateLimit: v2RateLimits.publicApi,
errorPolicy: v2OrchestrationErrorPolicy,
mapInput: ({ params, query }) => {
const decoded = query.cursor
? v2WorkspaceMemberCursorSchema.safeParse(decodeCursor(query.cursor))
: undefined
const inner = readScopedCursor(query.cursor, memberCursorScope(params.workspaceId))
const decoded = inner ? v2WorkspaceMemberCursorSchema.safeParse(decodeCursor(inner)) : undefined
if (decoded && !decoded.success) {
throw new OrchestrationError('validation', UNREADABLE_CURSOR_MESSAGE)
}
Expand All @@ -35,7 +53,7 @@ export const GET = defineV2JsonRoute({
}
},
useCase: listPublicWorkspaceMembers,
present: ({ page }) => ({
present: ({ page }, { params }) => ({
data: page.members.map((member) => ({
email: member.email,
name: member.name,
Expand All @@ -44,6 +62,11 @@ export const GET = defineV2JsonRoute({
isExternal: member.isExternal,
joinedAt: member.joinedAt.toISOString(),
})),
nextCursor: page.nextEmail ? encodeCursor({ email: page.nextEmail }) : null,
nextCursor: page.nextEmail
? encodeScopedCursor(
memberCursorScope(params.workspaceId),
encodeCursor({ email: page.nextEmail })
)
: null,
}),
})
Loading
Loading